最近CTF中TP反序列化考的比较频繁,从前段时间的N1CTF到最近的安洵杯都利用了thinkphp反序列化,疯狂填坑,审计挖掘了下TP5、TP6反序列化中的利用链,本篇主要总结下TP6利用链的挖掘思路。小白文章,大佬们请略过。。。
TP5反序列化入口都是在Windows类的析构方法,通过file_exists()
函数触发__toString
魔术方法,然后以__toString
为中间跳板寻找代码执行点,造成反序列化任意命令执行。有关TP5的分析可以看挖掘暗藏thinkphp中的反序列利用链这篇文章,感觉分析的思路比较好,本篇分析TP6,也是按照文中的思路来的。
TP6的不同之处就是没有了Windows类,也就无法利用其中的析构方法作为反序列化入口,需要重新挖掘其他入口点。
序列化:将php值转换为可存储或传输的字符串,目的是防止丢失其结构和数据类型。
反序列化:序列化的逆过程,将字符串再转化成原来的php变量,以便于使用。
简单来说,就是涉及php中的serialize与unserialize两个函数。
魔术方法:在php中以两个下划线字符(__)开头的方法,方法名都是PHP预先定义好的,之所以称为魔术方法
就是这些方法不需要显示的调用而是由某种特定的条件触发执行。
常用的魔术方法:
__constuct
: 构建对象的时被调用
__destruct
: 明确销毁对象或脚本结束时被调用
__wakeup
: 当使用unserialize时被调用,可用于做些对象的初始化操作
__sleep
: 当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用
__call
: 调用不可访问或不存在的方法时被调用
__callStatic
: 调用不可访问或不存在的静态方法时被调用
__set
: 当给不可访问或不存在属性赋值时被调用
__get
: 读取不可访问或不存在属性时被调用
__isset
: 对不可访问或不存在的属性调用isset()或empty()时被调用
__unset
: 对不可访问或不存在的属性进行unset时被调用
__invoke
: 当以函数方式调用对象时被调用
__toString
: 当一个类被转换成字符串时被调用
__clone
: 进行对象clone时被调用,用来调整对象的克隆行为
__debuginfo
: 当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本
__set_state
: 当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值
反序列化漏洞就是通过多个类,赋予一定条件,使其自动调用魔术方法,最终达到代码执行点。过程包括起点、中间跳板、终点。
最常用的就是反序列化时触发的魔术方法:
__destruct
: 明确销毁对象或脚本结束时被调用
__wakeup
: 当使用unserialize时被调用,可用于做些对象的初始化操作
有关字符串操作可以触发的魔术方法:
__toString
: 当一个类被转换成字符串时被调用
触发的情况有:
用到打印有关函数时,如echo/ print等
拼接字符串时
格式化字符串时
与字符串进行==比较时
格式化SQL语句,绑定参数时
数组中有字符串时
__toString
: 当一个类被转换成字符串时被调用
__call
: 调用不可访问或不存在的方法时被调用
__callStatic
: 调用不可访问或不存在的静态方法时被调用
__set
: 当给不可访问或不存在属性赋值时被调用
__get
: 读取不可访问或不存在属性时被调用
__isset
: 对不可访问或不存在的属性调用isset()或empty()时被调用
__call
: 调用不可访问或不存在的方法时被调用
call_user_func
、call_user_func_array
等代码执行点
主要分析三篇利用链的挖掘思路,网上也有很多分析,但是发现很多POC都不能用,因此自己分析构造下POC。
TP6.0安装参照ThinkPHP6手册,从5.2版本开始不能利用下载的方法,需要利用composer。
composer create-project topthink/think TP-6.0 6.0.*-dev
起点的挖掘可以利用直接搜索常用入口魔法函数的方法。
第一条利用链选择从Model
类分析:
vendor/topthink/think-orm/src/Model.php
满足$this->lazySave
为true 就可以进入save
方法,跟进下save
方法:
发现满足条件就可以进入updateData
方法:
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) { return false;}$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
需要满足:
$this->isEmpty()为false
$this->trigger('BeforeWrite') 为true
$this->exists 为true
跟进下:
$this->isEmpty()为false 需要$this->data不为空
$this->trigger('BeforeWrite') 为true 需要$this->withEvent 为false
$this->exists 为true
进入updateData
函数:
protected function updateData(): bool{ // 事件回调 if (false === $this->trigger('BeforeUpdate')) { return false; } $this->checkData(); // 获取有更新的数据 $data = $this->getChangedData(); if (empty($data)) { // 关联更新 if (!empty($this->relationWrite)) { $this->autoRelationUpdate(); } return true; } if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) { // 自动写入更新时间 $data[$this->updateTime] = $this->autoWriteTimestamp($this->updateTime); $this->data[$this->updateTime] = $data[$this->updateTime]; } // 检查允许字段 $allowFields = $this->checkAllowFields(); foreach ($this->relationWrite as $name => $val) { if (!is_array($val)) { continue; } foreach ($val as $key) { if (isset($data[$key])) { unset($data[$key]); } } } // 模型更新 $db = $this->db(); $db->startTrans(); try { $this->key = null; $where = $this->getWhere(); $result = $db->where($where) ->strict(false) ->cache(true) ->setOption('key', $this->key) ->field($allowFields) ->update($data); $this->checkResult($result); // 关联更新 if (!empty($this->relationWrite)) { $this->autoRelationUpdate(); } $db->commit(); // 更新回调 $this->trigger('AfterUpdate'); return true; } catch (\Exception $e) { $db->rollback(); throw $e; }}
然后跟进函数分析,发现checkAllowFields
函数的db
函数存在提到的拼接字符串操作,因此可以触发__toString
然后再分析updateData
函数和checkAllowFields
函数 看下进入db
函数的条件:
首先是updateData
函数:
跟进getChangedData()
函数:
满足$this->force
为true即可,这样进入到checkAllowFields
函数:
this->field为空,
跟进发现只要$this->connection
为mysql
即可。
梳理下思路:
//寻找一个入口魔术方法
//可以利用 vendor/topthink/think-orm/src/Model.php
public function __destruct()
{
if ($this->lazySave) { //需要满足$this->lazySave为true
$this->save();
}
}
public function save(array $data = [], string $sequence = null): bool
{
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}//需要满足 $this->data不为空 $this->withEvent为false
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
}//$this->exists为true
protected function updateData(): bool
{
if (false === $this->trigger('BeforeUpdate')) {
return false;//$this->withEvent为false已经满足
}
$data = $this->getChangedData();//$this->force 为true
$allowFields = $this->checkAllowFields();
}
protected function checkAllowFields(): array
{
if (empty($this->field)) {//$this->field 为空
if (!empty($this->schema)) {//$this->schema 为空
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
}
}
}
public function db($scope = []): Query
{
$query = self::$db->connect($this->connection)//$this->connection为mysql
->name($this->name . $this->suffix)
->pk($this->pk);
return $query;
}
前边构造条件已经触发__toString
函数,现在需要寻找可利用类的__toString
。
通过审计发现后续利用思路和TP5.2版本利用动态代码执行是一样的,这里只做简单分析。
通过搜索不难发现熟悉的Conversion
类,直接利用TP5.2的利用链:
跟进函数:
public function toArray(): array{ $item = []; $hasVisible = false; foreach ($this->visible as $key => $val) { if (is_string($val)) { if (strpos($val, '.')) { [$relation, $name] = explode('.', $val); $this->visible[$relation][] = $name; } else { $this->visible[$val] = true; $hasVisible = true; } unset($this->visible[$key]); } } foreach ($this->hidden as $key => $val) { if (is_string($val)) { if (strpos($val, '.')) { [$relation, $name] = explode('.', $val); $this->hidden[$relation][] = $name; } else { $this->hidden[$val] = true; } unset($this->hidden[$key]); } } // 合并关联数据 $data = array_merge($this->data, $this->relation); foreach ($data as $key => $val) { if ($val instanceof Model || $val instanceof ModelCollection) { // 关联模型对象 if (isset($this->visible[$key]) && is_array($this->visible[$key])) { $val->visible($this->visible[$key]); } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) { $val->hidden($this->hidden[$key]); } // 关联模型对象 if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) { $item[$key] = $val->toArray(); } } elseif (isset($this->visible[$key])) { $item[$key] = $this->getAttr($key); } elseif (!isset($this->hidden[$key]) && !$hasVisible) { $item[$key] = $this->getAttr($key); } } // 追加属性(必须定义获取器) foreach ($this->append as $key => $name) { $this->appendAttrToArray($item, $key, $name); } return $item;}
进入toArray 函数后,TP5.2有两个思路,一个是利用getAttr的getValue 函数,然后value = closure(value, this->data);动态调用;另一个思路是进入appendAttrToArray 函数,利用relation->visible(
toArray 方法中data = array_merge(this->data, this->relation);是可控的,所以key] = this->getAttr(key); 中的key 也是可控的,进入getAttr 函数:
首先进入getData
函数看下$value值的处理:
可以发现$value为可控的,由此getValue
函数的参数都是可控,进入到getValue
函数:
this->withAttr可控,
这样就可以执行任意代码。
梳理下思路:
// vendor/topthink/think-orm/src/model/concern/Conversion.php
public function __toString()
{
return $this->toJson();
}
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
public function toArray(): array
{
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val)
$item[$key] = $this->getAttr($key);
}
// vendor/topthink/think-orm/src/model/concern/Attribute.php
public function getAttr(string $name)
{
return $this->getValue($name, $value, $relation);
}
protected function getValue(string $name, $value, bool $relation = false)
{
$closure = $this->withAttr[$fieldName];
$value = $closure($value, $this->data);
}
这样一跳完整的利用链就出来。
利用起点挖掘的时候发现还存在其他起点。
vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php
需要满足$this->autosave
为false,进入save
函数,发现并没有实现什么功能,参考网上师傅分析的思路,AbstractCache
类的子类有没有实现该函数:
进入到vendor/topthink/framework/src/think/filesystem/CacheStore.php
,发现了save
方法:
看到可控的this->store可以触发任意类的set方法只要找到任意类存在危险操作的set 方法即可利用。
跟进下$this->getForStorage
:
this->cache可控,
寻找一处存在危险行为的set
方法:
vendor/topthink/framework/src/think/cache/driver/File.php
:
public function set($name, $value, $expire = null): bool{ $this->writeTimes++; if (is_null($expire)) { $expire = $this->options['expire']; } $expire = $this->getExpireTime($expire); $filename = $this->getCacheKey($name); $dir = dirname($filename); if (!is_dir($dir)) { try { mkdir($dir, 0755, true); } catch (\Exception $e) { // 创建失败 } } $data = $this->serialize($value); if ($this->options['data_compress'] && function_exists('gzcompress')) { //数据压缩 $data = gzcompress($data, 3); } $data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
clearstatcache();
return true;
}
return false;}
分析set
方法,跟踪下几个重要的函数:
$filename = $this->getCacheKey($name);
public function getCacheKey(string $name): string{ $name = hash($this->options['hash_type'], $name); if ($this->options['cache_subdir']) { // 使用子目录 $name = substr($name, 0, 2) . DIRECTORY_SEPARATOR . substr($name, 2); } if ($this->options['prefix']) { $name = $this->options['prefix'] . DIRECTORY_SEPARATOR . $name; } return $this->options['path'] . $name . '.php';}
$this->options
可控,所以getCacheKey
返回的值完全可控。
$data = $this->serialize($value);
protected function serialize($data): string{ if (is_numeric($data)) { return (string) $data; } $serialize = $this->options['serialize'][0] ?? "\Opis\Closure\serialize"; return $serialize($data);}
this->options['serialize'][0]可控,data 为我们传入set函数的value,也就是this->store->set(contents, this->expire); 中的content,是可控的。只不过此时data 经过json编码。
不难发现这里我们可以构造动态代码执行,测试下这个过程(本地实验是在windows下所以利用&
或者||
,linux下直接利用反引号即可。
<?php$contents = ["test"=>"\"||dir||\""];$cachedProperties = array_flip([
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',]);foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}}$contents = json_encode($contents);$options = ["system"];$data = $contents;var_dump($data);$serialize = $options[0];$serialize($data);
梳理下思路:
// vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php
// abstract class AbstractCache 抽象类
// protected $cache = [];
// protected $complete = [];
public function __destruct()
{
if (! $this->autosave) { //$this->autosave=false
$this->save();
}
}
// vendor/topthink/framework/src/think/filesystem/CacheStore.php
//use League\Flysystem\Cached\Storage\AbstractCache;
// class CacheStore
// protected $key;
// protected $expire;
public function save()
{
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}//$this->store = new File();
// vendor/topthink/framework/src/think/cache/driver/File.php
// // use think\cache\Driver;
// class File extends Driver
public function set($name, $value, $expire = null): bool
{
$data = $this->serialize($value);
}
// vendor/topthink/framework/src/think/cache/Driver.php
// abstract class Driver
// protected $options = [];
protected function serialize($data): string
{
$serialize = $this->options['serialize'][0];
return $serialize($data);//命令执行点
}
还没完 ,继续分析set
方法:
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;$result = file_put_contents($filename, $data);
发现还存在一个任意文件写入的点,只不过存在一个死亡exit,CTF中常见的一个点,利用p牛的php://filter协议的base64编码很轻松就能绕过。前面提到过filename可控,data 也可控,所以可以GETSHELL。
PS:这里只梳理触发的过程,防止不必要的麻烦,不放出POC,具体参数在分析过程中都提到了。
vendor/topthink/think-orm/src/Model.php
入口在Model
类的__destruct
方法,但是此类为抽象类无法实例化,找到了它的子类Pivot
类
vendor/topthink/think-orm/src/model/Pivot.php
以实例化Pivot
类为起点
然后给有关参数赋值,满足一定条件层层触发:
this->save > this->checkAllowFields >
在$this->db()
中字符串拼接,触发__toString
vendor/topthink/think-orm/src/model/concern/Conversion.php
触发了Conversion
类的__toString
,Conversion
类为Trait类,在Model
类中利用,只需赋值然后触发:
this->toJson >
vendor/topthink/think-orm/src/model/concern/Attribute.php
为Trait类,在Model
类中利用
getAttr
> $this->getValue
$closure = $this->withAttr[$fieldName];$value = $closure($value, $this->data);
动态函数执行。
vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php
入口为AbstractCache
类的__destruct
方法 该类为抽象类找到其子类CacheStore
vendor/topthink/framework/src/think/filesystem/CacheStore.php
进入子类的$this->save
调用任意类的set
函数:
$this->store->set($this->key, $contents, $this->expire);
调用File
类 vendor/topthink/framework/src/think/cache/driver/File.php
$this->serialize
然后命令执行:
vendor/topthink/framework/src/think/cache/Driver.php
Driver
类为抽象类,在File
类 中有调用
return $serialize($data);
执行命令。
前部分和利用链二一样,只是在最后GetShell的方法不同,利用File
类任意文件写入shell
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;$result = file_put_contents($filename, $data);
先找到入口文件,然后再层层跟进,找到代码执行点等危险操作。
特别注意魔法函数、任意类和函数的调用、以及子类等的综合分析
构造POC注意复用类和抽象类的问题:
发现类是Trait类,Trait类PHP 5.4.0开始引入的一种代码复用技术,是为解决PHP单继承而准备的一种代码复用机制,无法通过 trait
自身来实例化,需要找到复用它的类来利用。
抽象类也不能实例化,需要找到子类普通类来实例化。
再就是ThinkPHP命名空间的问题:
命名空间基础可以参考php文档,参照文档很好理解三种引用方式,文档中将命名空间与文件系统作类比:
TinkPHP采用命名空间,那么我们构造POC的时候也应利用命名空间的方法调用不同类和函数,构造POC就是在一个文件中定义多个命名空间,文档中也有说明。有两种方式:简单组合语法和大括号语法
简单组合语法:
<?phpnamespace MyTest1;class Test {}function test() {}namespace MyTest2;class Test {}function test() {}?>
不推荐这种方法。
大括号语法:
<?phpnamespace MyTest1{
class Test {}
function test() {}}namespace MyTest2{
class Test {}
function test() {}}?>
构造POC的最后还会用到全局非命名空间:
将全局的非命名空间中的代码与命名空间中的代码组合在一起,只能使用大括号形式的语法。全局代码必须用一个不带名称的 namespace 语句加上大括号括起来
<?phpnamespace MyTest1{
class Test {}
function test() {}}namespace MyTest2{
class Test {}
function test() {}}namespace{
$v = new MyTest2\Test();
$s = new MyTest1\Test();
$this -> xxx = $v;
echo serialize($s);}?>
挖掘利用链真好玩,phpstorm真香。
挖掘暗藏ThinkPHP中的反序列利用链 ThinkPHP6.X反序列化利用链 ThinkPHP 6.0.x反序列化(二) PHP手册-命名空间