前段时间,ThinkPHP发布了V5.0.16版本的release,该版本提到了安全更新。本篇文章以此次安全更新入手,对ThinkPHP 5.0版本 SQL注入漏洞进行了详细分析。文末还有测试小问题,看看大家get到这个漏洞的精髓了吗?
前言
Thinkphp V5.0.16版本的release说明如下:
说明中提到了安全更新,但并没有提到是什么安全问题。
V5.0.16的commits记录如下,可以看到在3月26日出现了一个关于安全性的提交,但26日似乎没有一次性改好,在27日又对这个inc/dec查询改动了一次
接下来看下这个inc/dec查询到底有什么问题,需要一改再改。
漏洞分析
先看下26日改了什么
再看看27日改了什么
改动都在Builder.php这个文件的相同位置,而且反反复复的折腾的,就是$val[1]这个变量。
接下来看看完整的函数部分,看看$val[1]到底怎么了。
漏洞部分在parseData函数
protected function parseData($data, $options) { if (empty($data)) { return []; } // 获取绑定信息 $bind = $this->query->getFieldsBind($options['table']); if ('*' == $options['field']) { $fields = array_keys($bind); } else { $fields = $options['field']; } $result = []; foreach ($data as $key => $val) { $item = $this->parseKey($key, $options); if (is_object($val) &&method_exists($val, '__toString')) { // 对象数据写入 $val = $val->__toString(); } if (false === strpos($key, '.') && !in_array($key, $fields, true)) { if ($options['strict']) { throw new Exception('fields not exists:[' . $key . ']'); } } elseif (is_null($val)) { $result[$item] = 'NULL'; } elseif (is_array($val) && !empty($val)) { switch ($val[0]) { case 'exp': $result[$item]= $val[1] . '+' . floatval($val[2]); break; case 'inc': $result[$item]= $this->parseKey($val[1]) . '+' . floatval($val[2]); break; case 'dec': $result[$item]= $this->parseKey($val[1]) . '-' . floatval($val[2]); break; } } elseif (is_scalar($val)) { // 过滤非标量数据 if (0 === strpos($val, ':') &&$this->query->isBind(substr($val, 1))) { $result[$item] = $val; } else { $key = str_replace('.', '_', $key); $this->query->bind('data__' . $key, $val, isset($bind[$key])? $bind[$key]: PDO::PARAM_STR); $result[$item] = ':data__' . $key; } } } return $result; }
可以看出这个方法是用来传入的字典类型$data数据的,具体传入的$data是什么,还需要进一步分析。
先不管调用关系,单单看这个方法,在处理$data的value时,会分情况处理,
是否是空,是否是数组,是否是常量,而漏洞恰恰出在了是否是数组这个elseif上了。
接下来看看谁调用了parseData,传入的$data又是什么。
向上跟踪到Builder.php中的insert方法
这个insert也不是最上层,但我们就先看看这个insert做了什么。
假如最上层入口的$data我们可控,$data这个字典中的value值还是个数组,那个经过parseData方法后,最终的返回值就可控,原因如下:
只要$val[0]的值是exp/inc或者是dec,那么我们就能将$val[1]恶意构造的值传入$result[$item]中,这个$result值最终会返回给insert方法中的$data变量,看下图:
并且,最终$fields的值会是$item的值;$values的值会是$result[$item]的值,即为
不要担心这个parseKey方法会破坏我们构造的$val[1],因为。。。。
到目前为止,我们的推断是,只要$data的值可控,那么我们就能将恶意构造的值传入$values这个参数。接下来,看看$values这个可控参数又如何造成sql注入的。
很明显,这里是要拼接sql语句了,最终的$values会被拼接到$sql变量中
抛开恶意构造的部分,有经验的朋友一看就能想到这个$sql变量是要做什么的。对,这个$sql变量是要用作参数化查询的sql指令部分。
为了验证我们的猜想,继续往上层跟踪。
在\library\think\db\Query.php中,调用了我们刚才分析的builder中的insert方法
刚才的$sql变量,这次又传递给了Query.php中的$sql变量了,这里的$bind,实际上就是用来取出真是value值的。
然后在上图红圈中,使用execute进行参数化查询。由于$sql变量可控,里面可以包含我们传入的恶意字符串,因此,即使用了参数化查询,也没法避免sql注入的产生。
这个漏洞怎么利用呢?这关系的Query.php中的这个insert方法,看看thinkphp中关于这个方法的使用说明
我这里给个demo,帮助大家理解下应用场景。
总结
理论上来说,利用参数化查询,将要执行的sql语句和参数分开传入,的确可以防止sql注入的产生。但是像这个案例,要执行的sql语句中的内容竟然可控,那就比较尴尬了。
思考
漏洞分析虽然告一段落了,这里我给大家提出几个问题,看看大家有没有真的弄明白这个漏洞。
- 如果我直接通过get方法传入一个字符串,这个漏洞会利用成功吗?
- 最终的修补如下图
当$val[0]=exp的时候,$val[1]仍然可控,并且也传入了$result[$item]里了,这里是否还是有漏洞呢?为什么thinkphp不修这里呢?
答案
- get传入的值如果是一个字符串,最终会到如下这里
最终的$result[$item]中的$key值是不可控的,
对于我给出的那个例子
$key就是红圈中的内容
因此最终执行时,$sql不可控,还利用了参数化查询,完全没有注入的可能,如下图
答案2
这个问题也困扰到我了,找到这个问题的时间简直比分析漏洞的时间还长。
通过Input方法传入的变量,会中途经过\library\think\Request.php中的input方法进行处理,然而在这个方法中,有一个过滤器。。。
如果$data是数组形式的,就利用$this->filterValue进行处理
这个过滤器还没对我们的$data下手,注意看红框处,filterExp
在这里,filterExp如果匹配到了exp,则会给它后面加一个空格,这就导致了我们通过get/post
提交进来的数组中,如果有exp,则会被处理为“exp ”,因此无法进入”exp”这个case