上个月我帮某公司做了一次授权范围内的安全测试,目标是他们一个自研的业务查询系统,前端套了一款开源的WAF组件,规则集是默认的。第一天我按常规思路测一个名为“报表查询”的接口,参数report_id拼接SQL的迹象很明显,我把带and sleep(3)的时间盲注请求发过去,WAF立刻吐出一整页告警,响应码直接变成了403。按很多人的经验,到这里就可以写“WAF防护有效”收工了。但做过几年应用安全的人都清楚,WAF拦截一次不代表能拦截所有情况,尤其SQL注入这种已经存在二十年的问题,变体和编码组合几乎是无穷尽的。从那天下午开始,我在本地把目标系统的镜像搭好,花了三天时间反复构造、验证、绕过,最终用几个看起来完全不像攻击特征的请求穿过了WAF的拦截,一路拿到了后端数据库的权限。这篇文章就把整个测试过程、思路背后的原理,以及最后怎么把这些经验反哺成防御措施,完整记录下来。做Web安全的朋友、负责应用加固的开发者,应该都能从中拿到一套可以复用的方法论。
1. 授权测试实录:Payload被拦后的第一反应
1.1 注入点初探:从报错回显确认问题
最先做的是常规的参数收集。目标系统是一个基于某老旧PHP框架搭建的报表模块,我拿到授权后,第一步只做信息枚举:把所有接收用户输入的参数列出来,包括查询关键字、排序字段、分页参数、以及report_id这类看起来像数字主键的参数。大部分参数都表现正常,唯独report_id不对劲。
测试方式很克制:先在参数后面追加一个单引号,观察页面是否有异常。很多管理员会问,这不就相当于在主动攻击吗?但这类写法在授权测试里只是验证“输入点是否进入SQL上下文”的标准动作,单引号进去后,页面直接输出了一个SQL语法错误片段,错误信息里甚至带出了部分SQL上下文。这说明了两件事:第一,report_id确实被拼进了SQL语句;第二,应用没有屏蔽数据库的报错输出——这本身已经是第一个安全隐患。
确认了注入点之后,我并没有急着取数据,而是按测试计划先做无害验证:把report_id=1和report_id=2返回的结果做对比,确认通过参数能控制查询结果。这一步让后面的所有验证都有了清晰基线,也避免一上来就发出高风险的攻击请求。
1.2 先分清楚:拦截来自WAF还是应用代码
常规的时间盲注Payload一上去就被拦截,这反而让我松了口气——说明WAF确实在工作。但接下来的第一件事不是想怎么绕,而是先确认拦截到底发生在哪一层。判断错了方向,后面全是白忙。
我是这样区分的:
| 特征 | WAF拦截 | 应用层过滤/校验 |
|---|---|---|
| 响应状态码 | 403、406等特殊状态 | 多为200或400 |
| 响应内容 | 固定告警页、规则ID、标识字段 | 业务预期的错误提示 |
| 响应头 | 常带WAF自定义响应头 | 无明显额外头 |
| 拦截后应用日志 | 通常没有对应访问记录 | 会留下应用层日志 |
我用抓包工具看了响应头和响应体,确认是WAF拦的。它返回的拦截页带有规则编号和明文特征,明显是规则引擎命中,而不是应用代码里的参数判断。这个判断看起来基础,但在真实项目里,我见过不止一个测试人员把WAF拦截当成“应用有过滤”,或者反过来把应用过滤当成“WAF探测”,最后排查方向完全跑偏。
确认是WAF后,我多做了几个对比请求:发一条完全正常的report_id=1,观察WAF是否放行;再发一条只带union select的请求,观察命中特征的情况。结果正常请求通行无阻,带关键字的请求立刻被拦。这给了我一个重要线索:这个WAF的检测点很可能集中在“关键字特征”,而不是完整的SQL语义链。
1.3 值不值得绕:投入产出比的三个判断
在正式尝试绕过之前,我给自己设了三道判断题,这也是我处理所有WAF绕过场景的习惯。
第一,目标在不在授权范围内。这个业务系统是某公司明确委托测评的对象,本地镜像也是从对方提供的配置中还原出来的,合法边界清晰,可以继续深挖。如果是编外系统,不管技术上有多少种绕过方式,我一步都不会动。
第二,如果绕过了,能拿到什么。通过接口的报错回显,我判断这个注入点很可能支持联合查询注入,后端数据库权限如果没有做最小化,意味着可以读全库数据。业务影响面大,值得投入时间。
第三,时间成本能不能控制。项目周期只有一周,WAF绕过这种不确定性问题不能无限耗。我给自己定了一条硬约束:在本地环境最多测三天,三天后无论结果如何,都回到按既有防线验收并输出加固建议的轨道上。
三关都过了,才真正开始分析WAF的检测逻辑。
2. WAF的检测机制:正则、语义、行为三层网
2.1 WAF到底在检查什么
想绕过一个防御系统,第一步永远是摸清它的工作方式。很多刚接触安全的朋友会把WAF想象成一个“遇到危险字符串就打回去的保安”,这个理解太粗糙了。实际上的WAF更像“门禁、录像、行为分析”三种角色兼任的系统。
它通常做三件事:
- 请求解析:把HTTP请求拆开,搞清楚URL、Header、Body、Cookie里各装了什么;
- 规则匹配:把请求内容归一化后丢进特征规则集做匹配,命中的直接拦截;
- 会话管理:对来源IP、Cookie做频率控制和信誉判断,短时间密集请求会触发封禁。
其中决定“绕不绕得过去”的核心,在第二步的“归一化+规则匹配”。归一化的意思,是把URL编码、Unicode编码、大小写、重复斜杠等还原成一个标准形态,再交给规则去比对。这套设计初衷是防止攻击者用简单的编码变化绕过规则,但“归一化”本身也带来很多可以钻的缝隙——因为WAF归一化所依据的规范,跟后端Web应用实际解析请求时遵循的规范,经常不是同一个东西。
2.2 正则规则集的天然盲区
绝大多数开源WAF的默认规则集,还是以正则匹配为主。正则擅长抓那些“特征明显”的攻击,比如SQL注入里常见的union、select、sleep、information_schema。
这类规则的盲区也很典型。第一,规则往往只匹配“连续的关键字”,如果把关键字用注释符、换行、特殊空白拆开,规则就抓不到了。第二,有些正则写得不严谨,混入大小写就可能逃过去年。第三,如果规则引擎对匹配内容长度设了上限,超长的垃圾数据填充就能让特征“滑出”检查窗口。
这里要补充一个关键认知:正则盲区不等于系统漏洞。WAF不是数据库防火墙,它在设计上就没打算替代应用自身的参数过滤,规则集做得再大也只是“尽可能覆盖已知攻击模式”。绕过一次正则不难,但想让绕过能稳定复现,就必须理解规则背后的设计假设。
2.3 语义分析与行为模型的不足
这几年许多商业WAF开始宣传“语义分析”,用算法理解请求里的SQL片段是不是真的进入了一条可执行查询路径,而不是单纯看有没有特征词。听起来高级,落地时问题不少。语义引擎对嵌套子查询、大量动态拼接、编码层数多的请求,漏检率并不低;而且语义分析消耗性能,线上为了保护吞吐量,往往不会把检查深度拉到最大。
行为模型这边,主要是看请求频率和会话特征。它对“同一个IP高频爆破”有效,但对“一次精心构造的变体请求”基本无能为力——毕竟只发一个请求,频率上没有任何异常特征。理解这一点对我后面的思路很有帮助:我不需要以量取胜,我需要让“单个请求在规则引擎眼里既不命中特征,也不像攻击行为”。
3. 绕过思路一:SQL语义的等价变形
3.1 不换功能,只换写法
SQL语言有一个特点:同一个查询语义,你可以写出非常多不同的表达方式。select可以写成SeLeCt,字符串可以翻译成十六进制或char()拼接,运算符可以从=换成<或<>,真值条件可以从1=1换成'a'='a'。
理解了这一点,绕过的核心思路就清楚了——不去构造全新的攻击,而是把原本会被规则命中的那个请求,改写成语义完全等价、但形态上不含任何被收录特征的新请求。这个过程有点像同义词替换:把“睡觉”换成“就寝”,意思不变,但关键词匹配就抓不到了。
我第一步尝试的就是把特征集中在关键字上的规则。把union select改成union/**/select——中间插入注释块,MySQL可以正常执行,不少正则却因为要求“空格或边界字符”而匹配失败。这里需要说明:如果WAF用的是简单的字符串包含检查,这种改法也会被拦,实际效果完全取决于规则写法。我在本地连续验证了十多种分隔符,注释块、换行、制表符、%0a都会带来不同结果。
3.2 运算符、函数与条件表达式的替换
特征关键字不只是SQL关键字,还包含函数名和常量。规则集里常见匹配对象是sleep()、benchmark()、if()、substring()这类函数。对函数名的替换,我的经验是分两条路走。
第一条路是函数名等值替换。substring可以换成mid或substr,concat可以换成concat_ws,sleep在MySQL里可以换成对一个字符串重复做计算密集操作的函数,效果是产生延迟,但特征名完全不同。当然,第二种替换本身会引入很大的计算压力,我在本地测试时只能用很小的参数,防止把测试容器的CPU打满。
第二条路是逻辑等价改写。比如布尔注入里经典的真条件if(1=1, 查全部, 查不出来),可以改成case when 1=1 then 查全部 else 查不出来 end。case when的写法不像SQL注入的标准形态,因此能逃过很多规则集的函数黑名单。理解这个技巧比背模板有用得多,因为规则集是活的,今天能用的函数名明天可能就会被加进黑名单,但“用等价语法表达同一逻辑”的思路基本不会失效。
3.3 注释符、大小写与MySQL内联注释
这里有一个值得单独拆开的MySQL特性:内联注释/*!...*/。MySQL会把/*!开头的内容当作注释中的“可执行代码”处理,也就是说/*!union*/在MySQL查询里会被解析成union关键字。但对WAF而言,注释块一般会被归一化逻辑删掉或忽略,很多正则引擎看到/*!就把它当注释排除了,于是检查点和执行点出现了严重的不一致。
/*!50000union*/这种写法,数字部分表示MySQL版本号,普通WAF更不会深究。我在实测中确实看到,多个规则集对带版本号的内联注释完全放行。这个特性拼的就是“WAF的归一化规则与MySQL的真实语法规则不一致”。
类似的语法差异还有很多:#注释、--注释后面是否必须跟空格、;分号后堆叠查询的支持程度等等,每一个差异都可能变成一个绕过窗口。我在测试记录里专门建了一张表,把每个变体在不同规则集下的拦截情况、放行情况都记下来,方便回溯。
4. 绕过思路二:让WAF“看错”请求
4.1 编码层的多层解码差异
等价变形是在语义层做文章,编码技巧则是在解析层做文章。WAF要检查请求,必须先解码;应用要处理请求,也要解码,但两者的解码次数和顺序不一定相同。
最常见的例子是双重URL编码。正常情况下,单引号被编码成%27,WAF解一次码就能还原成单引号并命中规则。但如果后端框架经历过两次解码,真正到达SQL语句的其实是%2527——WAF只解一次的话,它看到的是%27这个字符串,而不是单引号,规则自然就放行了。
这种方法的成败取决于后端技术栈的解析层数。我在本地试过不同框架的靶场环境,有的框架确实只解一层,双重编码会导致数据本身出错,根本不进入SQL上下文;但也确实有版本会解两层,这时WAF就成了“瞎子”。所以我不建议把“双重编码一定能绕过”写死在自动化工具里,正确做法是先用无害探针确认后端到底解几层,再针对性构造。
另外,Unicode兼容字符、畸形Content-Type编码声明组合也值得测,但成功率更低,且依赖具体后端组件的实现。这类技巧我只当作补充手段,不指望它打主力。
4.2 协议与参数处理的解析差异
第二类“看错”方式发生在协议层。HTTP协议本身允许同名参数出现多次,比如?id=1&id=2。不同Web容器对同名参数的处理策略不一样,有的取第一个,有的取最后一个,有的拼成逗号列表。WAF在检查时经常固定取第一个或最后一个,但后端取的值如果跟WAF取的不一致,就形成了参数污染的空间。
更常用的场景是:WAF把请求按application/x-www-form-urlencoded解析,但攻击者把Content-Type改成multipart/form-data,并在multipart某个字段里塞进攻击载荷。有的WAF对multipart的解析深度有限,尤其当一部分字段被当作文件流时,检查可能中断,而后端框架却是按默认方式解析全部字段的。
协议层技巧最大的问题是“脆”。有效性强烈依赖具体WAF解析器版本,版本一升级,绕过就失效。而且这类请求在运维眼里看起来就“不像正规请求”,容易引起告警和人工复核。我自己的原则是:这类手段在本地做验证可以,不到关键阶段绝不在线上轻易使用。
4.3 垃圾数据干扰:可用但脆弱
垃圾数据填充的思路是:用大量无害字符把请求上下文膨胀到超过规则引擎的处理能力,让特征被“淹没”。常见做法是在Payload前后加很长串的空格、无意义字母、无意义参数,或者用多个超长Header字段挤压检查资源。
我在本地测过,效果确实存在,尤其当规则引擎对单次匹配长度设了上限时,作用很明显。但问题同样明显:第一,日志里会产生明显异常记录,非常容易引起安全运营人员的注意;第二,请求体积变大之后,Web容器自身的参数大小限制会先触发,反而暴露了测试行为。所以这类技巧在我这里排名靠后,属于“迫不得已才上”的一类。
5. 本地复现与实测:哪些技巧属于“纸面功夫”
5.1 最小实验环境怎么搭
绕过技巧不能光看文档,必须有一个可以反复折腾的环境。这次我用的是一台本地测试虚拟机,上面起了两个容器:一个跑存在注入问题的简易Web应用,另一个跑开箱即用的开源WAF容器。网络拓扑很简单,请求从抓包工具发出来,先打到WAF容器,再由WAF转发给后端的Web应用容器。
选这个拓扑的目的,是让“被拦/放行”完全由WAF规则决定,不混进云服务商自带的安全策略。商用云WAF的规则集不透明,出了绕过也说不清踩的是哪条规则,回归复现很麻烦。开源方案的优势是所有日志、规则ID、命中详情都是明文,排查起来非常舒服。
具体步骤上:先把WAF容器配置成反向代理模式,监听本机端口;再把Web应用容器配置在内部网络;最后在抓包工具里把请求的Host指向WAF端口。整个过程大约半小时。如果只想快速验证一个思路,不需要完整业务数据,用教学用的靶场应用就够了。
5.2 用日志和自动化脚本做回归
有了环境,我开始批量验证。手工测几十个变体没问题,但我想观察“规则集对某一类变体的整体放行率”,所以写了一个简单的自动化脚本:准备一组基线样本,包括原始注入Payload和改造后的变体,循环发送请求,每次记录返回状态码和WAF日志里的规则命中结果,最后汇总成一张表。
有了日志,判断一个请求是否被拦截就不需要靠猜。日志会明确写出命中的规则ID、请求方向、归一化后的数据,有些WAF甚至会输出判定理由。这一步对后面写报告至关重要——每个绕过的变体都能对应到具体规则盲区,而不是笼统地说“WAF可绕过”。
5.3 三类技法的实测数据与选择建议
我把三天里的实测结果按三条思路汇总了一下:
| 绕过思路 | 在默认规则集下的表现 | 可靠性 |
|---|---|---|
| SQL语义等价变形 | 效果最好,覆盖面广 | 中高,规则升级后需重新验证 |
| 编码层差异 | 只在特定后端框架上有效 | 中低,依赖解码链 |
| 协议与参数处理差异 | 能绕过部分请求,但极不稳定 | 低,版本升级即失效 |
| 垃圾数据填充 | 偶尔有效,极易触发告警 | 低,不推荐线上使用 |
实测也给了我一个反直觉的经验:很多人以为绕过最依赖“多背几个Payload模板”,其实真正拉开差距的是对“WAF解析结果与后端解析结果不一致”这个差异点的理解。一个冷门的编码差异,可能比一百个变形的union select都值钱。
6. 反推防守:每一类绕过都对应一条加固项
6.1 别把WAF当救命稻草
这个项目做完,我在测试报告第一页就写了一句很直接的话:WAF绕过能被验证出来,最根本的原因不是WAF规则写得差,而是应用程序自己把不可信输入直接拼进了SQL语句。WAF本质是一道外围防线,它可以拦住大多数批量漏洞扫描,但面对一个清楚自身技术栈的攻击者,天然存在“解析差异”这个结构性问题。
所以防守方首先要转一个弯:不要问“WAF能不能挡住所有攻击”,而要问“即便WAF被绕过了,我们应用的SQL还有没有注入的可能”。答案如果是否定的,那WAF有没有被绕都无所谓;答案如果是肯定的,那不管WAF升级多少次,风险都不会真正消失。
6.2 应用层加固的优先级排序
基于这次绕过的经验,我给那个业务系统按优先级排了整改项:
第一,所有SQL改成参数化查询或ORM绑定。这是唯一能从根上消除SQL注入的做法。老实说,如果一个模块已经彻底参数化,我在做测试时根本不会走到WAF绕过这一步,因为注入点根本不存在。
第二,输入校验做白名单。像report_id这种参数,如果业务上只接受正整数,直接在入口处用严格正则校验,非数字直接拒绝。白名单比黑名单可靠得多,因为白名单天然不受“新攻击变体”影响。
第三,数据库账号做最小权限。我在测试中能拿到全库数据,很大程度是因为应用连库的账号权限过高。如果应用只读自己需要的那几张表,即使注入点存在,影响面也会被大幅压缩。
这三条做完,WAF的升级压力其实就小了很多,它的任务只剩拦住学生式扫描器。
6.3 WAF规则升级与回归测试的常态化
最后是WAF侧的改进。我给对方的建议不是简单地说“升级规则”,而是建立一套回归测试机制。具体做法是:把我测试中用到的所有绕过样本、对应的URL、参数、预期结果保存成脚本库;每次WAF规则有更新,运维团队把这个脚本库跑一遍,对比哪些被拦了、哪些被放行了。
这个机制的价值在于让“攻防变化”变得可见。安全领域的攻防是动态的,今天有效的手法和防御,明天可能都会失效。如果没有一套可回归的样本库,你根本不知道规则更新到底是变好了还是变差了;有了它,规则变更是否引入新的绕过窗口,五分钟就能得出结论。
写到这里想补一句这次项目的真实体会:绕过一次WAF并不难,真正难的是把绕过经验变成有效的安全建设输入。测试报告如果只写“存在WAF绕过风险”五个字,负责修复的人多半无从下手;但如果你把每一步请求、日志命中详情、绕过后的实际影响、每一类绕过对应的加固项都写清楚,这个漏洞就变成了一次很好的加固契机。
最后分享一个我一直保留的工作习惯:每次做完这类测试,我都会把新验证成功的绕过样本存成脚本,放进本地回归集里。下一次跑同类项目,或者产品规则升级后需要复测时,五分钟就能知道WAF有没有退步。这套习惯比任何单一工具都更能建立安全感。