做Web安全测试的人,早晚都会遇到这样一个尴尬场面:明明一个明显的SQL注入点,提交?id=1'之后后台却返回了正常页面,仿佛什么都没发生过。别急着怀疑人生,大概率不是没有注入点,而是你撞上了某种字符过滤。所谓“SQL注入字符绕过”,简单说就是当目标系统对输入内容做了过滤、拦截、转义之后,攻击者仍然能通过变形、编码、等价替换等手段,让数据库成功执行恶意SQL。这篇文章我结合这几年在授权测试和防护复盘里积累的经验,把常见的字符绕过思路、完整实操链路和防御建议一次性梳理清楚,适合正在做安全测试的新手、需要自查代码风险的开发,以及想给业务系统补WAF规则的运维朋友。
1. 先搞清楚:字符绕过到底在绕什么
很多刚接触的人会把“绕过”理解成某种玄学,其实它背后只有一个核心逻辑:过滤层看到的输入,和数据库最终解析到的SQL,不是同一个东西。只要这两者之间存在差异,就有了绕过空间。
1.1 注入请求是怎么被拦下来的
绝大多数系统会在三个层面拦截恶意输入。
第一层是Web应用代码里自己写的过滤逻辑,这类最常见的是关键字黑名单。比如后端拿到用户输入的id参数后,先检查里面有没有union、select、空格、单引号等字符,一旦命中就直接返回错误页。很多老项目里的代码写得很粗暴,直接用str_replace或正则把关键词替换成空字符串,或者用addslashes给引号加反斜杠。
第二层是WAF(Web应用防火墙),这东西既有硬件设备也有云端的软件方案。WAF拦截的依据一般是正则规则库,比如检测到union[\s\S]+select这种组合特征就直接拦。这里有个关键点:WAF通常工作在HTTP层,它看到的原始报文是URL编码后的内容,如果解码顺序处理得不好,就容易被编码混淆蒙混过去。
第三层是数据库本身,极少数据库会主动拦注入语法,数据库只负责执行。但这恰恰是绕过的终极武器——只要你能让数据库解析出一段恶意SQL,前面所有过滤层都不重要了。
1.2 过滤层与数据库解析层的差异是绕过的根源
我打个比方。过滤层像一个保安,他只认固定几个“危险面孔”——看到“union”两个字就赶人,看到空格就警觉,看到引号就翻包检查。但数据库像个翻译官,它能识别各种同义词、方言、省略句式。你想混进去,不需要长得完全不像危险分子,只需要让保安认不出,同时翻译官还能看懂你要表达的意思。
具体来说,差异主要出现在这几个地方:
- 字符集与编码差异:同样一个中文字符或特殊符号,在URL编码、Unicode编码、十六进制编码下长得很不一样。过滤规则如果只解码一层,就可能在编码链路里漏掉变体。
- 解析器的宽容度:数据库SQL语法对空格、注释、运算符都相当宽容。
select和SELECT一样,/**/能当空格用,+在某些数据库里也能连接字符串或当空白。过滤层若按“严格标准”去匹配,自然挡不住这些变体。 - 过滤规则本身写得太死:只拦截小写的
union,没拦截Union;只替换一次关键字,替换完的字符串里又生成了新关键字;只拦了空格,没拦%0a。这些都属于规则缺陷,也是“字符绕过”大量手法的直接来源。
理解了这条核心逻辑,后面所有绕过的套路都变得顺理成章:要么让危险字符在过滤层不以“危险”的样子出现,要么让数据库在解析的时候把看似无害的字符重新解释成有语义的东西。
2. 常见过滤点的绕过套路拆解
字符绕过的对象主要集中在三类输入上:空格、SQL关键字、引号和字符串。下面按这三类逐一拆开讲,每个都给出能用得上的替代方案。
2.1 空格被过滤时用什么顶上
空格是SQL注入里出现频率最高的字符,几乎每个payload里都有。如果系统把空格直接过滤掉,经典的union select就会断成两截没法解析。此时可以换这些替代品:
- 注释符
/**/:这是最常用的方案。union /**/ select在MySQL里等价于union select,因为MySQL把注释符中间的内容当作空白处理。实测中几乎百试百灵,唯一的风险是很多WAF已经把这个特征写进规则库了,单独用容易被识别。 - URL编码空白符:
%09(制表符)、%0a(换行)、%0d(回车)、%0b、%0c都可以在部分场景替代空格。直接把?id=1%09union%09select%091扔过去,如果后端没做URL解码后的二次过滤,就能穿透。 - 括号括住表达式:MySQL语法里很多位置可以用括号消除空格需求。比如
union(select(1),2,3),这里select后面的括号把参数列表包起来,数据库照样能解析。这个技巧对空格过滤特别有效,因为在URL里括号不需要编码,不容易被WAF盯上。
实操经验是:先测系统到底过滤了哪些空白符。做法很简单,逐个提交%09、%0a、%0d、/**/看返回结果有没有差异。有些系统只过滤了普通空格,压根没想到过滤制表符和换行,此时就能直接用最低成本的替换方案。
2.2 关键字被过滤时的替换思路
当union、select、and、or这些关键字被过滤时,可用思路明显更多。我按实用程度排一下:
- 大小写混写:
UnIoN SeLeCt。很多正则表达式只匹配小写或没加i修饰符,数据库则完全不区分关键字大小写。这个方法简单到容易被忽视,但在真实目标里命中率其实很高。 - 双写绕过:适用于过滤逻辑是“把关键字替换成空字符串”的情况。比如系统执行
str_replace('union', '', $input),那么你提交ununionion,第一次替换后内部的union被删掉,留下的是union,数据库正常执行。对付这类只处理一次的正则,双写是经典解法。 - MySQL内联注释:
/*!union*/。这是MySQL独有的“魔法注释”,MySQL服务端会解析注释体里的SQL,而普通过滤层会把/*!当成普通注释直接放行。很多系统根本没对这个做兼容处理,所以实战价值很高。 - 等价关键字替换:这是最值得花时间研究的方向。比如
and可以用&&替代,or可以用||替代;=可以用like、in、regexp替代;select在某些非查询场景下可以用show、desc、describe替代。甚至from都可以用join语法结构绕开。 - 函数等价替换:确认注入点后,获取数据不一定非要靠
union select。如果页面没有回显但存在报错信息,可以用extractvalue、updatexml这类函数触发报错来带出数据;如果什么都看不到,就转布尔盲注或时间盲注,用sleep(N)判断真假。
这些替换思路不是孤立的,绝大多数真实场景需要组合使用。我的习惯是:拿到一个过滤规则之后,第一时间把“哪些字符被过滤、过滤了几次、区分不区分大小写”测试清楚,然后再针对性地组合绕法,而不是一上来就试网上随便搜到的payload。
2.3 引号与字符串常量被限制怎么办
字符型注入点和很多查询语句里都需要用到单引号'。如果系统把单引号直接转义或过滤,字符串就写不进去,这时有几个替代手法。
- 数字型注入优先转构造:如果注入点本身就是数字型,比如
where id=1,那根本不需要引号。这也是为什么我判断注入类型会最先确认数字型还是字符型。 - 十六进制编码:把字符串写成十六进制形式,数据库会自动转回原字符。比如
admin的十六进制是0x61646D696E,where username=0x61646D696E在MySQL里就等价于where username='admin',一个引号都不用。这个方法在MySQL和某些国产数据库分支上很稳定。 - char() 函数拼接:如果数据库支持,可以用
char(97,100,109,105,110)拼出字符串。效果类似十六进制,但写法上又是另一种特征,可以用来躲避针对0x开头的过滤规则。 - 反引号或方括号包裹字段名:MySQL里表名和字段名可以用反引号包裹,SQL Server支持方括号。当空格和引号都受限时,这些符号可以辅助构造合法SQL,但要注意不同数据库的差异。
这里提醒一个踩过的坑:很多WAF同时检测单引号和等号,如果只绕过了引号却留着等号,照样会被拦。替换时要把整条语句的所有可疑字符都考虑进去,而不是只盯着被过滤的那个点。
3. 一次完整绕过实操:从探测到取数
套路讲了一大堆,不如完整走一遍流程。为了把过程说清楚,我搭了一个模拟环境,它模拟的是某公司内部订单查询系统的一个常见接口,逻辑非常简单,但过滤规则恰好覆盖了前面说的多个点,我认为这种一站式演示对理解“为什么这样绕”帮助很大。
3.1 模拟环境与过滤逻辑
模拟系统的功能是:用户传了一个订单编号参数,后端把参数直接拼进SQL查订单表,页面会回显订单状态和金额。后端代码大概长这样:
<?php $id = trim($_GET['id']); // 过滤层:海象级防护 $id = str_replace(' ', '', $id); $id = str_replace('union', '', strtolower($id)); $id = str_replace('select', '', strtolower($id)); $id = addslashes($id); $sql = "select * from orders where id='$id'"; $result = mysql_query($sql); // ... 页面输出订单状态、金额 ?>这套代码的过滤策略不算复杂,但是有典型代表性:去掉了空格、把union和select替换成空字符串、给单引号加反斜杠。
3.2 一步步构造可用Payload
第一步,确认注入点。参数被拼进SQL用的是单引号包裹,我提交?id=1',页面返回错误或者行为异常,说明存在字符型注入。接着验证闭合方式,提交?id=1' and '1'='1,如果页面正常返回,就说明单引号前面确实闭合了。这里空格还没有被过滤——但注意,这只是为了探测,真正的利用阶段空格会被删。
第二步,确认查询列数。用order by来试最方便。普通写法是?id=1' order by 3--,但这套代码会把空格删掉。所以我改用注释符替代空格:?id=1'/**/order/**/by/**/3--。这里有个细节:--后面本身要有空格的,但代码把$id里的所有空格删光,连注释符后面的空格也被删了,此时单引号后面的--不一定能正确注释掉尾部单引号,所以更稳妥的注释方式是MySQL的#或者--%20在URL里被后端还原成空格之前就被删了,所以直接用#收尾反而干净。于是请求变成?id=1'/**/order/**/by/**/3#,页面正常;再把3改成4,页面报错,说明查询列数是3列。
第三步,构造union查询。原始payload是' union select 1,2,3 #,但双关键词都会被过滤。双写绕过就派上用场了,因为过滤逻辑是str_replace('union','', strtolower($id)),一次替换,我提交ununionion让它删掉中间的union后剩一个union。同样,seselectlect经过替换后变回select。拼接之后完整payload是:
?id=1'/**/ununionion/**/seselectlect/**/1,2,3#页面回显了数字2和3,说明这两个位置可以直接显示数据。这里有一个细节值得注意:代码在替换过滤之后才加addslashes,这意味着如果替换过程产生的新字符串里带有单引号,反斜杠是没有机会处理的。这种过滤顺序漏洞,正是很多“看起来防御很严密”的系统被打穿的原因。
第四步,读取数据。既然列数确定了,回显位置也确定了,把2号位替换成database(),3号位换成version(),就能把数据库名和版本号带出来。实际请求:
?id=1'/**/ununionion/**/seselectlect/**/1,database(),version()#继续深入,把2号位替换成子查询去查表名。注意子查询里同样不能出现空格和关键词,尽量复用替代规则。我构造了:
?id=1'/**/ununionion/**/seselectlect/**/1,(seselectlect/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema=database()),3#到这里,已经从“单引号被过滤”“空格被过滤”“union和select被过滤”这套组合防线中完整走通了取数链路。整个过程没有用任何高明工具,纯手工修改payload结构。把每步的返回结果记录下来,能清楚地看到哪些替代是成功的、哪些被WAF拦在了前置层。
3.3 不同数据库下的变体
同一套过滤规则,在不同数据库里应对方式差异很大。我把常见的替代写法整理成表,方便遇到不同后端时快速对照:
| 过滤点 | MySQL | SQL Server | Oracle |
|---|---|---|---|
| 空格被过滤 | /**/、%0a、%09、括号 | /**/、%0a、%0d | /**/、%0a、%0d、()括号 |
| 字符串引号被过滤 | 十六进制0x...、char() | char()拼接、0x(受版本影响) | chr()、` |
| 关键字被过滤 | 内联注释/*!union*/、双写 | %00截断(老版本)、双写 | 双写、大小写混写 |
| 注释符 | --、#、/*...*/ | --、/*...*/,无# | --、/*...*/ |
| 特定手法 | load_file()读文件、into outfile写文件 | waitfor delay做时间盲注 | utl_http外带数据、` |
SQL Server不像MySQL那样支持内联注释执行,所以它的绕过更依赖空白符和编码技巧。Oracle对注释符格式要求严格,--后必须跟空白字符,这个空白字符又容易被过滤,所以Oracle场景我更倾向于用||拼接或子查询来绕。了解这些差异的意义不是背表格,而是排查时能快速判断后端类型,缩小尝试范围。
4. 防御端视角:如何让绕过失效
安全测试做到一半,我最常被研发问到的问题就是:“那到底怎么写才不会被打穿?”其实防御思路比攻击手法简单得多,核心只有一条:别把用户输入直接当成SQL执行。
4.1 参数化查询彻底消灭拼接
所有字符绕过技巧能成立的前提,都是用户输入被拼进了SQL语句的语法结构里。只要避开字符串拼接,用参数化查询(PreparedStatement)把输入当作纯数据传给数据库,过滤层就算形同虚设,也没法注入。参数化之后,用户输入个' union select ...,在数据库眼里就是一行普通文本,连SQL语法解析都不会进入。
Java里用PreparedStatement,PHP里用PDO的预处理,Python里用带?占位符的游标,ORM框架默认也走参数绑定。唯一要注意的是,很多老项目里动态拼接的SQL不是主查询,而是表名、字段名、排序字段、limit参数这些位置,这些位置无法参数化,必须走白名单校验。比如排序字段只允许asc、desc,表名只允许预设的几个值,这是参数化覆盖不到的死角,需要专门写校验。
参数化查询可以说是底线,但我也见过不少系统“参数化”之后因为代码bug把用户输入重新拼了回去。比如开发者以为用了PreparedStatement,却把SQL语句本身也由前端传入,这等于还是拼接。代码评审时需要重点盯“SQL语句本身是否静态”这件事。
4.2 多层输入校验与WAF规则设计
如果某些老系统实在改不动,或者临时应急,就必须在过滤层和WAF层做重防御。这里有一个容易被忽视的点:单层过滤永远不够。正确的做法是在网关、应用层、数据库前置代理各自加一道独立校验,并且每一层的过滤规则不要写死成“只匹配小写关键字”。
WAF规则设计可以从这几个维度入手:
- 不只匹配原始关键字,还要匹配URL解码、Unicode规范化之后的形态。先解码再检测,并且要解码两层以上。
- 对
union、select、/**/这种组合特征做“语义指纹”,一旦发现“关键字+空白符+关键字”的结构就拦截。单纯匹配单个关键字会漏掉大量变体。 - 关注数据库特定语法:比如MySQL的
/*!内联注释、SQL Server的%00截断、PostgreSQL的$$符号,这些特征在常规攻击里出现频率低,但一旦出现基本就是恶意行为,可以单独设高优先级规则。 - 对高危接口启用单独的“输入白名单”策略:订单号只允许数字和短横线,邮箱只允许邮箱字符,这种接口级白名单比通用黑名单可靠得多。
这里还要特别提醒:WAF拦截日志很值钱,但前提是你得开着日志且定期有人看。实际操作中我发现,很多已被打穿的系统,WAF日志里其实早就躺满了绕过尝试记录,只是没人归纳特征、更新规则,导致规则库几个月都没变过。
4.3 日志监控与事件复盘
每一次攻击尝试都会在Web日志、数据库错误日志、WAF告警里留下痕迹。哪怕攻击者成功绕过了,日志也能还原出完整的攻击路径。我建议有条件的团队做三件事:
一是把HTTP请求的完整参数原文记录进日志,不要只记业务字段。如果某个参数值里出现了编码符号、注释符、数据库函数名,这些本身就是强烈的告警信号。
二是把数据库错误信息不要直接返回给用户,而是单独记录到后端错误日志。SQL注入测试的一大特征就是触发大量数据库报错,通过统计报错次数突然上升,往往能比WAF更早发现异常。
三是安全测试和研发定期一起复盘真实攻击日志。我在模拟测试中跑出来的那套payload,如果出现在生产环境日志里,开发和安全的判断会完全不一样。提前把常见绕过模式同步给研发,他们写代码时就会主动避开容易踩坑的写法。
防御不是加一个WAF设备就完事,它是“代码层参数化、边界层白名单、流量层语义检测、事后层日志审计”的联动,缺一环,其他环节的压力就大了很多。
5. 常见问题与排查速查表
最后把字符绕过里最高频的问题整理成一张速查表,方便实战时快速对照。这些现象和对应解法都是我在模拟环境里反复测试过的,基本覆盖了日常能碰到的过滤规则组合。
| 现象 | 说明 | 尝试方向 |
|---|---|---|
union被拦截 | 命中关键字黑名单 | 大小写混写、双写、MySQL内联注释、等价函数替换 |
| 空格被过滤 | 无法拼接SQL片段 | /**/、%09、%0a、%0d、括号包裹 |
| 单引号过滤/被转义 | 字符型注入无法闭合 | 转数字型注入、十六进制、char() |
and/or被拦截 | 布尔盲注无法构造条件 | &&、||、xor替代 |
| 有报错但无回显 | 报错信息泄露可用 | extractvalue、updatexml、floor(rand(0)*2) |
| 无报错无回显 | 页面完全静默 | 布尔盲注逐字符判断、时间盲注sleep、外带 |
=被过滤 | 条件判断无法构造 | like、in、regexp、between |
| 注释符被过滤 | 无法截断SQL尾部 | MySQL#换--,或尝试闭合引号构造完整语句 |
| 过滤后生成新关键字 | 单次替换缺陷 | 双写绕过,确认替换次数 |
| 大小写混写也拦 | WAF规则做不区分大小写匹配 | 内联注释、编码、等价函数、盲注 |
再补几个容易踩坑的实操体会:
体会一:过滤顺序比过滤内容更致命。像前面模拟环境里那样,先做关键词替换再做转义,两个绕过面会叠加。排查代码时,先看过滤函数的执行顺序,常能发现意外惊喜。
体会二:URL编码层面的绕过度数经常被高估。有些WAF会做递归解码,%2527这种二次编码可能还是会被还原成单引号拦截。真正稳妥的绕过点通常不在编码层,而在SQL语法本身的宽容度上,比如空格替代和括号语法。
体会三:payload越短越容易过。WAF规则本质上是在做字符串匹配,payload里堆砌的注释符、编码符越多,特征就越明显,越容易被规则命中。能用order by就别用group by,能用updatexml就别拼三段式union,减少特征数量才是绕过的核心原则。
体会四:盲注不是最后手段,而是必然手段。很多系统即使存在注入点,也不会把数据回显到页面上。这时候与其死磕回显,不如直接切盲注。时间盲注虽然慢,但稳定性远高于布尔盲注,尤其是在目标系统有全局缓存的情况下。个人习惯是先测sleep(5)能不能让响应延迟,如果能,就说明时间维度上可控,后面走得会顺很多。
我的建议是:拿到一个注入点,不要急着打出完整payload,先把环境探测清楚。确认数据库类型、过滤关键字、过滤次数、空格是否过滤、注释符是否有效,五步探测做完再选绕过方案。这样看起来慢,实际成功率却高得多。字符绕过说到底不是背payload,而是理解过滤规则和数据库解析规则之间的错位。错位在哪里,绕过就在哪里。