SQL注入过滤方法总结

· · 科技·工程

引子

本文主要基于 MySQL 的注入。阅读本文需要您至少掌握 SQL 基本的标准语法(至少能够熟练使用 SELECTINSERTUPDATE 及其各常用子句)及 PHP 基本语法和函数特性等。

假设某网站的登录系统工作方式如下:用户输入账户和密码,前端将 usernamepassword 传递给后端,而后端用如下方式拼接字符串(为方便理解,使用 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 等。可能的代码如下:

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 关键字被过滤了。此时可以通过 LIMIT 和 OFFSET 子句配合,一条一条地找到需要的用户:

SELECT * FROM users LIMIT 1 OFFSET 1

把 OFFSET 的值从 1 开始枚举即可实现依次登录每个用户。

此时可以用聚合子句,GROUP BY 和 HAVING 配合:

SELECT * FROM users GROUP BY username HAVING username='Alice'

用聚合函数吧,只在理论上见过解决办法,实战还没遇到过。

多次过滤

再次改进,对查询字符串循环替换,直到没有关键字出现:

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 支持预处理语句,也就是提前存储语句,然后执行。需要使用的语法有:

有时候过滤的只是一些常见关键字,比如 SELECTPREPARE 这种不太常见的没被过滤,则仍然可以注入。可以写出这样的语句:

PREPARE a FROM concat("SELEC","T * FROM users"); EXECUTE a;

此处作为举例,用 concat 函数实现拼接字符串,实际上走到这一步,想绕开关键字检测的办法已经数不胜数。这里仅再举一例,用数字直接代替字符串:

PREPARE a FROM 0x53454c454354202a2046524f4d207573657273; EXECUTE a;

逐字节按 ASCII 码转化即可实现字符串和数字互转。

过滤一切

如果以上一切都被过滤了,单纯靠构造字符串已经很难绕开了。但也不是完全没有机会。

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。其实可以用相同功能或类似的函数代替。

特殊逻辑

本段资料来源: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 关键字。:

这里有个两个小特性。对于 a OR b,如果 b 是字符串,则会尝试转化为数字,然后遵循 0 为假非 0 为真的原则转化为布尔值。转化为数字的办法是:取开头最长的数字段(比如 234aaa1 转化为 234),如果开头不是数字则为 0。第二个特性是:非法字符会被忽略。

收集具有特殊 MD5 的字符串还是很有用的。

作为后端开发者

如果你的角色是后端开发者:

CTF 乐趣真多

惯用过滤

最后,还是看看开头例子:使用 ' OR 1=1 --,制造 1=1 恒成立条件。但是有时候过不去。

为什么呢?

出题人把 1=1 过滤了。

改成 2=2 或者 666=666 都行。

绷(