ThinkPHP3.2.3SQL注入复现

文章发布时间:

最后更新时间:

文章总字数:
2.7k

预计阅读时间:
10 分钟

ThinkPHP3.2.3SQL注入复现

前言

最近这段时间集中在研究ThinkPHP全系漏洞,争取都复现一下,提升一下代码审计能力。

准备工作

先在本地搭建环境研究研究,首先研究sql注入肯定要先自己弄好数据库,这个thinkphp创建时绑定的数据库是空的,于是创个users表,输点数据。

image-20230815223250004

由于我下的是3.2.5版本的源码(只能找到这个…),但是这个基于where进行sql注入的漏洞在3.2.4修复了,于是对照修复的补丁位置改回去3.2.3版本。

image-20230816111003088

这个修复让我们无法再通过传入一个数组(键为where)来控制where的值(从option到this->option,且不再把option直接传进_parseOptions),从而无法绕过后面的检测,分析完就知道为什么要这么改了。

搭建环境最后一步,index里写上对应的功能(通过id获取的参数查询users表,这是payload1的配置)

payload1:where注入

1
2
$data = M('users')->find(I('GET.id'));
var_dump($data);

打上断点开始调试,先试试普通的sql注入会发生什么,传入1'

image-20230816111505604

首先这个M方法处理的是users,并不是我们传入的值,是用来建立模型的,不用管,直接步出,I方法用来获取我们通过GET传入的id值,然后传入find方法,跟进find方法。

image-20230816112130887

可以看到经过赋值后此时的where变量是一个array。find函数最关键的部分如下:

image-20230816112302459

parseOptions对表达式进行分析(也就是处理我们的查询语句,options是一个array,里面有where,而where也是array,里面放了id="1'"),而select就是正式进行查询,跟进parseOptions:

image-20230816112753794

parseOptions前面都在自动获取表名,指定表之类的操作,字段类型验证在后面:

image-20230816105921113

可以看到这个字段类型验证的条件是where是一个array,sql注入做文章的地方就是这里,先看看如何进行字段验证,关键函数是parseType,跟进看看。

image-20230816113146738

简单来说就是根据表字段的类型来将传入的值强转,可以看到我们传入的1'被intval转成了1(id字段的类型是int),sql注入失效,可以通过将id字段的类型设置成varchar看看会怎么处理字符串,是否存在sql注入。

image-20230816121132212

改了之后可以重新断点跳到这个地方,可以看到是成功跳过了所有的if,回到了parseOptions再回到find函数中,来到find中第二个关键函数select。

image-20230816121422957

select中的关键是buildSelectSql,就是这个函数利用我们传入的options生成了sql查询语句,跟进:

image-20230816121738189

parsesql对我们的options进行了处理,跟进。

image-20230816121822977

看到了一堆函数,用来处理sql语句的不同逻辑

image-20230816122651856

一通跳转到了处理我们传入的值的函数,parseValue,注意看此时传入前的value仍然是1'。这行代码调用了escapeString来处理我们value,跟进:

image-20230816122936591

很好,这个addslashes直接宣告了常规sql注入的终结,因为会转义单引号。

image-20230816123102487

可以看到我们的value变成了1\,失去效果。

既然传字符串不行,可以考虑传入一个数组,而联想到刚刚的判断,可以传入?id[where]=1看看,因为可以直接跳过强转部分,理由稍后阐明(不需要再使得字段是varchar,是int也行)。

image-20230816123804101

可以看到这里和上文最明显的不同就是这个options里的where不再是一个数组array,而是一个字符串。

image-20230816124009180

既然where不再是array,这个parseOptions里的字段类型验证直接失效,更不会到parseType中发生强转,成功绕过第一层,回到find中,到第二个关键函数。

image-20230816124356755

可以看到一大堆函数里的parseWhere处理了where(因为我们现在相当于传入的是where,处理我们传入的就只有parseWhere,而不会像上次一样传入parseValue处理,自然也就绕过了addslashes),并且直接把where字符串内容返回,最后一通跳转返回了sql语句。

image-20230816124734797

这个where后面的值就是我们传入的1,从而可以随意控制值来进行sql注入,相当于没有了任何验证,传入?id[where]=id=-1 union select 1,group_concat(flag4s),3,4 from flags(后面一整个字符串直接拼到sql语句中)得到这一题的flag。

总结一下

  1. 传入id值时正常逻辑,find()->_parseOptions()->_parseTypes()->select()->parseSql()->parseValue(),其中_parseTypes()是第一层需要绕过的点,parseValue是第二层需要绕过的点
  2. 传入?id[where]=1,绕过上述两层,find()->_parseOptions()->select()->parseSql()->parseWhere(),直接将内容拼接到sql语句中。

修复后

分析下官方是怎么修复的,结合之前那张图可知重要的改动是把options变成了this->options,并且不再直接将options传入parseOptions,this->options初始为空,需要赋值,而如果传数组,就没法赋值where给this->Options,所以相当于到parseOptions中我们根本就没有where(直接传数组相当于啥也没传),自然绕不过第二层,不再可以注入。

image-20230816134109804

需要options是string或者numeric,才能赋值。

image-20230816132632123

可以看到这里处理的options只剩下了limit=>1。

payload2:exp注入

index改一下

1
2
3
4
5
$User = D('Users');
$map = array('username' => $_GET['username']);
// $map = array('username' => I('username'));
$user = $User->where($map)->find();
var_dump($user);

为什么用GET获取而不用I方法获取,因为I方法内部有过滤,不允许传入参数值以exp开头(后面分析payload就知道为什么要用exp开头了)。直接调用where再调用find主要是为了保证where是我们传入的array。

image-20230816161602527

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

传入payload打上断点开始快乐调试:

先跟进where方法,一句话:把我们传入的这个数组array('username' => $_GET['username'])传给了$this->options['where']

image-20230816184119289

跟进find方法,会发现this->options里面只有一个limit,和官方修复后的那个一样,不过我们知道进到parseOptions里面会进行合并,那就和之前的一样了。跟进到经典字段验证。image-20230816184308853

可以看到,这个var变量是一个数组,而is_scalar方法判断一个变量是否是标量(存储单个值的数据类型),这里if判断失败,自然实现了绕过。

直接跳到第二个关键点,select方法,进入buildSelectSql方法,然后跳到parseSql一大堆函数,前面的验证都和之前一样,直接跳到不一样的地方,parseWhere,由于我们现在的where是一个二维数组(应该可以这么说),自然处理方式和之前不同。

image-20230816185054690

可以看到whereStr现在是空的,也就是说parseWhereItem处理的结果就是这个string的值,跟进:

image-20230816185331808

这个exp变量获取的是var[0]的值,也就是我们传入的第一个值,就是exp,可以看到这时候whereStr就是key拼上var[1],也就是查询的username拼上我们传入第二个值。

然后这个语句就毫无过滤的传回去拼到where查询了,实现了sql注入。

总结一下

往where方法传入数组,第一个值为exp,第二个值为=1 union ….,就可以在parseWhere构建whereStr,从而实现sql注入。比如传?username[0]=exp效果如下:

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

payload3:bind注入

index:

1
2
3
4
5
$User = M("Users");
$user['id'] = I('id');//这个获取的是数据库里的字段名
$data['passwd'] = I('passwd');
$value = $User->where($user)->save($data);
var_dump($value);

这里可以用I方法的原因是I方法并没有不允许bind开头,故3.2.4更新中更新了bind到黑名单。其实这个bind和上面那个exp很像,都是参数绑定导致的问题。

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

I方法获取完之后是这样的:

image-20230816191545357

where方法和上一个完全一样,没什么好说的,跟进save方法,save先使用facade方法来处理我们的data,跟进facade方法

image-20230816191650141

看到了经典检查和强转,不过我们也不是用data进行注入。然后进入update方法,

image-20230816192105532

跟进parseSet(这里有一个小插曲,关键部分代码3.2.5进行了修改,导致我输payload没成功,于是对照改回来,下面的是3.2.3原来的代码,成功实现报错注入):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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);//关键在这里,由于bind为空,所以name变量的值就是0
$set[] = $this->parseKey($key).'=:'.$name;
$this->bindParam($name,$val);
}
}
}
return ' SET '.implode(',',$set);
}

跟进bindParam看看:

image-20230816195605466

从而得到bind的值:0 = 1,后面就是常规的bind注入和上面的exp注入一样,会产生whereStr。

image-20230816192836350

返回str构成的sql语句:

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

进入最终的excute方法:

image-20230816193719388

重点在最下面那个最终的queryStr生成,简单来说strtr就是把所有的:0都替换成了1(运用了之前那个bind规则,当我们的id第二个值传入的是0时,刚好拼出一个:0,从而实现替换,使得sql报错)

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

替换完得到最终的语句,成功sql注入

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

总结一下

首先第一个id[0]=bind传入是为了在parseWhere中按bind处理,拼接返回whereString,

然后第二个id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)是用来报错注入的语句(因为是save操作,并不会有回显,只能用报错注入),这个0是强制的,因为可以替换的仅仅为:0,如果不是,最后就不会替换,从而报错(但是没有注入)。

最后第三个passwd的值就是希望:0替换成的值,由于这里是id,随便输个数字就行。

参考链接:

thinkphp3.2.3 SQL注入漏洞复现_thinkphp3.2.3 not like_bfengj的博客-CSDN博客

Thinkphp3 漏洞总结 - Y4er的博客

断点调试,复现漏洞真的很有意思!(笑)