0x00 漏洞简介
该漏洞为ThinkPHP框架的反序列化漏洞,需要二次开发并实现反序列化功能才可利用,且该漏洞只能在linux服务器上使用,Windows无法使用
0x01 影响范围
ThinkPHP 5.0.24
0x02 复现过程
前置准备
由于该漏洞是TP框架导致的反序列化漏洞,所以修改ThinkPHP_ROOT/application/index/controller/index.php
中的代码如下:
class Index
{
public function index()
{
unserialize(base64_decode($_GET['x']));
}
}
这条链是利用Output
类中的__call
魔术方法来进行跳板,最终利用File
类来写入webshell
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);
}
}
现在从头开始分析POP链
起点:ThinkPHP_ROOT/thinkphp/library/think/process/pipes/Windows.php
__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 = [];
}
根据官方文档的描述,file_exists
函数的参数是字符串类型,那么这里就可以触发魔术方法__toString
跳板:ThinkPHP_ROOT/thinkphp/library/think/Model.php
__toString()
public function __toString()
{
return $this->toJson();
}
跟进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 : [];
}
我们目的是去调用Output
的__call
方法,那么就需要一个调用函数的地方,初步看这里有两个地方可以使用
但是要执行第一处$bindAttr = $modelRelation->getBindAttr();
就必须满足$modelRelation
类中存在getBindAttr()
函数的条件,这就与调用__call
魔术方法产生了分歧。那么就来看第二处,要执行第二处的语句需要满足以下条件
# 需要判断为真的条件
if (!empty($this->append)) # 1,参数可控
if (method_exists($this, $relation)) # 2,参数可控
if (method_exists($modelRelation, 'getBindAttr')) # 3,参数可控
if ($bindAttr) # 4,间接可控
# 需要判断为假的条件
if (is_array($name)) # 5,参数可控
elseif (strpos($name, '.')) # 6,参数可控
if (isset($this->data[$key])) # 7,间接可控(与四号条件和可控传参有关)
满足以上条件并使$value
为Output
对象即可触发__call
函数(Output
类中不存在getAttr()
方法)
满足1、2、5、6、7号条件后需要注意的两条语句
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
其中为了保证2号条件被满足,$relation
需要是Model类中的函数,这里找到一个方法getError()
,该方法可以保证参数可控
public function getError()
{
return $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;
}
很明显,只要满足$this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)
这个条件就可以通过可控传参$this->parent
将$value
赋为Output
对象
这里需要$modelRelation
为Relation类型,通过链可以知道选用了HasOne类:ThinkPHP_ROOT/thinkphp/library/think/model/relation/HasOne.php
(HasOne类继承了OneToOne类,OneToOne类继承了Relation类),此时可满足$this->parent && !$modelRelation->isSelfRelation()
两个条件
之后需要$modelRelation->getModel()
也为Output对象才能满足所有条件,跟进getModel()
方法:ThinkPHP_ROOT/thinkphp/library/think/model/Relation.php
public function getModel()
{
return $this->query->getModel();
}
这里$this->query
可控,这里链中用到了Query类的getModel方法
public function getModel()
{
return $this->model;
}
这里$this->model
可控,于是将其定义为Output对象即可满足条件,再回过头来看后续的代码
此时$value
为Output对象,并且HasOne继承的OneToOne中存在getBindAttr
方法,并且参数可控
public function getBindAttr()
{
return $this->bindAttr;
}
定义$this->bindAttr
为数组即可(注意键值不要与在Model类中定义的$this-data
相同),完成后即可调用Output的__call
方法
跳板:ThinkPHP_ROOT/thinkphp/library/think/console/Output.php
__call()
再来看一下Output的__call
方法
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);
}
}
block()
此时只要将可控变量$this->styles
置为getAttr即可调用block
方法,跟进block
方法
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}
writeln()
再跟进writeln
方法
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}
write()
跟进write
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
获取跳板set()
$this->handle
在Output中可控,全局搜索write来寻找可控点,为节省时间直接去看链子里用到的Memcached类(ThinkPHP_ROOT/thinkphp/library/think/session/driver/Memcached.php)
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
终点:ThinkPHP_ROOT/thinkphp/library/think/cache/driver/File.php
set()
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 = "<?phpn//" . 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;
}
}
此处需要注意两个变量:$filename
和$data
,其中$filename
由getCacheKey
方法控制,跟进。
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
定义,且最终会变为PHP文件。
再回来看$data
,它从$value
获取值,但是从之前的wirteln()
处获取的$value
值为固定值true,于是继续往下,发现写入文件成功后会执行setTagItem()方法,继续跟进。
setTagItem()
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
值由之前的文件名控制,那么就可以在文件名中入手,但是因为写入的文件会加入死亡exit(),就需要利用伪协议对payload进行rot13编码,这样exit()就会被编码失效。此时就可以写一个POC来向目标服务器写入webshell了。
0x03 POP链图示
0x04 最终效果
原文始发于微信公众号(绿罗Sec):ThinkPHP5.0.24反序列化POP链的简单分析