判断 WAF 测试结果,只看 200 或 403 很容易走错方向:200 可能是自定义拦截页,403 也可能由应用产生。真正要比较的是同一份请求字节,在边缘检查点和业务代码中分别变成了什么对象。
这类分析需要沿请求路径建立证据,而不是累积编码技巧。部署路径、检查范围、解析方式与最终业务语义,是四个需要分别回答的问题。
先画解释链,再猜规则
flowchart TD A["原始请求字节"] --> B["入口路由与消息边界"] B --> C["检查点看到的内容"] C --> D["转发与后端解析"] D --> E["业务读取的值"] E --> F["可观察的业务效果"]
这是一张分析模型,不是所有产品的固定流水线。信誉、限速、挑战、异常评分与内容规则的顺序由部署决定。记录原始字节、边缘日志、转发结果和业务读取值,才能定位差异发生在哪一层。
同一证书、favicon 或旧 DNS 地址只能提供部署线索。共享模板会复用图标,证书可覆盖多个入口,SPF 描述的是邮件发送关系;它们都不是“已抵达同一源站”的充分证据。应对照源站配置、Host/SNI 路由与应用日志,同时检查仅记录或跳过检查的路由。
转发头先经过信任判断
X-Forwarded-For 和 X-Real-IP 首先是请求内容。可信含义来自前一跳的身份、字段清理规则与后端代理配置,而非字段名字。
| 位置 | 应保存的信息 |
|---|---|
| 连接 | TCP 对端与实际入口 |
| 入站边缘 | 客户端提供的原始转发字段 |
| 出站边缘 | 被覆盖、删除或追加后的字段 |
| 框架 | 客户端地址 API 的最终取值 |
| 决策 | 命中的信任范围、豁免规则与规则 ID |
Express 的 trust proxy 必须匹配实际代理拓扑。信任所有转发值时,最后一个可信代理应清理或覆盖客户端提供的相关字段;按跳数信任时,还要考虑不同长度的入口路径。大型共享网络或整个 ASN 的出口归属,也不等于应用身份认证。
大小限制与超限动作是两件事
总请求大小、可检查的前缀、解压后大小、字段长度与上传大小可能受不同配置约束。不要把字符数当作 UTF-8 字节数,也不要把 WAF 检查上限当作后端接收上限。
AWS WAF 对超限组件提供 Continue、Match 与 No match。Continue 检查限制内内容;Match/No match 决定当前语句是否命中。Match 不自动等于阻断,最终结果还取决于规则动作与其他规则。
- 1
固定基线
固定路由、媒体类型、压缩状态和总字节数,使用没有执行语义的唯一标记。
- 2
只移动一个字段
在相同总长度下调整标记位置,记录检查点是否识别、后端是否完整读取。
- 3
检查超限处置
区分整体拒绝、前缀检查、仅记录与后端截断。若后端也拒绝输入,就尚未形成可达的业务影响链。
先证明语义等价,再比较检测
大小写、空白和注释各有语法范围。UNION/**/SELECT 可以在相应 SQL 方言中分隔两个 token;UN/**/ION 并不会因此普遍成为一个关键字。HTML 标签名也不是先删除任意注释再拼接。
JavaScript 标识符区分大小写,字符串拼接只产生字符串。Unicode 转义、八进制表示和不换行空格,都应按语言位置、严格模式与字符集解释;单独的 %a0 不是一个通用 UTF-8 空白表示。
对于浏览器效果,要继续核对输入所在上下文、模板转义、DOM API、CSP、Trusted Types 和事件到达条件。JSFuck 等表达式的外观复杂度既不证明执行,也不证明无害。输入被回显,与进入解释代码或标记的 sink,是不同证据。
重复参数与多次解码要精确到 API
以下只是一个普通查询示例,没有附带服务器响应或测试结论:
GET /review?q=first&q=second HTTP/1.1
Host: example.com
X-Review-Case: duplicate-query| 解析对象 | 单值访问 | 列表访问 |
|---|---|---|
| Django 5.2 QueryDict | 最后一个值 | getlist() 保留完整列表 |
| Werkzeug MultiDict | 第一个值 | getlist() 保留完整列表 |
| 业务自行合并 | 取决于具体代码 | 不假设自动连接 |
框架语言不是足够精确的标签。查询参数、表单字段、JSON 重复键和 multipart 同名 part 应分别测试;业务没有连接列表时,就没有凭空出现的连接步骤。
下面同时验证 URL 解码次数、重复项取值模型与 cp037 字符集往返。它不代表已运行 Django、Werkzeug 或任何商业 WAF:
from urllib.parse import (
unquote, parse_qsl, quote_from_bytes, unquote_to_bytes
)
raw = "%252f"
once, twice = unquote(raw), unquote(unquote(raw))
assert (once, twice) == ("%2f", "/")
pairs = parse_qsl("q=first&q=second", keep_blank_values=True)
values = [v for k, v in pairs if k == "q"]
assert (values[0], values[-1], ",".join(values)) == (
"first", "second", "first,second"
)
marker = "review_marker"
encoded = quote_from_bytes(marker.encode("cp037"), safe="")
assert unquote_to_bytes(encoded).decode("cp037") == marker
print("decode=%2f -> /; first=first; last=second; cp037=roundtrip")charset=ibm037 只是声明,后端仍可能拒绝或忽略它。%252f 只有在相应层实际再次解码时才成为斜杠;若值一直作为普通文本使用,表示差异也未必有影响。
Cookie 中的引号、反斜杠与历史 $Version 字段同样依赖具体解析器。记录逐层键值对及后续解码,不把“服务器接收了字段”当作“浏览器执行了内容”。
multipart 与 chunked 是不同层
先恢复 HTTP 消息内容,再解释 multipart 定界。嵌套 part 是否继续解析由应用决定,多个 part 也不会天然拼成一个业务值。
boundary 的引号还有一个细节:加引号并不能让任意字符成为合法边界。RFC 2046 允许冒号,因此 boundary="Review:Boundary" 是合适的引号处理测试;分号不属于该边界字符集合,应归入畸形输入与错误处理测试,而不是合法语法等价变换。
下面生成普通表单并按 17 字节切片编码为 HTTP/1.1 chunked,再独立解码比对:
boundary = b"Review:Boundary"
body = (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="q"\r\n\r\n'
b"review_marker\r\n--" + boundary + b"--\r\n")
parts = [body[i:i + 17] for i in range(0, len(body), 17)]
wire = b"".join(
f"{len(p):X}\r\n".encode("ascii") + p + b"\r\n" for p in parts
) + b"0\r\n\r\n"
def decode_fixture(data):
pos, result = 0, bytearray()
while True:
end = data.index(b"\r\n", pos)
size = int(data[pos:end], 16)
pos = end + 2
if size == 0:
assert data[pos:] == b"\r\n"
return bytes(result)
result += data[pos:pos + size]
pos += size
assert data[pos:pos + 2] == b"\r\n"
pos += 2
assert decode_fixture(wire) == body
assert body.endswith(b"--" + boundary + b"--\r\n")
print(f"body={len(body)}; chunks={len(parts)}; roundtrip=OK")这里的解码器只服务于该无扩展、无 trailer 的受控样本,不是通用 HTTP 解析器。长度使用字节数的十六进制;multipart 结束边界仍在 body 中,随后才是零长度块与终止空行。
HTTP/2 使用 DATA 帧承载内容,不使用 HTTP/1.1 chunked。协议转换场景应分别检查前后两跳的表示;Transfer-Encoding 不应原样带入 HTTP/2,TE: trailers 则是另一个字段及其特定例外。
用最小反例闭合分析链
- 1
保留同一基线
记录版本、入口、路由、原始字节摘要、规则 ID 和应用读取值。
- 2
一次改变一个变量
先验证语法与长度,再比较边缘及后端解析对象;不要一开始就叠加多种编码。
- 3
缩小差异
删除无关字段与变换,直到最小输入仍产生同样分歧。
- 4
验证业务效果
区分回显、数据变化、事件到达和实际执行,不单独依靠状态码。
- 5
加入反例和修复回归
去掉关键变化后结果应恢复;修补后同时验证原用例和正常请求。
本文的离线模型只验证表示转换与消息长度,没有发送网络请求,也没有宣称这些候选条件对当前产品普遍有效。修复应落在不一致的那一层:收紧源站入口、正确配置代理信任、明确超限处置、统一解析策略,以及在业务 sink 使用对应上下文的处理方式。再添加一个关键词,只覆盖了表面字符串。