Thinkphp5.0.24 反序列化链学习

这条链是某师傅在先知对一个Tp5.0.24开发的CMS审计时发现的一个触发点的利用,本来该利用点只能完成SSRF与任意文件删除的,为了提升危害,于是又从TP5.0.24中挖掘出了这一条通过写入文件Getshell的的Pop链(师傅们实在tql,枯了)

关于这条链先知上已经有几篇分析过了,了解了几点:

1.当目标目录无写入权限时我们可以创建一个权限为0755的文件夹,然后写shell

2.使用php伪协议写入shell绕过死亡exit,因为文件名不可控所以Windows下无法利用(后面可以知道原因)

0x01

学习开始,首先开头还是Tp5.1.x中的那条链,具体看我的PaperBook仓库中有详细分析的过程,然后到removeFiles这个方法文件名传入对象触发__toString魔术方法然后到thinkphp/library/think/Model.php 853行的toArray方法开始,原来在5.1.x是利用访问某类中不存在的方法触发__call魔术方法,但是在5.0.x中hook变为静态属性(关于静态属性为何不能利用可以看php相关知识),所以得重新寻找可以利用的地方触发__call魔术方法,先看toArray这个方法在什么位置触发的__call,又是如何触发的 因为图片Base64显示太大了,所以直接代码吧 XD.

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
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;//另?前的条件为真执行 $value->getAttr
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}

看代码,首先append变量我们可控,然后前两个分支第一个是判断$name是否为数组,第二个是判断$name中是否存在".",前两个分支显然是不满足我们的需求的,于是直接到else中,我们看看else的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$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;//另?前的条件为真执行 $value->getAttr
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}

$relation = Loader::parseName($name, 1, false)追踪了一下意义不大,我们直接看满足条件后的赋值

1
2
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

我们要使$relation为我们当前类存在的方法(method_exists函数),且方法中返回的值我们可控,这里就找到了1608行的getError方法

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

其中error可控,代表$modelRelation可控,$value = $this->getRelationData($modelRelation)

$modelRelation经过getRelationData方法处理后赋值给了$value,我们跟踪一下getRelationData方法,看看他是如何处理$modelRelation,跟踪到639行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$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;
}

可以看到传入类型需为Relation,需要满足$this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)这样$value就能返回我们控制的值,其中$value设置的类中需要有getBindAttr方法,才能满足条件进入,在if判断中的3个条件,我们寻找满足条件的类,可以找到HasOne类满足我们的条件,类型为Relation,同时isSelfRelation(),$this->parent,get_class($modelRelation->getModel()) == get_class($this->parent),这3个条件中的值我们都可控,可以满足if的判断,但是可能我们会发现该类中并不能找到getBindAttr方法,无法通过method_exists的判断,但是看师傅的文章确实是使用该类,于是留心一下注意到了该类第一行的代码class HasOne extends OneToOne,继承自OneToOne类,跟进OneToOne,搜索getBindAttr方法,在222行找到该方法,其中bindAttr值可控

1
2
3
4
5
    //thinkphp/library/think/model/relation/OneToOne.php Line:222
public function getBindAttr()
{
return $this->bindAttr;
}

到了我们触发__call魔术方法的关键之处

1
2
3
4
5
6
7
8
9
$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;
}

$bindAttr中的值是从OneToOne中的getBindAttr方法获取的,其中的值我们知道我们可控,$attr可控,$value可控我们就可以触发__call方法,寻找符合条件的类找到thinkphp/library/think/console/Output.php类208行中的__call方法,其中会调用该类中的block方法,需满足in_array($method, $this->styles)因为styles的值我们可控,设置为包含getAttr的数组即可

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

所以成功进入return call_user_func_array([$this, 'block'], $args);,array_unshift这处在5.1的链中已经提过,在这条链中无太大影响故不提,我们继续追踪block方法,在Output类的122行

1
2
3
4
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

调用了writeln方法,我们跟踪writeln方法,在141行

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

随后又调用了write方法,在152行

1
2
3
4
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}

其中handle可控,也就是说我们能访问任意带有write方法的类,全局查找write方法(一般在继承File类的类中)

找到thinkphp/library/think/session/driver/Memcached.php(发现thinkphp/library/think/session/driver/Memcache.php文件也实现了相同功能,两者应该都能使用) 92行处

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

这里调用了set方法,handler还是一样可控,我们可以访问带有set方法类,在文件thinkphp/library/think/cache/driver/File.php 141行

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;
}
}

这里获取文件名是通过getCacheKey方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function getCacheKey($name, $auto = false)
{
$name = md5($name);
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}

这里需将可控数组options中的值cache_subdir与prefix设置一下,$filename是从options数组取path的值与传入的$name进行拼接为文件名,然后通过dirname函数返回目录,并判断目录是否存在,不存在则调用mkdir函数创建并返回$filename,这里文件名部分已经弄清楚了,我们来看要写入的数据,$value通过序列化后赋值给了$data变量,然后进行数据压缩,这一步我们可以设置options数组中的data_compress值来跳过这一步骤,随后在数据拼接的时候又加入了死亡exit,这时候可以利用php中伪协议来处理数据绕过,然后就到了file_put_contents函数中,但是$data我们从前面跟踪下来发现是无法控制的(前面默认传入的参数为true),我们继续看代码,当执行完file_put_contents函数后将返回结果赋值给$result然后执行了isset($first) && $this->setTagItem($filename),我们看setTagItem方法做了什么,首先$filename传入了该方法即$name变量

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

此时tag变量可控,$key的值我们就可以知道,然后$name($filename)又赋值给$value,最后将$key与$value继续传入set方法(此时写入的内容可控即第一次写入的文件名),文件名为$key即tag_md5(tag)->getCacheKey()->文件名:(path+md5(tag_md5(tag))+.php),将path中的值设置为带有php代码的值并进行处理绕过死亡exit即可实现写入webshell也正是因为文件名中path的值为php代码导致该链只能在linux下使用

0x02

流程大概是

  1. 第一次写入文件,文件内容不可控,然后进入setTagItem方法第二次写文件

  2. 第二次写文件,文件内容为第一次序列化后的文件名其中包含rot13的字符串和php死亡exit的代码,文件名为options[“path”]+md5(tag_md5(tag))+.php

  3. 但是经过php://filter/write=string.rot13/resource=<?cuc cucvasb();?>+文件名将写入文件的exit代码rot13编码 而 前面文件名已经rot13编码过的字符则还原成php代码自此成功写入webshell且文件名我们可以计算得出

具体的Exp在师傅们的Blog中已经给出,就不写出来了,因为最近重新装了系统,linux环境没换上就没有经典的弹calc图了,求原谅 :)

师傅们tql Orz

0x03 补充

在先知中已经看到有师傅提供了在Windows下利用的解决方法,这里补充一下

之前对Thinkphp5.0.24中存在的反序列化利用链进行了分析,说到因为写入方式的问题导致在Windows环境下不可用的问题,后面在先知看到某师傅的一篇文章,发现还是有办法在Windows环境下控制文件名进行写入,文章地址:关于 ThinkPHP5.0 反序列化链的扩展

重新跟踪

thinkphp/library/think/session/driver/Memcache.php文件的92行处进行了文件写入

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

至于与WIndows文件写入有啥关系,我们继续向下跟

我们可以绕一下走thinkphp\library\think\cache\driver\Memcached.php 101行下也有set方法其中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
if ($this->tag && !$this->has($name)) {
$first = true;
}
$key = $this->getCacheKey($name);
$expire = 0 == $expire ? 0 : $_SERVER['REQUEST_TIME'] + $expire;
if ($this->handler->set($key, $value, $expire)) {
isset($first) && $this->setTagItem($key);
return true;
}
return false;
}

这里**$this->handler->set** 我们把handler设置为File类 然后回顾上面我们File类中set方法写文件过程

这里第一次写入的文件不用理(也就是第一次调用File的set方法)

这时候进入setTagItem方法$key 此时作为第二次set的参数 也就是说参数此时可控了

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

因为上一步处理的文件名的方法中可以看到options[‘prefix’] 我们是可控的 赋值为Base64的恶意代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function getCacheKey($name, $auto = false)
{
$name = md5($name);
if ($this->options['cache_subdir']) {
// 使用子目录
$name = substr($name, 0, 2) . DS . substr($name, 2);
}
if ($this->options['prefix']) {
$name = $this->options['prefix'] . DS . $name;
}
$filename = $this->options['path'] . $name . '.php';
$dir = dirname($filename);

if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}

等于传入File的set方法我们的值可控 我们只需要将死亡exit去除

Memcache里面的set方法进入File里的set方法

1
if ($this->handler->set($key, $value, $expire)) 

此时$key为setTagItem方法里面的’tag_’ . md5($this->tag); $value为上一步传入的$key 即我们可控制的值 此时控制File set中getCacheKey 方法中options[‘path’]将我们的文件名改为php://filter/convert.base64-decode/resource=./ 即可将死亡exit去除