0X01 漏洞概况
本次漏洞存在于 Builder 类的 parseData 方法中。由于程序没有对数据进行很好的过滤,将数据拼接进 SQL 语句,导致 SQL注入漏洞 的产生。漏洞影响版本: 5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5 。
实验环境
composer版本1.8.5
1
composer create-project --prefer-dist topthink/think=5.0.15 thinkphp5015
将 composer.json 文件的 require 字段设置成如下:
1
2
3
4"require": {
"php": ">=5.4.0",
"topthink/framework": "5.0.15"
}执行update
1 | composer update |
可以直接用phpstudy带的composer
修改
application/index/controller/Index.php
文件1
2
3
4
5
6
7
8
9
10
11
12
namespace app\index\controller;
class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}创建数据库
1
2
3
4create table users(
id int primary key auto_increment,
username varchar(50) not null
);配置数据库
在
application/database.php
文件中配置数据库相关信息,并开启application/config.php
中的 app_debug 和 app_trace配置完成
0X02 漏洞复现
POC
1 | http://<yoursite>/public/index.php/index/index?username[0]=inc&username[1]=updatexml(1,concat(0x7,user(),0x7e),1)&username[2]=1 |
参数部分包含三个键值对:
username[0]=inc
:设置username[0]
参数的值为inc
。username[1]=updatexml(1,concat(0x7,user(),0x7e),1)
:设置username[1]
参数的值为updatexml(1,concat(0x7,user(),0x7e),1)
。username[2]=1
:设置username[2]
参数的值为1
。
其中,username[1]
的值,是一个试图利用 updatexml
函数进行 SQL 注入的示例
1 | updatexml(1, concat(0x7, user(), 0x7e), 1) |
updatexml
是 MySQL 中的一个函数,用于更新 XML 数据。在这里利用它来执行任意 SQL 查询。
concat(0x7, user(), 0x7e)
:concat
函数用于连接字符串。0x7
和0x7e
是十六进制表示的字符,分别是BEL
和~
(换成别的什么乱七八糟的也可以)。它们会与当前数据库用户的名字(通过user()
函数获取)拼接在一起。
由于 updatexml
函数的设计,这种拼接的字符串会被插入到 XML 文档中,并返回错误消息,其中包含拼接的内容。这样,攻击者可以通过错误消息获取敏感信息,如当前数据库用户。
user()
获取当前数据库用户的名字
version()
获取当前数据库版本
0X03 代码分析
调用链
漏洞函数
application/index/controller/Index.php
1 | public function index() |
request()->get('username/a')
:从请求中获取username
参数,并假设它是一个数组。db('users')->insert(['username' => $username])
:将获取到的username
参数插入到users
表中。return 'Update success'
:返回一个简单的成功消息。
**跟进insert **
1 | db('users')->insert(['username' => $username]); |
进来后跟进生成sql语句,看\think\db\Query::insert
函数如何处理数据
跟进\think\db\Builder::insert
数据会先经过\think\db\Builder::parseData
进行处理
跟进\think\db\Builder::parseData
获取字段列表
1 | $bind = $this->query->getFieldsBind($options['table']); |
getFieldsBind
获取 $options['table']
表的字段绑定信息,并存储在 $bind
中。如果 $options['field']
等于 '*'
,表示选择所有字段,则将 $bind
中的所有键(字段名)存储在 $fields
中。否则,将 $options['field']
的值存储在 $fields
中。
处理数据
result
初始化创建一个空数组用于存放结果数据- foreach 循环遍历传入的数据 data,用以处理键值对
- 如果键值对中的值val 是一个对象,并且存在toString 的方法,则调用该方法,将其转换为字符串。
- 检查键 key 是否包含”.”,且是否是
fields
字段列表中的。 - 如果不在,并且是在严格模式 (
$options['strict']
为true
) ,抛出异常。 - 如果值为空,则返回到结果数组中,置为NULL
- 如果不为空,则对
$val[0]
进行判断,分三支为 ‘exp’、’inc’、’dec’【根据数组的第一个元素(表示操作类型)进行不同处理】'exp'
: 表达式,直接使用值。- 当
$val
是一个数组且$val[0]
为'exp'
时,直接将$val[1]
赋值给$result[$item]
。'exp'
通常表示一个原生的数据库表达式,因此不需要进一步处理。 - 例如:
$val = ['exp', 'NOW()']
,则$result[$item]
将被赋值为'NOW()'
,表示在 SQL 中使用当前时间。
- 当
'inc'
: 递增操作,解析键并构造递增表达式。- 当
$val
是一个数组且$val[0]
为'inc'
时,表示一个递增操作。$val[1]
是要递增的字段名,$val[2]
是递增的值。 - 例如:如果
$val = ['inc', 'updatexml(0,concat(0x7,version(),0x7e),1)', 1]
,并且parseKey('updatexml(0,concat(0x7,version(),0x7e),1)')
返回'updatexml(0,concat(0x7,version(),0x7e),1)'
,则$result[$item]
将被赋值为'updatexml(0,concat(0x7,version(),0x7e),1) + 1'
- 当
'dec'
: 递减操作,解析键并构造递减表达式。
跟进 \think\db\Builder::parseKey
对 $val
没有进行任何过滤处理,怎么进去的,怎么出来的。并且直接拼接 $val[2]
1 | protected function parseKey($key, $options = []) |
所以从parseData
方法处理后,出来的数据是 updatexml(0,concat(0x7,version(),0x7e),1) + 1
**回到\think\db\Builder::insert
**,向下跟进
生成一个完整的sql语句
1 | INSERT INTO `users` (`username`) VALUES (updatexml(0,concat(0x7,version(),0x7e),1)+1) |
0X04 修复方案
修复漏洞的原理在于避免直接将用户输入的$val[1]
值拼接到 SQL 语句中,而是使用安全的方式来构建 SQL 语句。
修复前:
1 | case 'inc': |
在这段代码中,$val[1]
是用户输入的值,并通过 $this->parseKey
方法进行处理后,直接拼接到 $result[$item]
中。这种直接拼接方式很容易受到 SQL 注入攻击的影响。
修复后:
1 | $result[$item] = $item . '+' . floatval($val[2]); |
- **使用
$item
代替parseKey($val[1])
**:$item
是通过$this->parseKey($key, $options)
方法解析后的安全字段名。($iterm
的值为username)- 这样可以确保
$item
是一个安全的字段名,避免直接使用用户输入的值。
- 将
floatval($val[2])
与$item
拼接:floatval($val[2])
将用户输入的值转换为浮点数,以确保它是一个有效的数值,从而避免了字符串或其他类型的注入。
所以最终修复后的结果👇