TP6.0反序列化利用链挖掘思路总结

2022-04-26 14:48:26 浏览数 (1)

最近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: 当一个类被转换成字符串时被调用

触发的情况有:

代码语言:javascript复制
用到打印有关函数时,如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。

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

代码语言:javascript复制
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {    return false;}$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

需要满足:

代码语言:javascript复制
$this->isEmpty()为false
$this->trigger('BeforeWrite') 为true
$this->exists 为true

跟进下:

代码语言:javascript复制
$this->isEmpty()为false 需要$this->data不为空
$this->trigger('BeforeWrite') 为true 需要$this->withEvent 为false
$this->exists 为true

进入updateData函数:

代码语言:javascript复制
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->connection 为mysql 即可。

梳理下思路:

代码语言:javascript复制
//寻找一个入口魔术方法
//可以利用 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的利用链:

跟进函数:

代码语言:javascript复制
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有两个思路,一个是利用getAttr的getValue 函数,然后value = closure(value, this->data);动态调用;另一个思路是进入appendAttrToArray 函数,利用relation->visible(

4.代码执行

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

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

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

this->withAttr可控,

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

梳理下思路:

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

this->cache可控,

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

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

代码语言:javascript复制
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   = "<?phpn//" . sprintf('2d', $expire) . "n exit();?>n" . $data;
    $result = file_put_contents($filename, $data);

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

    return false;}

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

代码语言:javascript复制
$filename = $this->getCacheKey($name);
代码语言:javascript复制
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 返回的值完全可控。

代码语言:javascript复制
$data = $this->serialize($value);
代码语言:javascript复制
protected function serialize($data): string{    if (is_numeric($data)) {        return (string) $data;    }    $serialize = $this->options['serialize'][0] ?? "OpisClosureserialize";    return $serialize($data);}

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

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

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

梳理下思路:

代码语言:javascript复制
// 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 LeagueFlysystemCachedStorageAbstractCache;
// 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 thinkcacheDriver;
// 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 方法:

代码语言:javascript复制
$data   = "<?phpn//" . sprintf('2d', $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->checkAllowFields > 

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

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

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

this->toJson > 

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

getAttr > $this->getValue

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

代码语言:javascript复制
$this->store->set($this->key, $contents, $this->expire);

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

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

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

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

代码语言:javascript复制
return $serialize($data);

执行命令。

利用链三

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

代码语言:javascript复制
$data   = "<?phpn//" . sprintf('2d', $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 将被解析为 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

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

简单组合语法:

代码语言:javascript复制
<?phpnamespace MyTest1;class Test {}function test() {}namespace MyTest2;class Test {}function test() {}?>

不推荐这种方法。

大括号语法:

代码语言:javascript复制
<?phpnamespace MyTest1{
    class Test {}
    function test() {}}namespace MyTest2{
    class Test {}
    function test() {}}?>

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

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

代码语言:javascript复制
<?phpnamespace MyTest1{
    class Test {}
    function test() {}}namespace MyTest2{
    class Test {}
    function test() {}}namespace{
    $v = new MyTest2Test();
    $s = new MyTest1Test();
    $this -> xxx = $v;
    echo serialize($s);}?>

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

参考

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

0 人点赞