1. 从一个真实的绕过案例说起
去年帮一个朋友看他们刚上线的业务,日志里出现了一批很奇怪的请求。URL 里全是%25%32%37这种双重编码的字符,乍一看像是乱码,但把两层解码剥掉之后,赫然是一个完整的注入语句。他们的 WAF 规则明明配了拦截,为什么没触发?原因很简单:WAF 在检测时只做了一次 URL 解码,而后端应用做了两次。这个时间差,就是编码绕过最经典的利用点。
Payload 编码绕过,说白了就是攻击者把恶意内容用各种编码方式“化妆”,让 WAF 的规则匹配不到,但后端在真正处理请求时又会把它还原成可执行的内容。这中间涉及一个核心矛盾:WAF 看到的字符串和后端应用最终解析出来的字符串,往往不是同一个东西。谁对“最终形态”的理解更准确,谁就赢。
这篇文章我想把这件事讲透。不管你是刚接触 WAF 运维的新手,还是已经配了几百条规则的老手,我都会从编码绕过的底层逻辑讲起,把常见手法、检测思路、规则写法、排查技巧一层层拆开。看完你至少能做到两件事:第一,知道攻击者会用哪些编码花招;第二,知道怎么让自己的 WAF 规则堵住这些花招,而不是被一个%25就骗过去。
2. 编码绕过的本质:解析差异
2.1 为什么同一个请求会有两种“真相”
要理解编码绕过,先得接受一个事实:一个 HTTP 请求从客户端发出到后端业务逻辑处理,中间要经过很多层解析。每一层都可能对字符串做一次解码或转换。WAF 站在某一层看,后端站在另一层看,两边看到的“真相”就可能不一样。
举个最朴素的例子。攻击者想传union select,但 WAF 规则里写了union\s+select就拦。于是他传union%20select。如果 WAF 在检测前会做一次 URL 解码,那它看到的是union select,能拦;如果 WAF 不做解码,它看到的是union%20select,正则匹配不上,就放过去了。而后端框架通常会自动做 URL 解码,最终拿到的还是union select,照样执行。
这就是解析差异。WAF 的解码层数和后端的解码层数不一致,或者解码的时机、范围不一致,绕过就产生了。我常跟人说,配 WAF 规则本质上是在做一件事:让 WAF 的解析结果尽可能逼近后端的解析结果。逼近得越准,绕过空间越小。
2.2 编码绕过的三大类手法
实际遇到的编码绕过,基本可以归到三类里。
第一类是多层编码。就是刚才说的双重、三重 URL 编码。%27是单引号,%2527是%27再编码一次,后端解两次就还原成单引号。层数越多,WAF 越容易漏。
第二类是异构编码。同一个字符有多种表示方式。比如单引号可以是%27(URL 编码)、%u0027(某些场景下的 Unicode 编码)、'(HTML 实体)、\x27(十六进制转义)。WAF 如果只认%27,其他形式就漏了。
第三类是编码与语法结合。这类最阴险,因为它不只是编码问题,还利用了后端语言的语法特性。比如在某些 SQL 方言里,/**/可以当空格用,union/**/select和union select等价,但 WAF 如果只匹配空格就拦不住。再比如用+代替空格、用大小写混写UnIoN、用注释截断union#select等等。
注意:这三类手法经常组合使用。一个成熟的攻击 payload 可能是“双重 URL 编码 + 注释代替空格 + 大小写混淆”的叠加体。所以 WAF 规则不能只防单一手法,要考虑组合场景。
2.3 一个关键认知:解码不是越多越好
新手容易走极端,觉得“那我让 WAF 无限次解码不就行了”。这个思路有问题。无限解码会带来两个麻烦:一是性能开销,每个请求都反复解码,QPS 高了扛不住;二是误报,正常业务里也可能出现%25这种字符,你解到底可能把正常请求当成攻击。
正确的做法是对齐后端的解码行为。后端解几次,WAF 就解几次,不多不少。这就要求你去摸清楚自己业务框架的解码逻辑。比如某些框架对 query string 解一次,对 body 解一次,对 path 又用另一套规则。这些细节决定了你的 WAF 该怎么配。
3. 常见编码手法逐个拆解
3.1 URL 编码与它的多层变体
URL 编码是最基础的。规则很简单:一个字节用%加两位十六进制表示。'是%27,空格是%20,<是%3C。
多层编码就是在已经编码的字符串上再编码一次。%27里的%本身编码成%25,于是%27变成%2527。再编一次变成%252527。后端如果解两次,%2527就还原成%27,再解一次才是'。
这里有个容易踩的坑:不是所有后端都会解多层。有些框架只解一次,那你传%2527它拿到的就是%27这个字面字符串,不会变成单引号。所以攻击者要先探测目标解几次,再决定编几层。作为防守方,你要做的是确认自己后端解几次,然后让 WAF 解同样的次数。
我一般建议在 WAF 里对 URL 参数做最多两次解码,然后对解码后的结果做规则匹配。为什么是两次?因为绝大多数框架最多解两次,解三次的情况极少,而且解太多次误报率会上升。
3.2 Unicode 编码的坑
Unicode 编码有好几种写法,%u0027是其中一种(注意是%u不是%)。这种编码在 IIS 某些版本上会被自动解析,在别的服务器上可能不会。所以它是一把双刃剑:用对了能绕过,用错了后端不认,payload 就废了。
对 WAF 来说,要不要处理%uXXXX取决于你的后端环境。如果后端是 IIS 或者某些会解析%u的框架,那 WAF 必须把%u0027也解码成'再匹配。如果后端根本不认这种编码,那 WAF 处理它反而可能引入误报。
我的经验是:先确认后端认不认,再决定 WAF 处不处理。不要盲目把所有编码形式都加进解码流程,那样只会让规则越来越臃肿。
3.3 HTML 实体编码在 XSS 场景的威力
HTML 实体编码主要出现在 XSS 场景。<可以写成<,>是>,"是",'是'或'。
关键在于:HTML 实体是在浏览器渲染时才解码的,服务器端往往不解码。所以如果 WAF 只检查服务器端看到的原始字符串,而攻击 payload 是<script>,WAF 看到的是<script>,匹配不到<script>,就放过了。但这个内容如果被原样输出到页面,浏览器会把它解码成<script>并执行。
这就是存储型 XSS 里编码绕过的典型路径。防守思路是:WAF 在检测 XSS 时,除了看原始字符串,还要对 HTML 实体做一次解码后再匹配。同时,输出编码(output encoding)才是防 XSS 的根本,WAF 只是补充。
3.4 十六进制与八进制转义
\x27是十六进制转义,\047是八进制转义。这些主要出现在后端语言层面,比如 PHP 的字符串里\x27就是单引号。攻击者如果在参数里传\x27,某些场景下后端会把它当转义字符处理。
这类编码的检测比较麻烦,因为\x开头的字符串在正常业务里也可能出现(比如某些 ID 生成规则)。我的做法是:对\x和\0开头的转义序列做解码后再匹配,但同时给这类规则加一个白名单,排除已知的正常业务模式。
3.5 编码与语法混淆的组合拳
单独一种编码往往不够,真正难防的是组合。举几个我见过的真实组合:
union/**/select:用注释代替空格UnIoN SeLeCt:大小写混淆union%0aselect:用换行符代替空格union+select:用加号代替空格/*!union*/ select:MySQL 特有的可执行注释
这些手法的共同点是:它们改变了 payload 的“字面形态”,但没有改变它的“语义”。WAF 如果只做字面匹配,就会被绕过。要防住,规则必须做“归一化”——把注释去掉、把大小写统一、把各种空白符统一成空格,然后再匹配。
4. WAF 侧的解码与归一化设计
4.1 解码流程该怎么设计
一个合理的 WAF 解码流程,我通常建议按这个顺序来:
- 先做 URL 解码,最多两次
- 再做 Unicode 解码(如果后端支持)
- 再做 HTML 实体解码(针对 XSS 检测)
- 最后做语法归一化(去注释、统一空白、统一大小写)
这个顺序不能乱。为什么先 URL 后 Unicode?因为 URL 编码是最外层的,攻击者通常先做 URL 编码,再做其他编码。如果你先解 Unicode,可能把%u0027里的%当成 URL 编码的一部分,解出错误结果。
每一步解码后都要做一次规则匹配,而不是全部解完再匹配。这样能覆盖“只编一层”和“编多层”两种情况。代价是性能开销增加,所以实际部署时要根据业务 QPS 做取舍。
4.2 归一化:把“化妆”的 payload 打回原形
归一化的核心思想是:不管攻击者怎么改写 payload 的字面形式,只要语义相同,归一化后就应该得到相同的结果。
具体做几件事:
- 去掉 SQL 注释:
/**/、--、#等 - 统一空白符:把
%0a、%0d、%09、+都统一成空格 - 统一大小写:把 payload 转成小写再匹配
- 去掉多余括号和引号:某些场景下
((select))和select等价
归一化之后再做正则匹配,命中率会高很多。但归一化也有风险:过度归一化可能把正常请求也“归一”成攻击特征,导致误报。所以归一化规则要精细,不能一刀切。
4.3 规则写法的几个实战技巧
写 WAF 规则时,我有几个习惯。
第一,用“语义正则”而不是“字面正则”。比如匹配 SQL 注入,不要写union\s+select,而是写union[\s/**/+]+select,把常见的分隔符都覆盖进去。
第二,对关键字符做“存在性检测”而不是“精确匹配”。比如检测单引号,不要只匹配',而是匹配['"%27%2527\x27]这种集合。当然这要配合解码流程,否则会误报。
第三,给规则加“上下文约束”。单独一个select可能是正常业务词,但select后面跟着from再跟着where,就高度可疑。用上下文约束能大幅降低误报。
第四,定期用“绕过测试集”回归。我维护了一个包含几百个编码变体的测试 payload 集合,每次改规则都跑一遍,看有没有漏网的。这个习惯帮我堵住了不少盲区。
5. 实操:搭建一套能扛编码绕过的检测流程
5.1 环境与工具准备
要验证自己的 WAF 能不能扛编码绕过,你需要一个可控的测试环境。我一般这么搭:
- 一台测试服务器,部署一个存在已知漏洞的靶场应用(比如某个老版本的 CMS)
- 一个 WAF 实例,可以是开源的也可以是商业的,配置好规则
- 一个 payload 生成脚本,能自动生成各种编码变体
- 一个日志分析工具,用来对比 WAF 拦截记录和后端实际执行记录
payload 生成脚本是关键。我写过一个 Python 小工具,输入一个基础 payload,它能自动生成 URL 编码、双重编码、Unicode 编码、HTML 实体编码、大小写混淆、注释插入等几十种变体。用这个工具批量打靶场,就能快速发现 WAF 的盲区。
import urllib.parse def generate_variants(payload): variants = [] # 原始 variants.append(payload) # URL 编码 variants.append(urllib.parse.quote(payload)) # 双重 URL 编码 variants.append(urllib.parse.quote(urllib.parse.quote(payload))) # 大小写混淆 variants.append(''.join(c.upper() if i % 2 else c.lower() for i, c in enumerate(payload))) # 空格替换 variants.append(payload.replace(' ', '/**/')) variants.append(payload.replace(' ', '+')) variants.append(payload.replace(' ', '%0a')) return variants base = "union select 1,2,3" for v in generate_variants(base): print(v)这个脚本很简单,但覆盖了最常见的几类变体。实际用的时候可以再扩展,加入 Unicode 编码、HTML 实体等。
5.2 关键参数的计算与选择
WAF 解码层数设几次,这个参数不是拍脑袋定的。我的计算方法是:
- 先看后端框架的解码逻辑。查文档或者直接抓包看后端收到的原始字符串。
- 统计正常业务请求里
%字符的出现频率。如果频率很低,说明多层解码的误报风险小,可以多解一层。 - 压测 WAF 在不同解码层数下的性能。每多解一层,CPU 开销大概增加 5% 到 15%,具体看实现。
综合下来,我一般设两次 URL 解码。这个值在“覆盖大多数攻击”和“控制误报与性能”之间比较平衡。如果你的业务对安全要求极高,可以设三次,但要接受更高的误报率和性能开销。
5.3 完整检测流程的落地
把上面的东西串起来,一个完整的检测流程是这样的:
- 请求进入 WAF
- 提取 URL、Header、Body 中的参数
- 对每个参数做第一层 URL 解码,匹配规则
- 对第一层解码结果做第二层 URL 解码,匹配规则
- 对第二层解码结果做 Unicode 解码,匹配规则
- 对结果做 HTML 实体解码,匹配 XSS 规则
- 对结果做语法归一化,匹配 SQL 注入规则
- 任何一步命中,就拦截并记录
这个流程听起来繁琐,但实际实现时可以用“管道”模式,把各个解码器和匹配器串起来,代码结构很清晰。性能上,大部分请求在第一层或第二层就被放行了,只有可疑请求才会走完全流程。
提示:记录日志时一定要记录“原始 payload”和“解码后 payload”两个版本。排查问题时,这两个版本能帮你快速定位是解码环节出了问题,还是规则本身有问题。
6. 常见问题与排查技巧实录
6.1 为什么规则明明配了还是被绕过
这是被问得最多的问题。我总结了几种常见原因,做成速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 规则匹配不到 | WAF 解码层数少于后端 | 抓包对比 WAF 和后端看到的字符串 |
| 规则匹配不到 | 攻击用了异构编码 | 检查 payload 里有没有%u、&#、\x |
| 规则匹配不到 | 攻击用了语法混淆 | 检查有没有注释、大小写、空白符替换 |
| 规则误报 | 归一化过度 | 检查归一化规则是否把正常字符也改了 |
| 规则误报 | 上下文约束太松 | 给规则加前后文限定 |
| 性能下降 | 解码层数太多 | 压测不同层数下的 QPS |
排查时我有个习惯:先把 WAF 的检测日志和后端的访问日志对齐时间戳,找到同一个请求,对比两边看到的 payload。这个对比能直接暴露解析差异在哪里。
6.2 误报和漏报的平衡怎么找
误报和漏报是一对矛盾。解码层数多、归一化激进,漏报少但误报多;反过来则误报少但漏报多。
我的做法是分级处理。把规则分成三档:
- 高危规则:命中直接拦截,比如明确的 SQL 注入特征
- 中危规则:命中记录并告警,但不拦截,比如可疑的编码模式
- 低危规则:只记录,用于后续分析
这样既能拦住大部分攻击,又不会因为误报影响正常业务。中危和低危规则的日志定期分析,如果发现某条规则长期只产生误报,就调整或下线;如果发现某条规则漏掉了真实攻击,就升级为高危。
6.3 几个我踩过的坑
第一个坑:以为所有后端都解两次 URL 编码。有次给一个业务配 WAF,按两次解码配的,结果那个业务的后端只解一次。攻击者传%2527,WAF 解两次得到'拦截了,但后端只解一次得到%27,根本不执行。这导致大量正常请求里带%25的被误拦。后来改成按后端实际行为配,问题才解决。
第二个坑:忽略了 Header 里的编码。很多人只检查 URL 和 Body,忘了 Cookie、Referer、User-Agent 这些 Header 也可能藏 payload。有次攻击就是把注入语句放在 Cookie 里,用双重编码绕过,因为 WAF 没检查 Cookie 的解码。
第三个坑:归一化把正常业务词改坏了。有次归一化规则里把--当注释去掉,结果业务里有个参数值正好是A--B这种格式,被改成了AB,导致规则误判。后来给归一化加了白名单,排除已知的正常模式。
第四个坑:性能没做压测。上线前没压测,结果高峰期 WAF 的 CPU 打满,请求延迟飙升。后来把解码层数从三次降到两次,又加了缓存,才稳住。这件事让我养成了“任何规则改动都要压测”的习惯。
6.4 持续对抗的思路
编码绕过不是一次配好就完事的。攻击手法在进化,新的编码方式、新的语法特性会不断出现。所以 WAF 规则需要持续维护。
我的做法是:
- 每周看一次 WAF 拦截日志,找有没有“疑似绕过”的请求(比如解码后出现敏感词但没被拦的)
- 每月更新一次 payload 测试集,加入新发现的变体
- 每季度做一次完整的绕过测试,用自动化工具批量打
- 关注安全社区的新披露,看到新的绕过手法就第一时间验证自己的 WAF 能不能防
这套流程坚持下来,WAF 的防护效果会越来越稳。反过来,如果配完就不管了,半年后基本就是个摆设。
7. 一些个人体会
做 WAF 这几年,我最大的感受是:编码绕过本质上是“解析权”的争夺。谁对请求的最终形态理解得更准确,谁就掌握主动。WAF 要做的不是“比攻击者聪明”,而是“比后端更懂后端”。你得清楚后端每一步怎么解析、怎么解码、怎么执行,然后让 WAF 的检测逻辑跟它对齐。
另一个体会是,没有万能的规则,只有持续的对抗。任何一条规则都有被绕过的可能,关键是建立一套发现绕过、分析绕过、修补规则的闭环。工具和规则是死的,人是活的。
最后分享一个小技巧:如果你不确定某个 payload 会不会绕过你的 WAF,最直接的办法是把它同时发给 WAF 和后端,对比两边的处理结果。WAF 放过了但后端执行了,就是绕过;WAF 拦了但后端本来也不会执行,就是误报。这个对比测试做多了,你对编码绕过的直觉会越来越准。