ThinkPHP3.2.3SQL注入复现
最后更新时间:
文章总字数:
预计阅读时间:
ThinkPHP3.2.3SQL注入复现
前言
最近这段时间集中在研究ThinkPHP全系漏洞,争取都复现一下,提升一下代码审计能力。
准备工作
先在本地搭建环境研究研究,首先研究sql注入肯定要先自己弄好数据库,这个thinkphp创建时绑定的数据库是空的,于是创个users表,输点数据。
由于我下的是3.2.5版本的源码(只能找到这个…),但是这个基于where进行sql注入的漏洞在3.2.4修复了,于是对照修复的补丁位置改回去3.2.3版本。
这个修复让我们无法再通过传入一个数组(键为where)来控制where的值(从option到this->option,且不再把option直接传进_parseOptions),从而无法绕过后面的检测,分析完就知道为什么要这么改了。
搭建环境最后一步,index里写上对应的功能(通过id获取的参数查询users表,这是payload1的配置)
payload1:where注入
1 | $data = M('users')->find(I('GET.id')); |
打上断点开始调试,先试试普通的sql注入会发生什么,传入1'
首先这个M方法处理的是users,并不是我们传入的值,是用来建立模型的,不用管,直接步出,I方法用来获取我们通过GET传入的id值,然后传入find方法,跟进find方法。
可以看到经过赋值后此时的where变量是一个array。find函数最关键的部分如下:
parseOptions对表达式进行分析(也就是处理我们的查询语句,options是一个array,里面有where,而where也是array,里面放了id="1'"
),而select就是正式进行查询,跟进parseOptions:
parseOptions前面都在自动获取表名,指定表之类的操作,字段类型验证在后面:
可以看到这个字段类型验证的条件是where是一个array,sql注入做文章的地方就是这里,先看看如何进行字段验证,关键函数是parseType,跟进看看。
简单来说就是根据表字段的类型来将传入的值强转,可以看到我们传入的1'
被intval转成了1(id字段的类型是int),sql注入失效,可以通过将id字段的类型设置成varchar看看会怎么处理字符串,是否存在sql注入。
改了之后可以重新断点跳到这个地方,可以看到是成功跳过了所有的if,回到了parseOptions再回到find函数中,来到find中第二个关键函数select。
select中的关键是buildSelectSql,就是这个函数利用我们传入的options生成了sql查询语句,跟进:
parsesql对我们的options进行了处理,跟进。
看到了一堆函数,用来处理sql语句的不同逻辑
一通跳转到了处理我们传入的值的函数,parseValue,注意看此时传入前的value仍然是1'
。这行代码调用了escapeString来处理我们value,跟进:
很好,这个addslashes直接宣告了常规sql注入的终结,因为会转义单引号。
可以看到我们的value变成了1\
,失去效果。
既然传字符串不行,可以考虑传入一个数组,而联想到刚刚的判断,可以传入?id[where]=1
看看,因为可以直接跳过强转部分,理由稍后阐明(不需要再使得字段是varchar,是int也行)。
可以看到这里和上文最明显的不同就是这个options里的where不再是一个数组array,而是一个字符串。
既然where不再是array,这个parseOptions里的字段类型验证直接失效,更不会到parseType中发生强转,成功绕过第一层,回到find中,到第二个关键函数。
可以看到一大堆函数里的parseWhere处理了where(因为我们现在相当于传入的是where,处理我们传入的就只有parseWhere,而不会像上次一样传入parseValue处理,自然也就绕过了addslashes),并且直接把where字符串内容返回,最后一通跳转返回了sql语句。
这个where后面的值就是我们传入的1,从而可以随意控制值来进行sql注入,相当于没有了任何验证,传入?id[where]=id=-1 union select 1,group_concat(flag4s),3,4 from flags
(后面一整个字符串直接拼到sql语句中)得到这一题的flag。
总结一下
- 传入id值时正常逻辑,
find()->_parseOptions()->_parseTypes()->select()->parseSql()->parseValue()
,其中_parseTypes()
是第一层需要绕过的点,parseValue
是第二层需要绕过的点 - 传入
?id[where]=1
,绕过上述两层,find()->_parseOptions()->select()->parseSql()->parseWhere()
,直接将内容拼接到sql语句中。
修复后
分析下官方是怎么修复的,结合之前那张图可知重要的改动是把options变成了this->options,并且不再直接将options传入parseOptions,this->options初始为空,需要赋值,而如果传数组,就没法赋值where给this->Options,所以相当于到parseOptions中我们根本就没有where(直接传数组相当于啥也没传),自然绕不过第二层,不再可以注入。
需要options是string或者numeric,才能赋值。
可以看到这里处理的options只剩下了limit=>1。
payload2:exp注入
index改一下
1 | $User = D('Users'); |
为什么用GET获取而不用I方法获取,因为I方法内部有过滤,不允许传入参数值以exp开头(后面分析payload就知道为什么要用exp开头了)。直接调用where再调用find主要是为了保证where是我们传入的array。
payload:?username[0]=exp&username[1]==-1 union select 1,2,3
传入payload打上断点开始快乐调试:
先跟进where方法,一句话:把我们传入的这个数组array('username' => $_GET['username'])
传给了$this->options['where']
跟进find方法,会发现this->options里面只有一个limit,和官方修复后的那个一样,不过我们知道进到parseOptions里面会进行合并,那就和之前的一样了。跟进到经典字段验证。
可以看到,这个var变量是一个数组,而is_scalar方法判断一个变量是否是标量(存储单个值的数据类型),这里if判断失败,自然实现了绕过。
直接跳到第二个关键点,select方法,进入buildSelectSql方法,然后跳到parseSql一大堆函数,前面的验证都和之前一样,直接跳到不一样的地方,parseWhere,由于我们现在的where是一个二维数组(应该可以这么说),自然处理方式和之前不同。
可以看到whereStr现在是空的,也就是说parseWhereItem处理的结果就是这个string的值,跟进:
这个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 | $User = M("Users"); |
这里可以用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方法获取完之后是这样的:
where方法和上一个完全一样,没什么好说的,跟进save方法,save先使用facade方法来处理我们的data,跟进facade方法
看到了经典检查和强转,不过我们也不是用data进行注入。然后进入update方法,
跟进parseSet(这里有一个小插曲,关键部分代码3.2.5进行了修改,导致我输payload没成功,于是对照改回来,下面的是3.2.3原来的代码,成功实现报错注入):
1 | protected function parseSet($data) { |
跟进bindParam看看:
从而得到bind的值:0 = 1
,后面就是常规的bind注入和上面的exp注入一样,会产生whereStr。
返回str构成的sql语句:
1 | UPDATE `users` SET `password`=:0 WHERE `id` = :0 and updatexml(1,concat(0x7e,user(),0x7e),1) |
进入最终的excute方法:
重点在最下面那个最终的queryStr生成,简单来说strtr就是把所有的:0都替换成了1(运用了之前那个bind规则,当我们的id第二个值传入的是0时,刚好拼出一个:0,从而实现替换,使得sql报错)
1 | if(!empty($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博客
断点调试,复现漏洞真的很有意思!(笑)