TP6.0反序列化利用链挖掘思路总结
2019-12-15 07:02:00 Author: xz.aliyun.com(查看原文) 阅读量:335 收藏

最近CTF中TP反序列化考的比较频繁,从前段时间的N1CTF到最近的安洵杯都利用了ThinkPHP反序列化,疯狂填坑,审计挖掘了下TP5、TP6反序列化中的利用链,本篇主要总结下TP6利用链的挖掘思路。小白文章,大佬们请略过。。。

TP5反序列化入口都是在Windows类的析构方法,通过file_exists()函数触发__toString 魔术方法,然后以__toString为中间跳板寻找代码执行点,造成反序列化任意命令执行。有关TP5的分析可以看挖掘暗藏thinkphp中的反序列利用链这篇文章,感觉分析的思路比较好,本篇分析TP6,也是按照文中的思路来的。

TP6的不同之处就是没有了Windows类,也就无法利用其中的析构方法作为反序列化入口,需要重新挖掘其他入口点。

基础知识

1.PHP反序列化

序列化:将php值转换为可存储或传输的字符串,目的是防止丢失其结构和数据类型。

反序列化:序列化的逆过程,将字符串再转化成原来的php变量,以便于使用。

简单来说,就是涉及php中的serialize与unserialize两个函数。

2.PHP魔术方法

魔术方法:在php中以两个下划线字符(__)开头的方法,方法名都是PHP预先定义好的,之所以称为魔术方法 就是这些方法不需要显示的调用而是由某种特定的条件触发执行。

常用的魔术方法:

__constuct: 构建对象的时被调用

__destruct: 明确销毁对象或脚本结束时被调用

__wakeup: 当使用unserialize时被调用,可用于做些对象的初始化操作

__sleep: 当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用

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

__callStatic: 调用不可访问或不存在的静态方法时被调用

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

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

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

__unset: 对不可访问或不存在的属性进行unset时被调用

__invoke: 当以函数方式调用对象时被调用

__toString: 当一个类被转换成字符串时被调用

__clone: 进行对象clone时被调用,用来调整对象的克隆行为

__debuginfo: 当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本

__set_state: 当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值

3.反序列化漏洞利用过程

反序列化漏洞就是通过多个类,赋予一定条件,使其自动调用魔术方法,最终达到代码执行点。过程包括起点、中间跳板、终点。

起点

最常用的就是反序列化时触发的魔术方法:

__destruct: 明确销毁对象或脚本结束时被调用

__wakeup: 当使用unserialize时被调用,可用于做些对象的初始化操作

有关字符串操作可以触发的魔术方法:

__toString: 当一个类被转换成字符串时被调用

触发的情况有:

用到打印有关函数时,如echo/ print等
拼接字符串时
格式化字符串时
与字符串进行==比较时
格式化SQL语句,绑定参数时
数组中有字符串时

中间跳板

__toString: 当一个类被转换成字符串时被调用

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

__callStatic: 调用不可访问或不存在的静态方法时被调用

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

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

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

终点

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

call_user_funccall_user_func_array等代码执行点

利用链挖掘

主要分析三篇利用链的挖掘思路,网上也有很多分析,但是发现很多POC都不能用,因此自己分析构造下POC。

1.环境搭建

TP6.0安装参照Thinkphp6手册,从5.2版本开始不能利用下载的方法,需要利用composer。

composer create-project topthink/think TP-6.0 6.0.*-dev

2.利用起点

起点的挖掘可以利用直接搜索常用入口魔法函数的方法。

第一条利用链选择从Model类分析:

vendor/topthink/think-orm/src/Model.php

满足$this->lazySave 为true 就可以进入save 方法,跟进下save方法:

发现满足条件就可以进入updateData 方法:

if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
    return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

需要满足:

$this->isEmpty()为false
$this->trigger('BeforeWrite') 为true
$this->exists 为true

跟进下:

$this->isEmpty()为false 需要$this->data不为空
$this->trigger('BeforeWrite') 为true 需要$this->withEvent 为false
$this->exists 为true

进入updateData函数:

protected function updateData(): bool
{
    // 事件回调
    if (false === $this->trigger('BeforeUpdate')) {
        return false;
    }
    $this->checkData();
    // 获取有更新的数据
    $data = $this->getChangedData();
    if (empty($data)) {
        // 关联更新
        if (!empty($this->relationWrite)) {
            $this->autoRelationUpdate();
        }
        return true;
    }
    if ($this->autoWriteTimestamp && $this->updateTime && !isset($data[$this->updateTime])) {
        // 自动写入更新时间
        $data[$this->updateTime]       = $this->autoWriteTimestamp($this->updateTime);
        $this->data[$this->updateTime] = $data[$this->updateTime];
    }
    // 检查允许字段
    $allowFields = $this->checkAllowFields();

    foreach ($this->relationWrite as $name => $val) {
        if (!is_array($val)) {
            continue;
        }

        foreach ($val as $key) {
            if (isset($data[$key])) {
                unset($data[$key]);
            }
        }
    }

    // 模型更新
    $db = $this->db();
    $db->startTrans();

    try {
        $this->key = null;
        $where     = $this->getWhere();

        $result = $db->where($where)
            ->strict(false)
            ->cache(true)
            ->setOption('key', $this->key)
            ->field($allowFields)
            ->update($data);

        $this->checkResult($result);

        // 关联更新
        if (!empty($this->relationWrite)) {
            $this->autoRelationUpdate();
        }

        $db->commit();

        // 更新回调
        $this->trigger('AfterUpdate');

        return true;
    } catch (\Exception $e) {
        $db->rollback();
        throw $e;
    }
}

然后跟进函数分析,发现checkAllowFields函数的db 函数存在提到的拼接字符串操作,因此可以触发__toString

然后再分析updateData函数和checkAllowFields函数 看下进入db函数的条件:

首先是updateData函数:

跟进getChangedData()函数:

满足$this->force为true即可,这样进入到checkAllowFields 函数:

$this->field为空,$this->schema 为空即可进入db函数,看一下拼接字符串需要满足的条件:

跟进发现只要$this->connectionmysql 即可。

梳理下思路:

//寻找一个入口魔术方法
//可以利用 vendor/topthink/think-orm/src/Model.php
public function __destruct()
{
    if ($this->lazySave) { //需要满足$this->lazySave为true
        $this->save();
    }
}


public function save(array $data = [], string $sequence = null): bool
{
    if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
        return false;
    }//需要满足 $this->data不为空 $this->withEvent为false
    $result = $this->exists ? $this->updateData() : $this->insertData($sequence);
}//$this->exists为true


protected function updateData(): bool
{
    if (false === $this->trigger('BeforeUpdate')) {
        return false;//$this->withEvent为false已经满足
    }
    $data = $this->getChangedData();//$this->force 为true
    $allowFields = $this->checkAllowFields();
}


protected function checkAllowFields(): array
{
    if (empty($this->field)) {//$this->field 为空
        if (!empty($this->schema)) {//$this->schema 为空
            $this->field = array_keys(array_merge($this->schema, $this->jsonType));
        } else {
            $query = $this->db();
        }
    }
}


public function db($scope = []): Query
{
    $query = self::$db->connect($this->connection)//$this->connection为mysql
        ->name($this->name . $this->suffix)
        ->pk($this->pk);
    return $query;
}

3.中间跳板

前边构造条件已经触发__toString函数,现在需要寻找可利用类的__toString

通过审计发现后续利用思路和TP5.2版本利用动态代码执行是一样的,这里只做简单分析。

通过搜索不难发现熟悉的Conversion 类,直接利用TP5.2的利用链:

跟进函数:

public function toArray(): array
{
    $item       = [];
    $hasVisible = false;

    foreach ($this->visible as $key => $val) {
        if (is_string($val)) {
            if (strpos($val, '.')) {
                [$relation, $name]          = explode('.', $val);
                $this->visible[$relation][] = $name;
            } else {
                $this->visible[$val] = true;
                $hasVisible          = true;
            }
            unset($this->visible[$key]);
        }
    }

    foreach ($this->hidden as $key => $val) {
        if (is_string($val)) {
            if (strpos($val, '.')) {
                [$relation, $name]         = explode('.', $val);
                $this->hidden[$relation][] = $name;
            } else {
                $this->hidden[$val] = true;
            }
            unset($this->hidden[$key]);
        }
    }

    // 合并关联数据
    $data = array_merge($this->data, $this->relation);

    foreach ($data as $key => $val) {
        if ($val instanceof Model || $val instanceof ModelCollection) {
            // 关联模型对象
            if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
                $val->visible($this->visible[$key]);
            } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
                $val->hidden($this->hidden[$key]);
            }
            // 关联模型对象
            if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
                $item[$key] = $val->toArray();
            }
        } elseif (isset($this->visible[$key])) {
            $item[$key] = $this->getAttr($key);
        } elseif (!isset($this->hidden[$key]) && !$hasVisible) {
            $item[$key] = $this->getAttr($key);
        }
    }

    // 追加属性(必须定义获取器)
    foreach ($this->append as $key => $name) {
        $this->appendAttrToArray($item, $key, $name);
    }

    return $item;
}

进入toArray 函数后,TP5.2有两个思路,一个是利用getAttrgetValue 函数,然后$value = $closure($value, $this->data);动态调用;另一个思路是进入appendAttrToArray 函数,利用$relation->visible($name); 触发__call方法,TP6.0中第二种方法不能用了,第一种是可以的。接下来就是寻找最终的代码执行点。

4.代码执行

toArray 方法中$data = array_merge($this->data, $this->relation);是可控的,所以$item[$key] = $this->getAttr($key); 中的$key 也是可控的,进入getAttr 函数:

首先进入getData 函数看下$value值的处理:

可以发现$value为可控的,由此getValue 函数的参数都是可控,进入到getValue函数:

$this->withAttr可控,$this->data也可控:

这样就可以执行任意代码。

梳理下思路:

// vendor/topthink/think-orm/src/model/concern/Conversion.php

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

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

public function toArray(): array
{
    $data = array_merge($this->data, $this->relation);
    foreach ($data as $key => $val)
    $item[$key] = $this->getAttr($key);
}

// vendor/topthink/think-orm/src/model/concern/Attribute.php

public function getAttr(string $name)
{
    return $this->getValue($name, $value, $relation);
}


protected function getValue(string $name, $value, bool $relation = false)
{
    $closure = $this->withAttr[$fieldName];
    $value = $closure($value, $this->data);
}

这样一跳完整的利用链就出来。

5.另一条利用起点

利用起点挖掘的时候发现还存在其他起点。

vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php

需要满足$this->autosave 为false,进入save函数,发现并没有实现什么功能,参考网上师傅分析的思路,AbstractCache类的子类有没有实现该函数:

进入到vendor/topthink/framework/src/think/filesystem/CacheStore.php,发现了save 方法:

看到可控的$this->store可以触发任意类的set方法只要找到任意类存在危险操作的set 方法即可利用。$this->key可控,$this->expire可控。

跟进下$this->getForStorage

$this->cache可控,$this->complete 可控,因此$contents可控,只不过经过一次json编码,但是不影响目的。

寻找一处存在危险行为的set 方法:

vendor/topthink/framework/src/think/cache/driver/File.php

public function set($name, $value, $expire = null): bool
{
    $this->writeTimes++;

    if (is_null($expire)) {
        $expire = $this->options['expire'];
    }

    $expire   = $this->getExpireTime($expire);
    $filename = $this->getCacheKey($name);

    $dir = dirname($filename);

    if (!is_dir($dir)) {
        try {
            mkdir($dir, 0755, true);
        } catch (\Exception $e) {
            // 创建失败
        }
    }

    $data = $this->serialize($value);

    if ($this->options['data_compress'] && function_exists('gzcompress')) {
        //数据压缩
        $data = gzcompress($data, 3);
    }

    $data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
    $result = file_put_contents($filename, $data);

    if ($result) {
        clearstatcache();
        return true;
    }

    return false;
}

分析set方法,跟踪下几个重要的函数:

$filename = $this->getCacheKey($name);
public function getCacheKey(string $name): string
{
    $name = hash($this->options['hash_type'], $name);

    if ($this->options['cache_subdir']) {
        // 使用子目录
        $name = substr($name, 0, 2) . DIRECTORY_SEPARATOR . substr($name, 2);
    }

    if ($this->options['prefix']) {
        $name = $this->options['prefix'] . DIRECTORY_SEPARATOR . $name;
    }

    return $this->options['path'] . $name . '.php';
}

$this->options 可控,所以getCacheKey 返回的值完全可控。

$data = $this->serialize($value);
protected function serialize($data): string
{
    if (is_numeric($data)) {
        return (string) $data;
    }

    $serialize = $this->options['serialize'][0] ?? "\Opis\Closure\serialize";

    return $serialize($data);
}

$this->options['serialize'][0]可控,$serialize可控,$data 为我们传入set函数的$value,也就是$this->store->set($this->key, $contents, $this->expire); 中的$content,是可控的。只不过此时$data 经过json编码。

不难发现这里我们可以构造动态代码执行,测试下这个过程(本地实验是在windows下所以利用&或者||,linux下直接利用反引号即可。

<?php
$contents = ["test"=>"\"||dir||\""];

$cachedProperties = array_flip([
    'path', 'dirname', 'basename', 'extension', 'filename',
    'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);

foreach ($contents as $path => $object) {
    if (is_array($object)) {
        $contents[$path] = array_intersect_key($object, $cachedProperties);
    }
}

$contents = json_encode($contents);
$options = ["system"];
$data = $contents;
var_dump($data);
$serialize = $options[0];
$serialize($data);

梳理下思路:

// vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php
// abstract class AbstractCache 抽象类
// protected $cache = [];
// protected $complete = [];
public function __destruct()
{
    if (! $this->autosave) { //$this->autosave=false
        $this->save();
    }
}

// vendor/topthink/framework/src/think/filesystem/CacheStore.php
//use League\Flysystem\Cached\Storage\AbstractCache;
// class CacheStore
// protected $key;
// protected $expire;
public function save()
{
    $contents = $this->getForStorage();
    $this->store->set($this->key, $contents, $this->expire);
}//$this->store = new File();

// vendor/topthink/framework/src/think/cache/driver/File.php
// // use think\cache\Driver;
// class File extends Driver
public function set($name, $value, $expire = null): bool
{
$data = $this->serialize($value);
}
// vendor/topthink/framework/src/think/cache/Driver.php
// abstract class Driver
// protected $options = [];
protected function serialize($data): string
{
    $serialize = $this->options['serialize'][0];
    return $serialize($data);//命令执行点
    }

还没完 ,继续分析set 方法:

$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

发现还存在一个任意文件写入的点,只不过存在一个死亡exit,CTF中常见的一个点,利用p牛的php://filter协议的base64编码很轻松就能绕过。前面提到过$filename可控,$data 也可控,所以可以getshell。

6.漏洞利用

PS:这里只梳理触发的过程,防止不必要的麻烦,不放出POC,具体参数在分析过程中都提到了。

利用链一

vendor/topthink/think-orm/src/Model.php

入口在Model 类的__destruct方法,但是此类为抽象类无法实例化,找到了它的子类Pivot

vendor/topthink/think-orm/src/model/Pivot.php

以实例化Pivot类为起点

然后给有关参数赋值,满足一定条件层层触发:

$this->save > $this->updateData > $this->checkAllowFields > $this->db()

$this->db()中字符串拼接,触发__toString

vendor/topthink/think-orm/src/model/concern/Conversion.php

触发了Conversion 类的__toStringConversion 类为Trait类,在Model 类中利用,只需赋值然后触发:

$this->toJson > $this->toArray() 然后进入到Attribute类的 getAttr函数

vendor/topthink/think-orm/src/model/concern/Attribute.php 为Trait类,在Model 类中利用

getAttr > $this->getValue

$closure = $this->withAttr[$fieldName];
$value   = $closure($value, $this->data);

动态函数执行。

利用链二

vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php

入口为AbstractCache类的__destruct 方法 该类为抽象类找到其子类CacheStore

vendor/topthink/framework/src/think/filesystem/CacheStore.php

进入子类的$this->save 调用任意类的set函数:

$this->store->set($this->key, $contents, $this->expire);

调用Filevendor/topthink/framework/src/think/cache/driver/File.php

$this->serialize 然后命令执行:

vendor/topthink/framework/src/think/cache/Driver.php

Driver类为抽象类,在File类 中有调用

return $serialize($data);

执行命令。

利用链三

前部分和利用链二一样,只是在最后getshell的方法不同,利用File类任意文件写入shell

$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

思路总结

反序列化利用链基础挖掘思路

先找到入口文件,然后再层层跟进,找到代码执行点等危险操作。

特别注意魔法函数、任意类和函数的调用、以及子类等的综合分析

构造POC注意复用类和抽象类的问题:

发现类是Trait类,Trait类PHP 5.4.0开始引入的一种代码复用技术,是为解决PHP单继承而准备的一种代码复用机制,无法通过 trait 自身来实例化,需要找到复用它的类来利用。

抽象类也不能实例化,需要找到子类普通类来实例化。

再就是ThinkPHP命名空间的问题:

命名空间基础可以参考php文档,参照文档很好理解三种引用方式,文档中将命名空间与文件系统作类比:

  1. 非限定名称(不包含前缀的类名称)

    $a=new foo();foo::staticmethod();。如果当前命名空间是 currentnamespace,foo 将被解析为 currentnamespace\foo。如果使用 foo 的代码是全局的,不包含在任何命名空间中的代码,则 foo 会被解析为foo

  2. 限定名称 (包含前缀的名称)

    $a = new subnamespace\foo();subnamespace\foo::staticmethod();。如果当前的命名空间是 currentnamespace,则 foo 会被解析为 currentnamespace\subnamespace\foo。如果使用 foo 的代码是全局的,不包含在任何命名空间中的代码,foo 会被解析为subnamespace\foo

  3. 完全限定名称(包含了全局前缀操作符的名称)

    $a = new \currentnamespace\foo();\currentnamespace\foo::staticmethod();。在这种情况下,foo 总是被解析为代码中的文字名(literal name)currentnamespace\foo

TinkPHP采用命名空间,那么我们构造POC的时候也应利用命名空间的方法调用不同类和函数,构造POC就是在一个文件中定义多个命名空间,文档中也有说明。有两种方式:简单组合语法和大括号语法

简单组合语法:

<?php
namespace MyTest1;

class Test {}
function test() {}

namespace MyTest2;

class Test {}
function test() {}
?>

不推荐这种方法。

大括号语法:

<?php
namespace MyTest1{
    class Test {}
    function test() {}
}

namespace MyTest2{
    class Test {}
    function test() {}
}
?>

构造POC的最后还会用到全局非命名空间:

将全局的非命名空间中的代码与命名空间中的代码组合在一起,只能使用大括号形式的语法。全局代码必须用一个不带名称的 namespace 语句加上大括号括起来

<?php
namespace MyTest1{
    class Test {}
    function test() {}
}

namespace MyTest2{
    class Test {}
    function test() {}
}
namespace{
    $v = new MyTest2\Test();
    $s = new MyTest1\Test();
    $this -> xxx = $v;
    echo serialize($s);
}
?>

挖掘利用链真好玩,phpstorm真香。

参考

挖掘暗藏ThinkPHP中的反序列利用链
ThinkPHP6.X反序列化利用链
ThinkPHP 6.0.x反序列化(二)/)
PHP手册-命名空间


文章来源: http://xz.aliyun.com/t/6924
如有侵权请联系:admin#unsafe.sh