做安全测试这几年,SQL注入是我每次评估都绕不开的科目。这漏洞有二十多年历史了,可直到现在,我在不少项目的代码审计里还是能翻出字符串拼接的查询语句,只要某个参数带着查询条件进了数据库,又没有做严格约束,它就可能被拼出异常的SQL执行逻辑。更麻烦的是,现在多数系统都挂了WAF或做了关键字过滤,常规的Payload一上来就被拦,测试节奏经常卡在“明明感觉有洞,但进不去”的阶段。
这时候就必须理解“绕过”这件事。绕过不是玄学,它只是攻防对抗里的一种策略:既然关键字被过滤,就换等价写法;既然规则在匹配常见攻击签名,就尝试编码、拆包、注释分割。把绕过手法吃透,对两类人都有价值——安全测试人员能判断现有防护是否真的有效,开发人员则能反过来加固自己的系统,知道哪些过滤方案其实挡不住什么。
这篇文章围绕SQL注入的基础形态、判断方法、常用绕过思路展开,最后落到防御端的自检手段。内容偏实操,读者有点Web开发或安全测试背景最好,没有的话顺着一行行例子看,也能get核心逻辑。所有验证建议在自建靶场或授权测试环境里进行,这一点我放在最前面说干净。
1. 先厘清一个前提:SQL注入从检测到规避的全链路
SQL注入从来不是一个孤立的漏洞形态。我评估一个项目时,不会只盯着某一条请求看,而是把整条链路过一遍:请求入口、防护设备、业务代码、数据访问层、数据库执行。理解了这条链路,后面所有“绕过”动作就都有了坐标,也不会被单个拦截规则带偏思路。
1.1 一次带参数请求到达数据库前的几个关卡
一个典型的带查询参数的请求,大致会经过四个环节。
接入层先走一遍,这里通常是CDN、反向代理、WAF或API网关,做流量解析和规则匹配,绝大多数自动拦截发生在这层。应用层接着处理参数,进行类型转换、长度校验、关键字过滤,如果开发者没有任何安全编码意识,参数会原样进入SQL拼接语句。数据访问层是第三个关卡,ORM框架或原生SQL执行器在这个环节决定SQL是按预编译执行还是字符串拼接执行。最后到数据库,做语法解析并执行。
在实际测试里,大多数注入问题出在第三关:开发者用字符串拼接构造SQL,导致用户输入被当成SQL语法执行。而绕过问题,通常发生在前两关之间——要么输入校验写得不够严格,要么WAF规则本身可以被等价变换骗过。记住这个分层结构,后面讲各种绕过方案时就不会觉得散。
1.2 防护设备到底在拦什么
WAF和自定义过滤器最常干的事,就是做黑名单正则匹配。检测到union、select、sleep、information_schema这类的关键字就报警或阻断,检测到注释符--、#和单引号也直接拉入审核流程。
这里有个容易被忽略的核心点:黑名单防护做的是“签名匹配”,不是“语义理解”。签名匹配有一个天然弱点——数据库能识别语法,但规则识别的是字符串本身。同一个语法结构在数据库眼里只是换了种写法,在正则表达式眼里可能就完全不匹配了。比如SEL/**/ECT这种写法在MySQL里会被解析成SELECT,但在简单正则匹配里就是两个带注释的字符串块。绕过的空间由此产生,这也是后面第三章要展开的内容。
现代WAF在往语义解析方向演进,但部署成本高、误报率也高,很多系统依然跑着老式规则,所以理解黑名单思维的局限,仍然是一切绕过技术的地基。
1.3 意识到需要测试“绕过”的几种场景
并不是所有系统都需要测试绕过。如果代码层用了参数化查询并做了类型约束,那WAF绕没绕过都不太影响SQL安全性;如果代码层本来就有明显拼接漏洞,那测试重点也不是怎么绕过WAF,而是直接确认注入能利用到什么程度。
真正需要认真测试绕过的场景,通常有几个特征:系统的WAF规则很严,常规Payload一提交就403或返回拦截页面,但接口本身确实存在未加约束的查询参数;代码层做了关键字过滤,把select直接替换成空字符串,但替换逻辑只做一次,或者正则存在漏配;请求参数经过多层转发或参数重组,前面设备解析的方式和后面应用实际取值的方式不一致;接口既没有类型约束也没有参数化查询,但防护设备正好挡住了常见路径,需要绕过确认其他手法是否可行。
做好这类测试的前提是先做“探测式请求”,用尽量不触碰规则的小样本来判断后端行为,再根据反馈逐步调整Payload。绕过不是孤立的花活,它是基于反馈的迭代过程。
2. SQL注入的常见形态与判断思路
绕过的前提是清楚原生注入长什么样。这里把几种经典注入形态过一遍,重点不在罗列技巧,而是把判断逻辑讲明白。很多绕过方案的最终目的,就是把某种注入形态重新变成“可用”。
2.1 联合查询注入:从“有没有”到“有多少列”
联合查询注入是新手最容易上手的形态,因为结果直接显示在页面上。它的本质是利用UNION SELECT把攻击者的查询结果拼接到原查询结果之后。
判断过程通常是三步。第一步确认注入点,在参数后加单引号、双引号或数字运算,观察返回是否有异常或变化。第二步用ORDER BY探测列数,比如?id=1 ORDER BY 1正常,?id=1 ORDER BY 10报错,说明列数小于10,继续二分或逐个试,直到找到临界列数。第三步用无结果查询拼接UNION SELECT。
-- 探测列数 ?id=1 ORDER BY 5 -- 确定列数为5后,替换回显位 ?id=-1 UNION SELECT 1,database(),user(),version(),4这里有个细节值得说:为什么一般用id=-1而不是id=0?因为有些查询在id=0时可能仍然返回数据,而-1这类不存在的值配合UNION,可以让攻击者的子查询结果排在前面。这个细节在多次实际测试里都帮上过忙,值得养成习惯。
2.2 报错注入:让数据库把答案写在报错里
遇到页面不回显查询结果,但会把数据库报错信息打出来时,报错注入就很好使。MySQL环境下最常用的是extractvalue和updatexml这类XML函数,它们本来用于解析XML,但如果传入格式错误的XPath表达式,MySQL就会把错误信息连同表达式内容一起返回。
?id=1 AND extractvalue(1,concat(0x7e,(select user()),0x7e)) -- 输出类似:XPATH syntax error: '~root@localhost~'注意0x7e是波浪号~的十六进制,用来标记报错边界,方便在大量错误信息里快速截取数据。报错注入有个硬前提:服务端必须把错误内容回显给客户端。线上系统如果把详细报错关掉,或者WAF直接拦截包含extractvalue、updatexml等函数名的请求,这条路就断了,这时候往往要转向盲注。
2.3 布尔盲注与时间盲注:不依赖回显的判断方式
生产环境里不回显数据、不输出敏感报错的情况太常见了。这时候要判断注入是否存在,只能通过页面变化来侧写数据。布尔盲注的核心是让条件成立和不成立时页面表现不同。
?id=1 AND ascii(substr(database(),1,1))>100如果数据库名第一个字符的ASCII码大于100,页面正常;否则页面返回异常或空内容。通过不断改变比较值和位置,可以一位一位把数据抠出来。时间盲注则更进一步,不依赖页面状态变化,而是通过让数据库睡眠来制造时间差:
?id=1 AND if(ascii(substr(database(),1,1))>100,sleep(3),0)这类注入的判断逻辑不涉及特殊关键字,显得比较“温和”,但在绕过场景里反而经常成为突破口。很多WAF对sleep的限制很严,但换成benchmark这类耗时函数,或者把if改写成case when,就可能让规则失明——这正是下一章节的重点内容。
2.4 堆叠注入与文件读写:注入一旦碰到底层会怎样
堆叠注入指一条请求里带上多条SQL语句,用分号分隔。比如:
?id=1; DROP TABLE users--它的成立条件很苛刻,因为很多数据库驱动和ORM在设计上只允许单条语句执行。但一旦成立,危害就超出了查询范畴,可以增删改、可以调用存储过程、甚至可以尝试写入文件。文件读写本身是另一个独立的风险面,比如LOAD_FILE()读服务器上的敏感文件,INTO OUTFILE写入WebShell:
?id=1 UNION SELECT '<?php echo 1;?>' INTO OUTFILE '/var/www/html/test.php'这条路通常需要当前数据库账号具备文件相关权限,并且知道网站绝对路径。部署层面限制文件权限、关闭不必要的数据库函数,比单纯拦截关键字有效得多。
3. 绕过手法的六条路:原理、构造与实测
进入正题。这六类方法我在实际项目中接触最多,也被验证最有效。每条路我都会把原理、典型构造和局限说清楚。先再声明一次:下面所有构造都用于自建靶场或授权测试环境的验证,请勿对线上系统做未授权测试。
3.1 大小写、内联注释与关键字的切分组合
最简单的绕过尝试,自然是SeLeCt、UnIoN这种混合大小写。只要WAF规则里没加大小写不敏感标志,这种写法就能骗过一部分老规则。但现代WAF基本都把正则写成对大小写不敏感的,单靠大小写替换已经很难有惊喜。
更有价值的是内联注释和关键字切分。MySQL里有一种特殊注释写法/*!...*/,里面的内容会被当成SQL执行而不是注释,这给了绕过一个非常灵活的工具:
?id=1 /*!union*/ /*!select*/ 1,2,3更常见的切分写法针对的是应用层关键字过滤。如果代码用str_replace把select替换成空,可以写成SELSELECTECT,替换一次之后剩下SELECT;如果过滤逻辑只处理了完整关键字,还可以用UNION/**/SELECT这类带注释的写法。替换逻辑每多一层,可用的变形就多一分。这类方法在过滤规则较简单的系统上很好用,但在语义型WAF面前效果一般,需要搭配其他思路。
3.2 特殊空白字符与看不见的分隔符
数据库解析SQL时,对空白字符的识别比我们想象中宽松。MySQL能识别的不只空格和Tab,还包括换行符、垂直制表符、换页符、回车、非换行空格等。很多WAF规则在设计时只考虑了空格作为单词分隔符,给这类字符留下了缝隙。
?id=1%0aunion%0aselect%09user(),2,3在这个构造里,%0a换行替代了UNION前面的空格,%09Tab替代了SELECT后面的空格。URL解码后,数据库看到的仍然是一条合法SQL,但正则匹配时因为要求关键字之间必须以空格分隔,这条请求就绕了过去。要注意的是,这类方法依赖WAF先于URL解码检查请求,如果防护设备是解码后再匹配,就很容易被识别。测试时可以先提交一次纯URL编码版本,看拦截是否还会命中,再决定是否叠加更多层的编码变形。
3.3 等价函数替换与系统表更换
关键字黑名单很难把等价写法全部覆盖。我用得最多的三个替换方向是:函数替换、系统表替换、字符串处理函数替换。
函数替换的典型例子是耗时函数。sleep被拦了,就用benchmark:
?id=1 and benchmark(10000000,sha1('test'))benchmark会重复执行后面表达式很多次,作用同样是拖慢响应,但规则里如果只检测sleep,这条就漏过去了。
系统表替换在MySQL环境中特别有价值。很多过滤规则盯死information_schema,但在MySQL 5.7及以上版本里,sys库和mysql库里的表也能拿到元数据:
-- 常规写法 select table_name from information_schema.tables -- 绕写法 select table_name from mysql.innodb_table_stats select table_name from sys.schema_tables字符串处理函数也常常是绕过滤器的突破口。substr被拦了,substring、mid、left、right可以换着用;concat被拦了,concat_ws、group_concat还能顶上来。
我整理了一张函数替换速查表,日常测试时直接对照用:
| 原始写法 | 可替换写法 | 说明 |
|---|---|---|
| sleep(3) | benchmark(1000000,sha1('a'))、get_lock('test',3) | 耗时函数或锁函数 |
| substring(str,1,1) | substr、mid、left、right | 截取子串 |
| ascii(char) | ord(char) | 取字符编码 |
| concat(a,b) | concat_ws(':',a,b)、group_concat | 字符串拼接 |
| version() | @@version | 获取版本 |
| database() | schema() | 获取当前库名 |
一个经验是:替换不只是为了绕规则,更是为了在规则不断升级的环境里保持测试可行性。通常一个注入点能用的等价函数越多,说明应用层的过滤方案越脆弱。
3.4 运算符、逻辑与比较逻辑的等价变换
防护规则常把=、>、<这类比较运算符也纳入检测范围,因为它们太好用了。但SQL提供的比较和逻辑表达远比这几个符号丰富。
等号被过滤时,可以用like、in、regexp、between等操作符:
?id=1 and username like 'admin' ?id=1 and username in ('admin') ?id=1 and username regexp '^adm'and、or被过滤时,&&和||可以顶上。但URL里&本身是参数分隔符,需要URL编码成%26%26才能被后端正确解析。布尔盲注里常见的判断写法也可以用case when规避关键字:
?id=1 and if(ascii(mid(user(),1,1))>100,1,0) -- 改用case when,避开if关键字 ?id=1 and (case when ascii(mid(user(),1,1))>100 then 1 else 0 end)另一个容易被忽略的点是逗号。substr(str,1,1)里的逗号如果被过滤,可以用substr(str from 1 for 1)这种替代语法;LIMIT 0,1可以改成LIMIT 1 OFFSET 0。很多规则列表里完全没考虑这些替代语法,是一类低注意度的绕过面。
3.5 编码层面的绕过:URL、十六进制与字符集陷阱
编码变形是绕过的基础手段。URL编码、双重URL编码是最常见的两种,前者把单引号写成%27,后者再编码一次让%变成%25。如果后端和WAF的解码次数不一致,双重编码就可能脱检。
十六进制编码在数据写法上很有用,字符串可以写成十六进制形式,WAF如果没做十六进制解码就识别不出来:
?id=1 and table_name=0x75736572730x7573657273是users的十六进制表示。这套写法在绕过表名、字段名相关过滤时很常用,只要把目标字符串提前转成十六进制即可。
字符集陷阱中最经典的是宽字节注入。核心是利用字符集转换时“吃掉”转义符:当数据库连接字符集是GBK时,攻击者提交%bf%27,%bf与转义反斜杠%5c组合成一个合法的宽字符,后面的单引号成功逃出转义。这个问题的根因是字符集设置与转义函数不匹配。现在默认UTF-8环境下出现频率没那么高,但一旦遇到旧系统,它依然是绕过滤的高效路径。
3.6 协议层与请求语义的绕过
最后一类绕法和SQL语法无关,目标是让WAF“看不懂”请求结构。防护系统解析HTTP请求的方式如果和后端路由不一致,就会产生语义差。
参数污染是典型例子。同一个参数名提交多次,不同中间件对取值策略不一样:有的取第一个,有的取最后一个。如果WAF检查的是第一个值,而后端取的是最后一个值,把真正的注入逻辑放在最后就能绕过:
?id=1&id=1 union select 1,2,3分块传输编码是另一种玩法。请求体被拆成多个块,每块标明长度。如果WAF没有完整重组分块、或者只检查了第一块,那么多块拼接起来的完整内容对后端来说依然是正常请求,检测层却看不到全貌。
JSON参数同样值得注意。现代接口大量使用Content-Type: application/json,如果WAF只按表单方式解析,JSON里的嵌套字段和类型转换后的值就可能成为漏网之鱼。这类问题的本质是协议解析器的覆盖面和实现方式不同,所以单纯在WAF上叠加规则很难根治,需要从解析层面做对齐。
4. 防绕过的落地手段:从代码审计到安全上线
研究绕过最终是为了防绕过。无论WAF规则写得多么厚,只要业务代码自己把语义安全做扎实,绕过来反而没有利用价值。下面聊几个验证过有效、又容易落地的加固方向。
4.1 参数化查询是第一道也是最重要的一道墙
参数化查询能终结绝大多数注入问题,因为它在编译阶段把SQL结构和数据分离了。SQL语句的骨架先确定下来,传入的值只作为参数绑定,不会被拼接到语法结构里。无论是PDO的prepare、JDBC的PreparedStatement,还是MyBatis的#{},底层原理都一样。
MyBatis使用上有个重点需要注意:
<!-- 安全写法 --> <select id="queryUser" resultType="User"> select * from user where id = #{id} </select> <!-- 危险写法 --> <select id="queryUser" resultType="User"> select * from user where id = ${id} </select>#{}走预编译,${}直接把值拼进SQL。很多开发者为了写动态SQL方便,在排序字段、表名等位置使用${},这类位置一旦被外部输入控制,注入依然成立。参数化不能覆盖所有场景,动态表名和排序字段必须用白名单映射,而不是信任输入。
4.2 白名单输入校验比过滤黑名单可靠太多
很多应用层的过滤逻辑是典型的黑名单思路:把union、select、sleep等词替换为空或直接阻断。这种方案的维护成本极高,而且黑名单永远有一个问题——你没想到的写法就是漏洞。
白名单恰好反过来。大多数参数能接受的值域是有限的:ID应该是整数,状态应该是有限枚举值,邮箱应该匹配特定格式。验证逻辑写成白名单后,不符合格式的请求直接拒绝。
import re # 数字ID,只接受纯数字 if not re.fullmatch(r'\d{1,10}', user_input): raise ValueError("invalid id")这个思路一旦生效,攻击者根本走不到拼接SQL那一步。白名单的好处在于:即使绕过了WAF,代码层也不会给任何机会。把参数类型、长度、取值范围在设计阶段就划清楚,比事后不断补黑名单省心得多。
4.3 数据库账号权限收敛与错误输出治理
代码再安全,如果数据库账号权限失控,危险依然存在。应用连接数据库应该使用最小权限账号:只允许访问业务库的必要表和视图,禁用FILE、SUPER等高级权限,不把DBA账号用在业务代码里。这里有一个简单的权限收敛示例:
-- 只给业务账号必要的SELECT/INSERT/UPDATE/DELETE权限 GRANT SELECT, INSERT, UPDATE, DELETE ON biz_db.* TO 'app_user'@'localhost'; -- 禁止FILE权限 REVOKE FILE ON *.* FROM 'app_user'@'localhost';错误输出治理也很关键。很多报错注入能成功,是因为开发环境默认打开了详细错误显示。线上要关闭display_errors,把异常记录到日志而不是抛给用户。日志记录要尽量保留原始输入,方便事后分析攻击路径。
4.4 WAF规则的正确打开方式与误区
WAF是纵深防御的一环,但不应被当作唯一防线。如果应用层没有参数化查询,WAF再强也只能是一个“概率拦截器”,拦截率再高也有漏网的时候。
规则运营上,我建议至少做三件事:其一,把已知的攻击签名和正则规则收拢成可测试用例,每次规则变更后跑一遍,防止“修一个洞、堵一片功能”;其二,开启语义分析能力,单纯的关键字匹配在真实对抗中价值有限;其三,把WAF的日志同步到审计平台,和业务日志做关联分析。选型时也不需要追求功能最全的产品,更重要的是团队愿不愿意持续维护规则、跟进攻击手法的变化。
4.5 日志、审计与上线前的安全检查点
每次发布涉及数据库交互的接口,都应该有一份固定的检查清单。我的习惯是把下面这些条目作为发布前置条件:
- 是否所有查询都使用参数化或白名单映射?
- 是否所有动态表名、排序字段都走白名单映射?
- 数据库账号是否做到最小权限?
- 是否测试过
?id=1'、?id=1 and 1=1这类基础探测请求? - 是否确认错误信息不会回显给用户?
- 是否在WAF侧模拟过几种常见绕过变体?
- 是否对异常SQL日志配置了告警?
安全检查不是一次性的演练,把它做成常态化流程,比事后救火靠谱得多。
5. 自查清单与典型问题速查
最后把核心要点压缩成可直接使用的自查表,以及我在交流中被问得最多的几个问题。
5.1 六项核心配置自查表
| 检查点 | 推荐配置 | 原因 |
|---|---|---|
| SQL执行方式 | 参数化查询、禁止字符串拼接 | 从根本上隔离结构与数据 |
| 动态表名/排序字段 | 使用白名单映射 | ${}拼接无法用参数化覆盖 |
| 输入校验 | 白名单正则、类型约束 | 不符合格式直接拒绝 |
| 数据库账号 | 最小权限、独立账号 | 限制注入后的破坏半径 |
| 错误处理 | 关闭详细报错、日志记录原始输入 | 减少报错注入可利用性 |
| 防护与监控 | WAF+访问日志+异常告警 | 纵深防御与响应能力 |
5.2 常见问题与排查思路
问:项目里已经全部用了参数化查询,为什么还是被扫描器报SQL注入?
答:最常见的情况是某个点在表名、排序字段或模糊查询里用了拼接。优先排查${}、字符串拼接SQL、动态表名映射,这三类最容易被误判或漏判。
问:WAF已经把union和select封了,注入是不是就没了?
答:不一定。关键看代码层是否做了白名单和参数化。代码层如果不稳固,union和select只能挡住一部分形式,布尔盲注、时间盲注、报错注入可能仍然存在。黑名单过滤的意义在于增加攻击成本,不等于彻底消除风险。
问:拿到一个接口,我如何快速判断注入最可能成功的位置?
答:先看查询参数的类型。数字型优先尝试and 1=1、and 1=2确认页面差异;字符型优先尝试单引号和闭合方式;有JSON体就观察嵌套字段是否能被后端取到。判断出类型之后,再决定走联合查询、盲注还是绕过WAF。
问:时间盲注的sleep被WAF拦截了,除了benchmark还能用什么?
答:可以用get_lock做锁等待,也可以造一个子查询让数据量大到自然变慢。更本质的思路是:这类检测点需要的是“耗时可度量”,不必局限于某个函数。只要能稳定产生可观察的时间差异,手法就是成立的。
我自己在测试中的一点体会是:SQL注入的攻防对抗,表面看是漏洞利用和规则拦截的拉锯,内核其实是数据库语义和防护规则之间的错位。绕过能成立,往往是防护设备没有贴合SQL的真实语义。所以不管接下来工具和规则怎么迭代,代码层的参数化、最小权限、错误治理这三板斧,始终是绕不过去的防御地基。希望这篇分享能帮你把零散的绕过技巧串成一条可复用的思路。