传统 WAF 测试通常把一批固定请求依次发送出去,再统计有多少被拦截。问题在于,真实攻击者不会停在第一种写法上:一次请求被拒绝后,他们会调整编码、内容类型、参数位置或语法形式继续尝试。
这次测试采用了不同的方法:让测试器根据上一条请求是被拦截还是放行,动态选择下一种变体,并在经过授权的预发布环境中覆盖六类攻击。结果表明,自适应循环能够触及固定测试集容易遗漏的路径,也暴露出需要修复的检测缺口。
固定 payload 列表为什么不够
一条请求从客户端到业务代码,往往会经历多层解析:CDN、反向代理、WAF、Web 框架、路由器以及业务参数解析器。各层对同一输入的理解不一定一致。
例如,下面几种差异都可能改变检测结果:
- URL 编码是在 WAF 之前还是之后解码;
- JSON、表单和查询参数是否使用同一套检查规则;
- 参数重复出现时,WAF 与应用分别取第一个还是最后一个值;
- 大小写、空白符和转义字符是否被统一规范化;
- WAF 检查的是原始请求体,还是解析后的字段值;
- 请求到达源站后,是否真的进入了数据库、模板、命令或网络请求等敏感调用点。
固定测试通常只能回答“这批字符串有没有被规则命中”。反馈驱动的测试则会继续追问:规则为什么命中、换一种等价表示后是否仍然命中,以及被放行的请求到底停在了哪一层。
需要特别强调:HTTP 200 只表示 WAF 没有阻断请求,不代表攻击已经成功。可靠的结论必须结合源站日志、测试探针或安全桩,确认输入是否到达敏感 sink。
一个可控的自适应测试循环
完整循环可以拆成五步:
- 从一个经过审核的种子样本开始;
- 将请求发送到限定的预发布目标;
- 根据状态码、WAF 响应头和源站遥测判断结果;
- 选择下一种允许的变体,例如更换内容类型或进行一次标准编码;
- 把被放行的样本保存为回归测试,但不自动扩大目标范围。
来源摘要提到测试覆盖了六类攻击,但没有列出具体类别。实践中可以从以下六类开始,具体选择应以应用的技术栈和威胁模型为准:
- SQL 注入;
- 跨站脚本;
- 路径遍历;
- 命令注入;
- SSRF;
- 服务端模板注入。
如果引入前沿模型,模型最适合充当“候选变体规划器”,而不是直接控制网络执行器。执行层仍应强制校验目标域名、请求数量、并发上限、HTTP 方法和允许使用的变换集合。
可复制的最小测试器
下面的 Python 示例使用无害测试标记演示反馈循环。运行前需要准备一个专用的 /waf-test 接口:它只能记录输入,不能执行命令、渲染 HTML、读取文件、计算模板表达式或发起网络请求。
脚本默认拒绝运行,必须显式设置目标地址和授权确认。它只把 403、406、429 或 X-WAF-Action: block 视为明确阻断;实际环境应根据 WAF 的行为调整判断逻辑。
python -m venv .venv
. .venv/bin/activate
pip install requests
export TARGET_URL='https://staging.example.internal/waf-test'
export WAF_TEST_ACK='authorized-staging-only'
python waf_feedback_test.py
将下面内容保存为 waf_feedback_test.py:
import json
import os
import time
from urllib.parse import quote
import requests
TARGET_URL = os.environ.get('TARGET_URL', '')
ACK = os.environ.get('WAF_TEST_ACK', '')
if ACK != 'authorized-staging-only':
raise SystemExit('Refusing to run: set WAF_TEST_ACK for an authorized staging target')
if not TARGET_URL.startswith('https://'):
raise SystemExit('TARGET_URL must be an HTTPS staging endpoint')
# 这些是测试标记。测试接口不得解释、执行、渲染或访问其中引用的资源。
PROBES = {
'sqli': "' OR '1'='1 -- WAF_CANARY",
'xss': '<script>console.log(\'WAF_CANARY\')</script>',
'path_traversal': '../../waf-canary.txt',
'command_injection': '; echo WAF_CANARY',
'ssrf': 'https://waf-canary.invalid/check',
'template_injection': '{{ 7 * 7 }} WAF_CANARY',
}
BLOCK_CODES = {403, 406, 429}
TIMEOUT_SECONDS = 5
MAX_REQUESTS = 18
def send_probe(category, value, profile):
headers = {
'User-Agent': 'authorized-waf-regression-test/1.0',
'X-Security-Test': 'waf-feedback-loop',
}
if profile == 'json':
response = requests.post(
TARGET_URL,
json={'category': category, 'value': value},
headers=headers,
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
elif profile == 'form':
response = requests.post(
TARGET_URL,
data={'category': category, 'value': value},
headers=headers,
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
elif profile == 'json_percent_encoded':
response = requests.post(
TARGET_URL,
json={'category': category, 'value': quote(value, safe='')},
headers=headers,
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
else:
raise ValueError(f'Unknown profile: {profile}')
action = response.headers.get('X-WAF-Action', '').lower()
blocked = response.status_code in BLOCK_CODES or action == 'block'
return {
'category': category,
'profile': profile,
'status': response.status_code,
'blocked': blocked,
'waf_action': action or None,
}
def main():
request_count = 0
results = []
for category, seed in PROBES.items():
if request_count >= MAX_REQUESTS:
break
first = send_probe(category, seed, 'json')
request_count += 1
results.append(first)
print(json.dumps(first, ensure_ascii=False))
# 根据反馈选择下一条请求,而不是无条件跑完所有组合。
# 被拦截时检查规范化一致性;被放行时切换解析路径以确认缺口范围。
next_profile = 'json_percent_encoded' if first['blocked'] else 'form'
if request_count < MAX_REQUESTS:
second = send_probe(category, seed, next_profile)
request_count += 1
results.append(second)
print(json.dumps(second, ensure_ascii=False))
time.sleep(0.25)
with open('waf-results.json', 'w', encoding='utf-8') as output:
json.dump(results, output, ensure_ascii=False, indent=2)
passed = [item for item in results if not item['blocked']]
print(f'Completed {len(results)} requests; {len(passed)} were not explicitly blocked')
print('Review origin telemetry before classifying any result as exploitable.')
if __name__ == '__main__':
main()
这个脚本刻意限制了能力:没有自动发现域名、没有抓取链接、没有提高并发,也没有把响应正文交给模型。实际接入 CI 时,还应在网络层只允许访问预发布 IP,并给测试身份配置单独的速率限制。
不要只记录“通过”或“拦截”
一个有用的结果记录至少应包含以下维度:
| 字段 | 用途 |
|---|---|
| category | 攻击类别或测试意图 |
| mutation | 使用了哪种受控变换 |
| WAF decision | WAF 明确阻断、放行还是结果未知 |
| origin reached | 请求是否到达源站 |
| parser reached | 应用是否成功解析目标字段 |
| sink reached | 输入是否到达敏感调用点 |
| request hash | 在不保存敏感正文时关联重复样本 |
| rule version | 判断修复是否因规则升级而变化 |
这能避免两种常见误判。一种是把限流产生的 429 当成针对 payload 的安全拦截;另一种是把普通 200 当成漏洞利用成功。只有 WAF 决策、源站轨迹和 sink 遥测彼此对应时,结果才足以支持修复决策。
从检测缺口变成回归资产
摘要没有披露哪些具体变体成功通过,因此不应凭空推断某一种绕过方式。更值得复用的是处理缺口的方法:
- 统一规范化顺序:WAF 与应用应尽可能基于相同的解码和解析结果做判断,避免双重解码或解析顺序差异。
- 覆盖不同内容类型:同一个字段通过 JSON、表单和查询参数提交时,应执行一致的安全策略。
- 保留应用层校验:WAF 是补充控制,不能代替参数化查询、输出编码、严格 URL allowlist 和安全模板配置。
- 把样本加入 CI:保存最小化后的请求结构、预期 WAF 决策和源站预期行为,在规则或网关升级后重新运行。
- 分阶段发布规则:先记录、再告警、最后阻断,观察误报后再扩大覆盖范围。
使用模型生成候选变体时,还要防范额外风险:响应内容可能包含提示注入文本;日志可能含有令牌或个人数据;模型也可能不断扩展测试范围。较稳妥的设计是只向模型提供脱敏后的标签和结构化结果,并要求模型输出受 JSON Schema 约束的变换计划,再由确定性的执行器做最终校验。
上线前检查清单
- 测试目标是否由域名和 IP 双重 allowlist 锁定;
- 是否取得书面授权并设置明确的时间窗口;
- 测试端点是否不会执行、渲染或转发输入;
- 是否限制总请求数、并发数、重试次数和 HTTP 方法;
- 是否能区分 WAF 阻断、网关错误、限流与源站异常;
- 是否记录源站到达情况和敏感 sink 遥测;
- 是否对响应正文、Cookie、令牌和个人数据做脱敏;
- 每个确认的缺口是否都已转成可重复的回归测试。
自适应测试的价值不在于生成更多 payload,而在于围绕每一次反馈提出更准确的下一个问题。把模型限制在规划层,把权限、范围和执行牢牢留在确定性控制中,才能在提高覆盖率的同时避免把安全测试器变成新的风险源。