ThinkPHP5.0.24反序列化POP链的简单分析

渗透技巧 2年前 (2022) admin
607 0 0

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

ThinkPHP5.0.24反序列化POP链的简单分析

跳板: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方法,那么就需要一个调用函数的地方,初步看这里有两个地方可以使用

ThinkPHP5.0.24反序列化POP链的简单分析

但是要执行第一处$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,间接可控(与四号条件和可控传参有关)

满足以上条件并使$valueOutput对象即可触发__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对象即可满足条件,再回过头来看后续的代码

ThinkPHP5.0.24反序列化POP链的简单分析

此时$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,其中$filenamegetCacheKey方法控制,跟进。

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链图示

ThinkPHP5.0.24反序列化POP链的简单分析

0x04 最终效果

ThinkPHP5.0.24反序列化POP链的简单分析


ThinkPHP5.0.24反序列化POP链的简单分析


原文始发于微信公众号(绿罗Sec):ThinkPHP5.0.24反序列化POP链的简单分析

版权声明:admin 发表于 2022年2月21日 下午9:42。
转载请注明:ThinkPHP5.0.24反序列化POP链的简单分析 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...