我决定要做一个waf项目来了解一下waf的原理,以后可以用来给我自己的网站做防护

37957 字
190 分钟
我决定要做一个waf项目来了解一下waf的原理,以后可以用来给我自己的网站做防护

waf的定义#

一种专门用于保护Web应用程序的安全防护设备或软件。它通过监控、过滤和阻断HTTP/HTTPS流量,来防御针对Web应用的攻击。

核心特征:

  • 工作层次:主要工作在应用层(OSI第7层),分析HTTP/HTTPS请求和响应内容
  • 防护对象:Web应用本身,而非单纯的网络层或主机层
  • 防护原理:基于规则库、特征匹配、行为分析、机器学习等方式识别恶意流量
  • 部署形式:可以是硬件设备、软件、云服务(云WAF)

常见防护的攻击类型:

  • SQL注入
  • 跨站脚本攻击(XSS)
  • 跨站请求伪造(CSRF)
  • 文件包含漏洞
  • 命令注入
  • 恶意爬虫、CC攻击等

与传统防火墙的区别:

传统防火墙主要基于IP、端口等网络层信息进行访问控制,而WAF深入到HTTP协议内容层面,能识别伪装成正常请求的应用层攻击。


我的waf要防护什么#

注入类 —— 13 条#

  • 路径穿越:

    • 明文多级回溯(../..\)+ 编码痕迹(%2e%252e)。
    • 编码痕迹:%2e(.的URL编码)、%252e(双重编码,%25是%的编码)。
    • 原理:服务器若未过滤../,攻击者可读取任意文件,如 ../../../../etc/passwd。
  • 敏感文件访问:

    • /etc/passwd:Linux用户账户文件
    • /proc/self/:进程自身信息(可提权、读环境变量)
    • /var/log/:日志文件(可能含凭据)
    • .ssh/、.aws/:SSH密钥、AWS凭证目录
  • 危险协议包装器:

    • php://:读源码、输入流
    • data://:内联数据执行
    • gopher://:构造任意TCP请求(SSRF打内网利器)
    • jar://:Java相关
    • file://:本地文件读取(单独降级处理,因为正常业务也可能用到,误报率高)
  • SQL 注入:

    • UNION查询:UNION SELECT … 拼接窃取数据
    • 恒真条件:or 1=1、’ or ‘1’=‘1 绕过登录
    • 危险函数:sleep(时间盲注)、updatexml(报错注入)
    • 元信息探测:information_schema(查表结构)
    • 引号闭合特征:’、” 用于闭合原SQL语句
  • 命令注入:

    • 元字符串联:;cat、|、&& 等拼接额外命令
    • 命令替换:$(…)、反引号 ` 执行子命令
    • 例:127.0.0.1; cat /etc/passwd
  • SSRF:

    • 内网地址探测:请求 127.0.0.1、192.168.x.x、10.x.x.x
    • 云元数据:169.254.169.254(AWS/GCP/Azure元数据服务,可窃取云凭证)(169.254.169.254 是云厂商约定的元数据服务地址,云主机内部可直接访问,能获取实例信息,尤其是IAM临时凭据。SSRF攻击者常利用它窃取云凭证,进而接管云账户,是WAF重点拦截目标。)

XSS / HTML 注入 —— 10 条#

  • 危险标签(iframesvgobject…)及其携带外链的形态(iframe、svg、object 等,可加载外部内容或执行脚本;携带外链形态更危险(数据外带))
  • DOM clobbering 特征(id=ajaxurl 这类劫持目标)(利用 id=ajaxurl 等命名,覆盖页面已有全局变量/函数,劫持逻辑。属于DOM层攻击,不依赖脚本标签)
  • 标签带属性、弹窗与危险函数(alert(“eval()(alert(、eval( 等典型XSS验证/执行函数)
  • 尖括号后接分隔符(< area)—— 这是解析器分歧类漏洞的指纹,CVE-2026-64638 的入口
    • 这是解析器分歧类漏洞的指纹。
    • 不同解析器(浏览器 vs WAF vs 后端)对 < area 这种畸形标签的解析不一致,攻击者可利用分歧绕过检测。
  • 模板/表达式注入(${}#{}<%=)
    • ${}、#{}、<%= —— 服务端模板引擎(如Freemarker、Velocity、JSP)的表达式,若用户输入被拼进模板,可导致RCE

HTTP 协议层 —— 5 条#

  • CL 与 TE 并存(同时有 Content-Length 和 Transfer-Encoding,前后端解析不一致)
  • 重复 Content-Length(两个不同值的CL)
  • 畸形 Transfer-Encoding(三条请求走私)(如 Transfer-Encoding: xchunked,绕过某些代理的检测)
  • TRACE 方法(XST 攻击)(TRACE会原样回显请求,配合XSS可窃取Cookie(Cross-Site Tracing))
  • 扫描器 UA 指纹(sqlmap、nikto、nmap 等工具的User-Agent特征,直接拦截)

行为维度#

单 IP 滑动窗口限流(默认 10 秒 120 次),补的是”单个请求完全合法、连起来是爆破”那一类

  • 默认10秒120次。
  • 目的:补”单个请求完全合法、连起来是爆破”这一类。
  • 例如:登录接口,每个请求都正常,但1秒发100次就是暴力破解/CC攻击。
  • 滑动窗口:不是固定整点计数,而是”最近10秒内”滚动统计,更精确。

先看结果#

第一组:拿自己的 PoC 打自己的 WAF(看攻击打不打得通)#

这里用到的是CVE--2021-41773哪个漏洞复现 先直连靶场(不经过 WAF),漏洞是通的:

[+] 命中!第 4 次尝试成功
跳板目录 : icons
穿越层数 : 4
编码风格 : mixed
最终路径 : /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd
[+] 任意文件读取已确认。以下是 /etc/passwd 的前 8 行:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

把 WAF 挡在中间,同一个脚本再打一次:

目标: 127.0.0.1:8888
[*] 开始探测路径穿越(穷举 起始目录 x 穿越层数 x 编码风格)...
[-] 403 mixed -> /icons/.%2e/%2e%2e/%2e%2e/etc/passwd
[-] 403 dotted -> /icons/.%2e/.%2e/.%2e/etc/passwd
[-] 403 plain -> /icons/%2e%2e/%2e%2e/%2e%2e/etc/passwd
...
[-] 未探测到可利用的穿越组合。

21 种组合全部 403,一个都没打通。

第二组:后端侧的证据(拦在哪一层)#

光看 PoC 报错还不够 —— 万一是 WAF 把请求转发过去了, 只是响应被改了呢?所以要看后端自己的日志:

靶场 access log 行数:攻击前 125 -> 攻击后 125(新增 0 行)

新增 0 行。 攻击请求在 WAF 那一层就被掐断了,靶场连一个字节都没收到。

第三组:WAF 自己记下了什么(凭什么拦)#

终端上会实时打出:

用日志记录,后面会尝试用可视化视图,将waf做成通用工具使用

[拦截] GET 403 /icons/.%2e/%2e%2e/%2e%2e/etc/passwd 205分 1001,1002,1010 0ms
1001 uri[path] '../'
1001 path[path] '../'
1002 uri[raw] '.%2e'
1002 path[raw] '.%2e'
1010 uri[path] '/etc/passwd'
1010 path[path] '/etc/passwd'

每一行都在说:“这条请求的哪个片段、在哪个解码视图上、 命中了哪条规则、匹配到的是哪段文本”。


整体架构#

攻击者 exploit.py 浏览器 / curl
| |
+----------------+-------------------+
|
v
+--------------------------------------------------+
| WAF · 反向代理 127.0.0.1:8888 |
| |
| ① HTTP 解析层 socket 字节流 -> RawRequest |
| ② 归一化层 原始文本 -> 多个解码视图 |
| ③ 规则引擎 匹配 + 异常评分累加 |
| ③' 速率限制 单 IP 请求频率(行为维度) |
| ④ 决策层 总分 >= 阈值 ? 拦 : 放 |
| ⑤ 审计日志 每请求一行 JSONL |
+--------------------------------------------------+
|
放行的请求
v
Docker 靶场 httpd:2.4.49 / WordPress

对应的文件分工:

  • waf/httpmsg.py —— ① 报文解析层
  • waf/normalize.py —— ② 归一化层
  • waf/rules.py —— ③ 规则引擎
  • waf/ratelimit.py —— ③’ 速率限制
  • waf/engine.py —— ④ 决策层(同时负责主服务和反向转发)
  • waf/audit.py —— ⑤ 审计日志
  • rules/default.rules —— 规则集(30 条)
  • run.py —— 启动入口

启动#

第 1 步:看看规则都加载了什么#

Terminal window
python3 run.py --list-rules
  • run.py 是本项目的启动脚本
  • --list-rules 是它自己的一个参数,意思是”只打印加载到的规则,不启动服务”
  • 连起来:进到项目目录,让程序把规则表列出来给我看

预期输出(节选):

已加载 30 条规则(拦截阈值 100 分)
------------------------------------------------------------------------
1010 敏感文件访问 · 系统文件 path block 100 目标=path,uri,...
1020 SQL 注入 · UNION 查询 sql block 100 目标=query,...
2001 XSS · script 标签 html block 70 目标=query,...
4002 白名单 · 静态资源 raw allow 0 目标=path
------------------------------------------------------------------------

这一步的价值:确认规则真的被读进去了。 规则文件写错一行(比如少一个竖线)会导致整条规则无效。

第 2 步:跑自检(不需要起任何服务)#

Terminal window
python3 tools/selftest.py
  • tools/selftest.py 是自检脚本
  • 它在内存里构造 35 个请求(正常的 + 攻击的),直接喂给检测链,看判定对不对
  • 连起来:不开服务、不占端口,几秒钟检查一遍规则有没有把能力弄坏

预期结尾:

结果:全部 35 个用例通过

第 3 步:端到端冒烟测试#

Terminal window
python3 tools/e2e_test.py
  • 这个脚本会真的起两个进程:一个回显后端 + 一个 WAF
  • 然后用真实的 socket 发真实的 HTTP 请求
  • 测完自动把两个进程关掉
  • 连起来:走一遍完整链路,确认转发、拦截、日志都正常

预期结尾:

结果:全部 17 项通过

第 4 步:接上你自己的靶场#

这一步需要三个终端窗口。

终端 1 —— 起靶场:

Terminal window
cd /CVE-2021-41773
docker compose up -d

终端 2 —— 起 WAF:

Terminal window
cd /waf-lab
python3 run.py --backend 127.0.0.1:8080 --listen 127.0.0.1:8888

逐段拆解这条命令:

  • --backend 127.0.0.1:8080 —— 我的后端(真正干活的网站)在哪。 127.0.0.1 是本机,8080 是靶场映射的端口。 这是 WAF 唯一需要知道的关于后端的信息 —— 它不关心后端是什么程序、 什么版本、用什么语言写的。
  • --listen 127.0.0.1:8888 —— WAF 自己在哪个端口等请求
  • 连起来:启动一个 WAF,自己守在 8888 端口,把放行的请求转给 8080

终端 3 —— 打:

Terminal window
cd /CVE-2021-41773
python3 poc/exploit.py --target http://127.0.0.1:8888

把 --target 从平时的 8080 改成 8888,就是这一点差别 —— 攻击脚本本身一个字都没改。

然后你会看到终端 2 里刷出一串红色的 [拦截]。


拆解代码#

入口 run.py 203 行 解析命令行参数,组装五层,启动服务
│
├─ waf/__init__.py 20 行 包说明(只有文档字符串和版本号)
├─ waf/httpmsg.py 400 行 ① 报文解析层
├─ waf/normalize.py 536 行 ② 归一化层(11 个解码视图)
├─ waf/rules.py 425 行 ③ 规则引擎(匹配 + 异常评分)
├─ waf/ratelimit.py 161 行 ③' 速率限制(滑动窗口)
├─ waf/engine.py 755 行 ④ 决策层(主服务 + 反向转发)
└─ waf/audit.py 123 行 ⑤ 审计日志(JSONL)
数据 rules/default.rules 226 行 30 条规则,四个段位
工具 tools/selftest.py 275 行 内存自检(35 个用例)
tools/e2e_test.py 288 行 端到端测试(真起进程走 socket)
tools/echo_backend.py 148 行 纯 socket 回显后端
配置 .gitignore 排除字节码缓存和日志

run.py启动入口#

  1. 读命令行参数 —— --backend(后端在哪)、--listen(自己守哪个端口)、 --rules(用哪些规则文件)、--mode(拦截还是只记录)等
  2. 加载规则 —— 调 RuleSet.from_files() 把 rules/*.rules 读进内存。 如果参数里有 --list-rules,打印一张规则表就退出,不启动服务
  3. 组装各层 —— 建限流器 RateLimiter、审计日志 AuditLog, 全部塞进一个 Config 对象
  4. 启动服务 —— 调 serve(cfg),然后阻塞在那里等请求,直到 Ctrl + C

还有一件容易被忽略但很关键的事:它在启动时把 stdout / stderr 重定向成 UTF-8 + 行缓冲(stream.reconfigure(...))。不这么做的话,输出重定向到 文件时中文会变成 [鎷︽埅],日志还会因为块缓冲被整块丢掉。

最常用的三条命令:

Terminal window
python3 run.py --backend 127.0.0.1:8080 --listen 127.0.0.1:8888 # 挡在靶场前面
python3 run.py --list-rules # 只看看规则加载了什么
python3 run.py --backend 127.0.0.1:8080 --mode audit # 只记录不拦截

路径注入#

HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)

保证从任意目录运行 python3 /某处/run.py 都能 import waf。 下面那几行 # noqa: E402 就是在告诉 linter”我知道 import 不在文件顶部, 是故意的”。

parse_hostport(text, default_port=80)#

def parse_hostport(text, default_port=80):
text = text.strip()
if text.startswith("["): # [::1]:8888
host, _, rest = text.partition("]")
host = host[1:]
_, _, port = rest.rpartition(":")
return host or "127.0.0.1", int(port or default_port)
if ":" in text:
host, _, port = text.rpartition(":")
return host or "0.0.0.0", int(port)
return text, default_port

把 "127.0.0.1:8888" 解析成 ("127.0.0.1", 8888)。

  • 用 rpartition(":") 而不是 split(":"),是为了将来能容忍 IPv6 —— ::1:8888 这种写法里冒号不止一个,从左切会切错
  • 还处理了 [::1]:8888 这种带方括号的写法
  • 没写端口时用 default_port

build_parser()#

def build_parser():
p = argparse.ArgumentParser(
prog="run.py",
description="waf-lab —— 纯标准库实现的反向代理式 WAF",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
示例:
python3 run.py --backend 127.0.0.1:8080
python3 run.py --backend 127.0.0.1:8080 --listen 0.0.0.0:80 --mode audit
python3 run.py --list-rules
把 WAF 接入自己的站点时,只需要把域名解析或 Nginx 的 proxy_pass
指到 WAF 的监听端口,后端地址填真实站点的地址即可 —— 后端本身不用改任何代码。
""",
)
g = p.add_argument_group("网络")
g.add_argument("--listen", default="127.0.0.1:8888",
help="WAF 监听地址,默认 127.0.0.1:8888")
g.add_argument("--backend", default="127.0.0.1:8080",
help="后端(被保护的站点)地址,默认 127.0.0.1:8080")
g.add_argument("--timeout", type=float, default=15.0,
help="与后端通信的超时秒数,默认 15")
g = p.add_argument_group("检测")
g.add_argument("--rules", action="append", default=None,
help="规则文件或目录,可重复指定。默认读 rules/ 目录下的 default.rules")
g.add_argument("--threshold", type=int, default=100,
help="拦截阈值(累计风险分),默认 100")
g.add_argument("--mode", choices=("block", "audit"), default="block",
help="block=命中即拦,audit=只记录不拦(默认 block)")
g = p.add_argument_group("速率限制")
g.add_argument("--rate-limit", type=int, default=120,
help="单 IP 在窗口内允许的请求数,默认 120;设为 0 关闭")
g.add_argument("--rate-window", type=float, default=10.0,
help="速率窗口秒数,默认 10")
g.add_argument("--rate-score", type=int, default=3,
help="每超出一次加多少分,默认 3")
g = p.add_argument_group("输出")
g.add_argument("--log-dir", default=os.path.join(HERE, "logs"),
help="审计日志目录,默认 ./logs")
g.add_argument("--quiet", action="store_true",
help="不打印每个请求的处理结果(日志仍会写文件)")
g.add_argument("--quiet-headers", action="store_true",
help="阻断响应里不暴露规则编号 —— 生产环境建议开启")
g.add_argument("--no-color", action="store_true", help="关闭终端彩色输出")
g.add_argument("--list-rules", action="store_true",
help="只打印加载到的规则然后退出")
return p

用 argparse 定义全部命令行参数,按功能分成四个组:

  • 网络:--listen、--backend、--timeout
  • 检测:--rules(可重复指定)、--threshold、--mode
  • 速率限制:--rate-limit、--rate-window、--rate-score
  • 输出:--log-dir、--quiet、--quiet-headers、--no-color、--list-rules

main(argv=None)#

def main(argv=None):
args = build_parser().parse_args(argv)
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", line_buffering=True)
except Exception:
pass
if args.no_color or not sys.stdout.isatty():
C.disable()
# ---- 加载规则 ----
rule_paths = args.rules or [os.path.join(HERE, "rules")]
try:
ruleset = RuleSet.from_files(rule_paths, threshold=args.threshold)
except RuleError as exc:
print("%s[!] 规则加载失败%s:%s" % (C.RED, C.RESET, exc))
return 2
if args.list_rules:
print(ruleset.summary())
return 0
if not ruleset.detect_rules:
print("%s[!]%s 没有任何检测规则,WAF 会把所有流量原样放行。" % (C.YELLOW, C.RESET))
print(" 检查一下 --rules 指向的路径是否正确。")
# ---- 组装各层 ----
if args.rate_limit and args.rate_limit > 0:
limiter = RateLimiter(
window=args.rate_window,
limit=args.rate_limit,
score_per_excess=args.rate_score,
)
else:
# 传一个上限极大、永不触发的限流器,等于关闭
limiter = RateLimiter(window=1.0, limit=10 ** 9, score_per_excess=0)
audit_log = AuditLog(args.log_dir, echo=False)
backend_host, backend_port = parse_hostport(args.backend, 80)
listen_host, listen_port = parse_hostport(args.listen, 8888)
cfg = Config(
listen_host=listen_host,
listen_port=listen_port,
backend_host=backend_host,
backend_port=backend_port,
ruleset=ruleset,
limiter=limiter,
audit_log=audit_log,
mode=args.mode,
timeout=args.timeout,
verbose=not args.quiet,
expose_rules=not args.quiet_headers,
)
try:
serve(cfg)
except OSError as exc:
print("%s[!] 无法监听 %s:%d —— %s" % (
C.RED, C.RESET, listen_host, listen_port, exc))
print(" 端口被占用的话,换一个:--listen 127.0.0.1:8889")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

流程:

  1. 重定向输出编码。这段注释详细记录了两个真实踩过的坑:
    • 必须显式指定 encoding="utf-8":Python 在 stdout 不是终端时 会退回使用 locale 编码。实测踩过一次 —— 输出重定向到文件后, 中文全变成了 [鎷︽埅](因为写出去的 UTF-8 字节被按别的编码解释了)
    • 必须行缓冲:默认块缓冲要攒够 4~8KB 才写一次,进程被 kill -9 时 缓冲区整块丢失。
  2. --no-color 或输出不是终端时,调 C.disable()
  3. 加载规则。默认读 rules/ 目录。RuleError 时打印 错误并 return 2
  4. --list-rules 就直接打印规则表退出
  5. 如果一条检测规则都没有,警告(否则 WAF 会把所有 流量原样放行,而你可能以为是规则在生效)
  6. 组装限流器。--rate-limit 0 时传一个”上限极大、 永不触发”的限流器,等于关闭 —— 比到处写 if limiter 判断干净
  7. 建审计日志、解析地址、组装 Config
  8. 调 serve();OSError(端口被占)时给出换端口的提示

waf/__init__.py — 包#

一个说明文档,包含设计原则

"""
waf — 一个用纯 Python 标准库实现的反向代理式 Web 应用防火墙。
模块分工(对应五层处理链):
httpmsg.py ① 报文解析层 socket 字节流 -> 结构化的 RawRequest
normalize.py ② 归一化层 原始文本 -> 多个「解码视图」
rules.py ③ 规则引擎 规则匹配 + 异常评分累加
ratelimit.py ③' 行为检测 速率限制(非正则类规则)
engine.py ④ 决策层 串联全链、放行/阻断、反向转发
audit.py ⑤ 审计日志 每请求一行 JSONL
设计原则:
· 零第三方依赖,只用标准库(便于在任意环境跑起来)
· 不做任何"自作聪明"的规范化 —— 保留原始字节,因为
「WAF 看到的」和「后端看到的」之间的差异,正是绕过的藏身处
· 通用型:不假设后端是什么应用,只看 HTTP 语义
"""
__version__ = "0.1.0"

waf/httpmsg.py分析报文#

这一层解决的核心问题:WAF 看到的东西,和后端看到的东西,可能不是同一个东西。

四个解析上限常量#

  • MAX_LINE = 16 * 1024 —— 单行(请求行或单个头部)最大字节数
  • MAX_HEADER_COUNT = 200 —— 头部条数上限
  • MAX_HEADER_BYTES = 128 * 1024 —— 所有头部的总字节数上限
  • MAX_BODY = 16 * 1024 * 1024 —— 请求体上限(16MB)

这些数字不是随便定的。不设限的话,一个恶意的超长头部或超大 body 就能把 WAF 自己的内存吃光 —— 攻击者不用打后端,先把 WAF 打挂就行了。所以 “限制报文大小”本身就是 WAF 的第一条防护,而不是可选项。

SocketReader 类 —— 带缓冲的 socket 读取器#

为什么需要它:socket.recv(n) 的语义是”最多给我 n 字节”,可能多、 可能少、可能刚好。但 HTTP 解析需要的是精确语义 ——“给我一整行”、 “给我恰好 N 个字节”。所以要自己维护一个缓冲区,把多读的字节攒起来给 下一次用。(这也是为什么不能把 socket.makefile() 和 recv() 混用 —— 两边的缓冲各自为政,会把流里的字节撕成两半。)

五个方法:

  • __init__():初始化 self.buf = b""(缓冲区)和 self.eof = False(对端是否已关闭)
def __init__(self, sock):
self.sock = sock
self.buf = b""
self.eof = False
  • _fill():从 socket 再读一批数据(65536 字节)进缓冲区。 返回 False 表示对端关闭。所有读操作都会在”缓冲不够”时调它
def _fill(self):
if self.eof:
return False
chunk = self.sock.recv(65536)
if not chunk:
self.eof = True
return False
self.buf += chunk
return True
  • read_line():读一行,返回去掉结尾 CRLF 的内容(bytes)。 循环里先在缓冲区 find(b"\n");找不到就继续 _fill()。 返回 None 是一个有意义的信号:表示”还没读到任何东西,连接就结束了”, 用来区分”客户端正常关闭”和”读到半个请求”。另外它在缓冲区超过 limit 时抛 ProtocolError,挡住超长行攻击
def read_line(self, limit=MAX_LINE):
while True:
idx = self.buf.find(b"\n")
if idx >= 0:
line = self.buf[:idx]
self.buf = self.buf[idx + 1:]
if line.endswith(b"\r"):
line = line[:-1]
return line
# 没找到换行符,说明这一行还没读全
if len(self.buf) > limit:
raise ProtocolError("单行超长(超过 %d 字节)" % limit)
if not self._fill():
if self.buf:
line = self.buf
self.buf = b""
return line
return None
  • read_exactly():读恰好 n 个字节。连接提前关闭会抛异常 —— 因为一个声明了 Content-Length: 1000 却只发了 500 字节的请求, 本身就是攻击者试探解析器行为的手法
def read_exactly(self, n):
while len(self.buf) < n:
if not self._fill():
raise ProtocolError("连接提前关闭,请求体不完整")
data = self.buf[:n]
self.buf = self.buf[n:]
return data
  • read_chunked():解析 chunked 传输编码,返回拼好的 body。 chunked 的线上格式是”十六进制长度行 + 数据 + CRLF”循环,以长度 0 结束。

    chunked 的线上格式(每个 chunk 三部分):
    1a\\r\\n <- 十六进制的长度
    <26 字节的数据>\\r\\n <- 数据本身
    0\\r\\n <- 长度 0 = 结束
    \\r\\n <- 尾部空行

    为什么 WAF 必须自己解:chunked 允许把一个 body 拆成任意多段发送。 如果 WAF 只检查”第一个 chunk”,攻击者把载荷拆到第二个 chunk 里就能绕过。 这是一个真实存在过的 WAF 绕过手法。 实现上有两个细节:chunk 行可能是 1a;ext=value 这种带扩展参数的写法, 要取分号前面的部分才是长度;结束标记后面可能还有 trailer 头, 要一直读到空行为止

def read_chunked(self, max_total=MAX_BODY):
out = bytearray()
while True:
line = self.read_line()
if line is None:
raise ProtocolError("chunked 请求体意外结束")
size_part = line.split(b";", 1)[0].strip()
if not size_part:
continue # 容忍空行
try:
size = int(size_part, 16)
except ValueError:
raise ProtocolError("非法的 chunk 长度: %r" % line)
if size == 0:
# 结束标记。后面可能还有 trailer 头,一直读到空行为止
while True:
trailer = self.read_line()
if trailer is None or trailer == b"":
break
break
if len(out) + size > max_total:
raise ProtocolError("chunked 请求体超过上限")
out += self.read_exactly(size)
# 数据后面必须跟一个 CRLF(read_line 会返回空串)
ending = self.read_line()
if ending != b"":
raise ProtocolError("chunk 数据后缺少 CRLF")
return bytes(out)

ProtocolError类 — 自定义异常表示”报文不符合 HTTP 语法,或者触碰了本模块设定的上限”#

调用方(engine.py 的 handle())捕获它之后会回一个 400 并记日志 —— 报文不合法通常意味着攻击者在试探解析器差异,值得留痕。 handle() 在调用 read_request(),read_request() 的调用链里某处 raise ProtocolError,然后这个异常沿着调用栈往上冒,回到 handle() 被接住。

while True:
try:
req = read_request(reader, self.client_address) # ← 调用发生在这里
except ProtocolError as exc: # ← 捕获发生在这里
self._log_bad_request(cfg, str(exc))
send_simple(self.request, 400, "Bad Request",
"报文不符合 HTTP 语法:%s" % exc)
return

handle() 调 read_request(reader, …) —— 这是两者唯一发生关系的地方 read_request() 读到 Host: x,继续读下一行,拿到 b’BrokenHeaderWithoutColon’ if b”:” not in hline: 成立 → raise ProtocolError(“非法请求头(缺少冒号): …”) 这个 raise 让 read_request() 当场中断 —— 它后面剩下的代码一行都不会执行 Python 把异常对象交给”上一层的调用者”,也就是 read_request() 里那些没被 try 包住的调用点 read_request() 自己没有任何 except ProtocolError,所以异常继续往上冒 冒到 handle() 那句的位置,正好在 try 里、且有匹配的 except ProtocolError → 接住 → 记日志 + 回 400 + return 关连接

class ProtocolError(Exception):
"""报文不符合 HTTP 语法,或者触碰了本模块设定的上限。"""
pass

RawRequest 类 —— 一个请求的”原始快照”#

三个刻意保存成”原始状态”的字段(__init__):

def __init__(self, method, target, version, header_list, body,
client, body_encoding="identity", flags=None):
self.method = method # 如 "GET"(原始大小写)
self.target = target # 如 "/icons/.%2e/%2e%2e/etc/passwd"
self.version = version # 如 "HTTP/1.1"
self.header_list = header_list # [(原始键, 原始值), ...]
self.body = body # bytes
self.client = client # (ip, port) 元组
self.body_encoding = body_encoding # "identity" 或 "chunked"
self.flags = flags or [] # 协议层可疑迹象标记
  • target —— 请求行里那个路径,含 %2e、//、./ 等一切原样字符
  • header_list —— 列表而不是字典,因为 HTTP 允许同名头出现多次, 而且顺序有意义(比如两个 Set-Cookie)
  • body —— 原始 bytes,不预先解码

属性方法:

  • header(name):取某个头的值,出现多次时只取第一个, 找不到返回 None。比较时统一转小写,但存的仍是原样
def header(self, name):
low = name.lower()
for k, v in self.header_list:
if k.lower() == low:
return v
return None
  • headers(name):取某个头的全部值(比如可能有多个 Cookie 或 X-Forwarded-For)
def headers(self, name):
low = name.lower()
return [v for k, v in self.header_list if k.lower() == low]
  • path 属性:原始路径部分(问号之前),未解码。 注意它用的是 find("?") 而不是 urlsplit —— 绝不能让标准库替我们解码
@property#把一个方法变成属性来访问
def path(self):
"""原始路径部分(问号之前),未解码。"""
q = self.target.find("?")
return self.target if q < 0 else self.target[:q]
  • query 属性:原始查询串(问号之后),未解码。没有查询串时返回空字符串。
@property
def query(self):
q = self.target.find("?")
return "" if q < 0 else self.target[q + 1:]
  • host 属性:Host 头的值,没有则返回空串
@property
def host(self):
return self.header("host") or ""
  • content_type 属性:去掉 ;charset=utf-8 之类的参数, 只留媒体类型本身,并转小写
@property
def content_type(self):
ct = self.header("content-type") or ""
return ct.split(";", 1)[0].strip().lower()
  • __repr__():调试用的一行摘要
def __repr__(self):
return "<RawRequest %s %s from %s:%s>" % (
self.method, self.target, self.client[0], self.client[1])

read_request(reader, client) —— 解析主流程#

def read_request(reader, client):
"""从字节流读出一个完整请求。
返回值:
RawRequest —— 解析成功
None —— 连接被对端正常关闭(没有更多请求了)
抛异常:
ProtocolError —— 报文非法或超限,调用方应回 400
"""
# ---- 1. 请求行 ----
line = reader.read_line()
if line is None:
return None
# RFC 允许请求前有若干空行,用来自律跳过(否则会误判成畸形报文)
guard = 0
while line == b"":
line = reader.read_line()
if line is None:
return None
guard += 1
if guard > 8:
raise ProtocolError("请求行前空行过多")
# 请求行格式:METHOD SP request-target SP HTTP-version
# 用 split(b" ") 而不是 split(),因为后者会把连续空白折叠 ——
# 而"路径里带空格"是一个值得观察的异常现象,不该被悄悄消化掉
parts = line.split(b" ")
if len(parts) != 3:
raise ProtocolError("非法请求行: %r" % line[:200])
method = parts[0].decode("latin-1")
target = parts[1].decode("latin-1")
version = parts[2].decode("latin-1")
if not version.upper().startswith("HTTP/"):
raise ProtocolError("非法协议版本: %r" % version)
# ---- 2. 请求头 ----
header_list = []
total_bytes = 0
while True:
hline = reader.read_line()
if hline is None:
raise ProtocolError("请求头未结束,连接就断了")
if hline == b"":
break # 空行 = 头部结束
total_bytes += len(hline)
if total_bytes > MAX_HEADER_BYTES:
raise ProtocolError("请求头总大小超限")
# obs-fold:以空格/制表符开头的行是上一行的续行。
# RFC 已废弃这种写法,但解析器对它的处理差异正是走私漏洞的来源之一
if hline[:1] in (b" ", b"\t") and header_list:
k, v = header_list[-1]
header_list[-1] = (k, v + " " + hline.decode("latin-1").strip())
continue
if b":" not in hline:
raise ProtocolError("非法请求头(缺少冒号): %r" % hline[:200])
k, _, v = hline.partition(b":")
header_list.append((k.decode("latin-1"),
v.decode("latin-1").strip()))
if len(header_list) > MAX_HEADER_COUNT:
raise ProtocolError("请求头条数超限")
# ---- 3. 请求体 ----
# 这里就是协议层攻击的观察窗口。
# RFC 7230 规定:如果同时出现 Transfer-Encoding 和 Content-Length,
# 必须忽略 Content-Length。但现实中的中间件(代理 / WAF / 后端框架)
# 对"以谁为准"的处理并不一致 —— 这个分歧就是 HTTP 请求走私(smuggling)
# 的成因:让 WAF 按 CL 切分、让后端按 TE 切分,同一段字节被两种方式解读。
#
# 我们不去猜谁对,只需要**把这个分歧记下来**,交给规则去决定要不要拦。
flags = []
cl_raw = _hdr_first(header_list, "content-length")
te_raw = _hdr_first(header_list, "transfer-encoding")
body = b""
body_encoding = "identity"
if te_raw is not None:
body_encoding = te_raw.strip().lower()
if cl_raw is not None:
flags.append("cl_te_conflict")
if "chunked" in body_encoding:
if body_encoding.replace(" ", "") not in ("chunked", "chunked,identity", "identity,chunked"):
flags.append("odd_transfer_encoding")
body = reader.read_chunked()
# TE 存在但不含 chunked:没有可信的长度信息,按无 body 处理
elif cl_raw is not None:
# 出现多个不同的 Content-Length 同样是走私信号
all_cl = set(v.strip() for v in _hdr_all(header_list, "content-length"))
if len(all_cl) > 1:
flags.append("cl_conflict")
try:
length = int(cl_raw.strip())
except ValueError:
raise ProtocolError("非法 Content-Length: %r" % cl_raw)
if length < 0:
raise ProtocolError("负数 Content-Length: %d" % length)
if length > MAX_BODY:
raise ProtocolError("请求体超过上限(%d > %d)" % (length, MAX_BODY))
body = reader.read_exactly(length)
return RawRequest(method, target, version, header_list, body,
client, body_encoding, flags)

返回值有两种含义:RawRequest 表示解析成功,None 表示连接被对端正常关闭。 分三段:

  • 请求行。先跳过前置空行(RFC 允许,最多 8 行,防止 有人用空行洪水撑爆)。然后用 line.split(b" ") 而不是 split() —— 后者会把连续空白折叠,而”路径里带空格”本身就是一个值得观察的异常现象, 不该被悄悄消化掉。最后校验协议版本必须以 HTTP/ 开头
line = reader.read_line()
if line is None:
return None
# RFC 允许请求前有若干空行,用来自律跳过(否则会误判成畸形报文)
guard = 0
while line == b"":
line = reader.read_line()
if line is None:
return None
guard += 1
if guard > 8:
raise ProtocolError("请求行前空行过多")
# 请求行格式:METHOD SP request-target SP HTTP-version
# 用 split(b" ") 而不是 split(),因为后者会把连续空白折叠 ——
# 而"路径里带空格"是一个值得观察的异常现象,不该被悄悄消化掉
parts = line.split(b" ")
if len(parts) != 3:
raise ProtocolError("非法请求行: %r" % line[:200])
method = parts[0].decode("latin-1")
target = parts[1].decode("latin-1")
version = parts[2].decode("latin-1")
if not version.upper().startswith("HTTP/"):
raise ProtocolError("非法协议版本: %r" % version)
  • 请求头。逐行读,累计总字节数和条数,任一超限就抛异常。 这里处理了一个容易被忽略的细节:obs-fold,即以空格或 制表符开头的行是上一行的续行。RFC 已废弃这种写法,但解析器对它的处理差异 正是走私漏洞的来源之一
header_list = []
total_bytes = 0
while True:
hline = reader.read_line()
if hline is None:
raise ProtocolError("请求头未结束,连接就断了")
if hline == b"":
break # 空行 = 头部结束
total_bytes += len(hline)
if total_bytes > MAX_HEADER_BYTES:
raise ProtocolError("请求头总大小超限")
# obs-fold:以空格/制表符开头的行是上一行的续行。
# RFC 已废弃这种写法,但解析器对它的处理差异正是走私漏洞的来源之一
if hline[:1] in (b" ", b"\t") and header_list:
k, v = header_list[-1]
header_list[-1] = (k, v + " " + hline.decode("latin-1").strip())
continue
if b":" not in hline:
raise ProtocolError("非法请求头(缺少冒号): %r" % hline[:200])
k, _, v = hline.partition(b":")
header_list.append((k.decode("latin-1"),
v.decode("latin-1").strip()))
if len(header_list) > MAX_HEADER_COUNT:
raise ProtocolError("请求头条数超限")
  • 请求体。这是协议层攻击的观察窗口

RFC 7230 规定:如果同时出现 Transfer-Encoding 和 Content-Length,必须忽略 Content-Length。但现实中的中间件(代理 / WAF / 后端框架)对”以谁为准”的处理并不一致 —— 这个分歧就是 HTTP 请求走私(smuggling)的成因:让 WAF 按 CL 切分、让后端按 TE 切分,同一段字节被两种方式解读。我们不去猜谁对,只需要把这个分歧记下来,交给规则去决定要不要拦。解析层负责”看到”,规则层负责”判断”。协议层的三个 flag 后来被直接当成可匹配的片段

flags = []
cl_raw = _hdr_first(header_list, "content-length")
te_raw = _hdr_first(header_list, "transfer-encoding")
body = b""
body_encoding = "identity"
if te_raw is not None:
body_encoding = te_raw.strip().lower()
if cl_raw is not None:
flags.append("cl_te_conflict")
if "chunked" in body_encoding:
if body_encoding.replace(" ", "") not in ("chunked", "chunked,identity", "identity,chunked"):
flags.append("odd_transfer_encoding")
body = reader.read_chunked()
# TE 存在但不含 chunked:没有可信的长度信息,按无 body 处理
elif cl_raw is not None:
# 出现多个不同的 Content-Length 同样是走私信号
all_cl = set(v.strip() for v in _hdr_all(header_list, "content-length"))
if len(all_cl) > 1:
flags.append("cl_conflict")
try:
length = int(cl_raw.strip())
except ValueError:
raise ProtocolError("非法 Content-Length: %r" % cl_raw)
if length < 0:
raise ProtocolError("负数 Content-Length: %d" % length)
if length > MAX_BODY:
raise ProtocolError("请求体超过上限(%d > %d)" % (length, MAX_BODY))
body = reader.read_exactly(length)
return RawRequest(method, target, version, header_list, body,
client, body_encoding, flags)

_hdr_first() 和 _hdr_all()#

def _hdr_first(header_list, name):
low = name.lower()
for k, v in header_list:
if k.lower() == low:
return v
return None
def _hdr_all(header_list, name):
low = name.lower()
return [v for k, v in header_list if k.lower() == low]

两个模块级的头部查找辅助函数。之所以不写成 RawRequest 的方法, 是因为 read_request() 在构造 RawRequest 之前就要查头部, 那时对象还不存在。小事,但体现了”函数放哪一层要用数据依赖决定”。

describe(req)#

def describe(req):
"""把请求渲染成人能读的文本,用于终端观察和排错。"""
lines = []
lines.append("%s %s %s" % (req.method, req.target, req.version))
for k, v in req.header_list:
if k.lower() == "cookie":
v = v[:80] + ("..." if len(v) > 80 else "")
lines.append(" %s: %s" % (k, v))
if req.body:
preview = req.body[:200]
lines.append(" [body %d bytes, encoding=%s]" % (len(req.body), req.body_encoding))
lines.append(" " + repr(preview))
if req.flags:
lines.append(" [协议层标记: %s]" % ", ".join(req.flags))
return "\n".join(lines)

把请求渲染成人能读的文本,用于终端观察和排错。它对 Cookie 头做了截断,超过 80 字符就省略,避免一屏被一个长 Cookie 刷满。


Tips

一、为什么用 latin-1 解码而不是 UTF-8

HTTP 头按 RFC 是 ASCII/latin-1 的字节流。如果按 UTF-8 解,遇到非 UTF-8 字节会抛异常,或用 'replace' 变成一个 ”�“(U+FFFD)。两种结果都 丢失原始字节 —— 攻击者只要塞一个非法 UTF-8 字节,就能让你的 WAF 看不到真实内容,而后端(比如 PHP)照样能处理。

latin-1 的好处是每个字节都对应唯一字符,s.encode("latin-1") 永远能还原出 原始字节,是一一对应的双向映射。真需要按 UTF-8 解释时,在归一化层再解一次即可。

二、解析层负责”看到”,不负责”判断”

read_request() 从头到尾没有一句”这是不是攻击”。它只做两件事:把字节 读成结构,把可疑迹象记进 flags。这条边界一旦越过(比如在解析层 “顺手”过滤掉某些字符),后面所有层的可解释性就全没了。#

waf/normalize.py — 归一化层#

这一层负责把各种编码还原成明文,然后交给规则。本模块的输出是一串 Payload 对象,每个 = 一段待检测文本 + 它的所有解码视图。

攻击者从来不会老老实实地把危险字符写明文。一个 “/etc/passwd” 的路径穿越, 可以写成至少这几种样子:

../../../etc/passwd
..%2f..%2f..%2fetc/passwd
.%2e/%2e%2e/%2e%2e/etc/passwd <- 你的 CVE-2021-41773 PoC 用的就是这行
%252e%252e%252fetc/passwd <- 双重编码
..%5c..%5cetc/passwd <- Windows 后端认反斜杠

它们在后端眼里是同一个东西,在 WAF 眼里却是五个不同的字符串。 如果 WAF 只拿规则去匹配原文,那只要换一种编码就能过。

解决思路就是归一化:把「所有等价写法」都还原成同一种标准形式, 再拿去和规则比对。上面五种写法只要全部还原成 ”../../../etc/passwd”, 一条规则就能全抓住。

先使用正则归一化#

_HEX_U = re.compile(r"\\u([0-9a-fA-F]{4})")
_HEX_X = re.compile(r"\\x([0-9a-fA-F]{2})")
_SQL_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.S)
_SQL_LINE_COMMENT = re.compile(r"(--|#)[^\n]*")
_MULTI_WS = re.compile(r"\s+")
_MULTI_SLASH = re.compile(r"/{2,}")
  • _HEX_U 匹配 \uXXXX 形式的 Unicode 转义,\\ 在正则里表示一个字面反斜杠 \(因为 \ 本身是转义符,要写两次),u就是字母 u,([0-9a-fA-F]{4})捕获4 位十六进制,正好是一个 Unicode 码点。注意 r”…” 前缀:原始字符串,里面的 \u 就真的是 \ + \ + u,不是 Python 层面的转义。
  • _HEX_X 匹配 \xXX 形式的字节转义,和上面一样,只是捕获2 位十六进制,这两条通常用来把字面上的 \u4e2d 还原成真正的字符 中,或者反过来处理。
  • _SQL_BLOCK_COMMENT 匹配 SQL 块注释 /* ... */,/\* 匹配开头 /*(* 要转义),.*? 匹配中间任意内容,\*/ 匹配结尾 */,re.S(即 re.DOTALL):让.也能匹配换行符,所以跨行的块注释也能匹配。
  • _SQL_LINE_COMMENT 匹配 SQL 行注释,(--|#) 捕获开头:要么 --,要么 #(MySQL 风格),[^\n]* 匹配除换行外的所有字符,直到行尾,用 [^\n] 而不是 .*,是因为行注释到行尾就结束了,不需要 re.S,语义也更清晰。
  • _MULTI_WS 匹配连续空白,\s 是任意空白(空格、制表符、换行……),+ 表示一个或多个。
  • _MULTI_SLASH 匹配连续两个及以上斜杠,/{2,} 表示斜杠出现 2 次或更多,用途通常是 _MULTI_SLASH.sub("/", text),把 //、/// 这种压成一个 /。常见于处理拼接出来的文件路径或 URL。

view_url(s)百分号解码一次,按 latin-1 解释#

return urllib.parse.unquote(s, encoding="latin-1", errors="replace")

为什么用 latin-1 而不是默认的 utf-8: %c0%ae 按 latin-1 解出来是 “À®“(两个字符,一个字节一个字符), 按 utf-8 解会因为”过度长编码”而变成替换字符 “\ufffd”,字节信息全丢。 我们要的是字节保真,语义解释交给别的视图去做。

view_url2(s)百分号解码两次#

return view_url(view_url(s))

这是最常用的一层。因为 %252e 解一次变 %2e、解两次才变 ”.”,而现实中”双重编码绕过”是最经典的 WAF 规避手法之一。

view_url3(s)解码三次#

return view_url(view_url2(s))

层数越多误报越高,但用来展示”解码层数=检测边界”很直观。

view_form(s)表单语境下的解码:先把 + 当成空格,再做一次百分号解码#

return view_url(s.replace("+", " "))

为什么单独一个视图: 在 application/x-www-form-urlencoded 里,”+” 的意思就是空格 (这是 HTML 表单遗留的历史约定)。一个 SQL 注入载荷 “1+union+select” 在表单体里传到后端时,后端看到的是 “1 union select”。 只做百分号解码的视图看不出这个,所以表单场景必须要这层。

view_utf8(s)百分号解码一次,按 UTF-8 解释#

return urllib.parse.unquote(s, encoding="utf-8", errors="replace")

view_uni(s)解码 JS/JSON 风格的转义:\u002e -> . ,\x3c -> <#

s = view_form(view_form(s))
def _u(m):
try:
return chr(int(m.group(1), 16))
except (ValueError, OverflowError):
return m.group(0)
def _x(m):
try:
return chr(int(m.group(1), 16))
except (ValueError, OverflowError):
return m.group(0)
return _HEX_X.sub(_x, _HEX_U.sub(_u, s))

攻击者往 JSON body 里塞载荷时经常用这招绕过基于明文的正则。 起点用 form 双解码,和 html / sql 视图保持一致 ——这样”编码 + 转义”两层混淆叠在一起(%5Cu003c 之类)也能还原。

view_lower(s)全小写#

return s.lower()

抓 “UnIoN sElEcT” 这种靠大小写绕过朴素正则的写法。

view_html(s)HTML 实体解码:&lt; -> < ,&#x3c; -> < ,&amp; -> &#

return html.unescape(view_form(view_form(s)))

这是 XSS 检测的必需视图。浏览器会把实体当真正的字符解释,所以 WAF 也必须先展开实体再做标签匹配,否则 <script> 就漏了。

注意 html.unescape 遵循 HTML5 规范,连”没分号的实体”也会解(比如 &lt 也能解成 <),这恰好和浏览器的宽容行为一致。

先过 form 双解码:因为 XSS 载荷出现在 URL 参数或表单里时通常是百分号编码的,而且空格会被编码成 ”+“(这是表单编码的规定)。只解百分号不认 ”+” 的话,< area 这种载荷解出来是 <+area,尖括号后面那个空格就消失了 —— 而这一个空格恰恰是CVE-2026-64638 整条链的开关。

最初的 html 视图只做 url2,结果规则 2009在自检里对真实载荷完全匹配不上。教训是 ——视图的语义必须贴合后端/浏览器实际怎么解析,差一个字符就是漏报。

view_sql(s)SQL 归一化:解码 + 吃掉注释 + 折叠空白#

s = view_form(view_form(s))
s = _SQL_BLOCK_COMMENT.sub(" ", s)
s = _SQL_LINE_COMMENT.sub(" ", s)
s = _MULTI_WS.sub(" ", s)
return s.strip()

主要用于对抗这三类绕过: un//ion sel//ect 注释插在关键字中间 /!50000union/ select MySQL 版本注释(后端会当代码执行) union—\nselect 行注释切断关键字 %75nion%20%73elect 关键字被百分号编码 全部还原之后,它们都会塌缩回 “union select”。

为什么起点是 view_form(而不是 view_url) 因为 “1+union+select” 这种载荷在 application/x-www-form-urlencoded 和查询串里,”+” 的意思就是空格 —— 后端解出来是 “1 union select”。 如果 WAF 只做百分号解码,”+” 还是 ”+“,中间的正则 \bunion\b\s+\bselect\b 就会因为 ”+” 不是空白字符而匹配失败,攻击者白送一次绕过。

view_path(s)路径视图:把各种编码还原成明文,并把分隔符统一#

s = view_url2(s)
s = s.replace("\\", "/")
s = _MULTI_SLASH.sub("/", s)
return s
VIEWS = {
"raw": view_raw,
"url": view_url,
"url2": view_url2,
"url3": view_url3,
"form": view_form,
"utf8": view_utf8,
"uni": view_uni,
"lower": view_lower,
"html": view_html,
"sql": view_sql,
"path": view_path,
}
VIEW_NAMES = tuple(VIEWS.keys())

具体做三件事: 1. 百分号解码两次(覆盖 %2e 和 %252e 两种编码) 2. 反斜杠统一成斜杠(Windows 后端对 \ 和 / 一视同仁) 3. 折叠重复斜杠

因为 WAF 不知道 DocumentRoot 在哪:

/icons/../../etc/passwd
↑ 从 /usr/local/apache2/icons 出发,往上两层到 /usr/local/apache2
↑ 但从 /var/www/icons 出发,往上两层到 /var/www

同一个字符串,在不同服务器上是完全不同的文件。WAF 站在流量中间, 看不到后端的目录结构,任何”帮你算最终路径”的尝试都是猜。

所以这里的策略是:只负责把明文暴露出来,不负责判断它去哪。 解密之后 ”..” 和 ”/” 都摆在那里了,剩下的交给规则用 (\\.\\./|\\.\\.\\\\) 一类的模式去认。

class Payload一段待检测文本,以及从它派生出来的各种解码视图#

__slots__ = ("target", "text", "_cache")
def __init__(self, target, text):
self.target = target # 片段名,如 "path" / "query.value" / "header.ua"
self.text = text # 原始文本(未经解码)
self._cache = {}
def view(self, name):
"""取某个解码视图的文本。未知视图名按 raw 处理(不报错,避免一条规则打挂全流程)。"""
cached = self._cache.get(name)
if cached is not None:
return cached
fn = VIEWS.get(name)
if fn is None:
result = self.text
else:
try:
result = fn(self.text)
except Exception:
# 解码失败不该让整个请求处理挂掉 —— 退化成原文继续检测
result = self.text
if result is None:
result = ""
# 名字带 "lower" 的视图顺便把结果也小写,方便规则只写一遍模式
self._cache[name] = result
return result
def __repr__(self):
return "<Payload %s=%r>" % (self.target, self.text[:60])

称手的用法: p = Payload(“path”, “/icons/.%2e/%2e%2e/etc/passwd”) p.view(“path”) # -> “/icons/../../etc/passwd”

视图算过一次就缓存,所以规则多写几条也不会重复计算。

target_matches(rule_targets, actual_target)判断规则的 targets 列表是否覆盖某个实际片段名#

for t in rule_targets:
if t == "*" or t == actual_target:
return True
if t.endswith(".*") and actual_target.startswith(t[:-1]):
return True
return False

支持三种写法: “path” 精确匹配 “header.” 前缀通配(所有以 “header.” 开头的片段) "" 匹配一切

extract_payloads(req)把一个 RawRequest 拆成若干待检测片段#

payloads = []
# ---- 请求行 ----
# 方法本身也作为片段:TRACE 这类方法在正常业务里几乎不会出现,
# 却常被用来做 XST(跨站追踪)攻击。给它一个可匹配的位置。
payloads.append(Payload("method", req.method))
payloads.append(Payload("uri", req.target))
payloads.append(Payload("path", req.path))
# ---- 查询串 ----
if req.query:
payloads.append(Payload("query", req.query))
for kv in req.query.split("&"):
if not kv:
continue
if "=" in kv:
k, _, v = kv.partition("=")
payloads.append(Payload("query.key", k))
payloads.append(Payload("query.value", v))
else:
payloads.append(Payload("query.value", kv))
# ---- 请求体 ----
payloads.extend(_body_payloads(req))
# ---- 请求头 ----
for k, v in req.header_list:
if v:
payloads.append(Payload("header." + k.lower(), v))
if req.header_list:
payloads.append(Payload(
"headers",
"\n".join("%s: %s" % (k, v) for k, v in req.header_list),
))
# ---- Cookie 拆成一个个键值 ----
cookie = req.header("cookie")
if cookie:
for item in cookie.split(";"):
item = item.strip()
if not item:
continue
if "=" in item:
k, _, v = item.partition("=")
payloads.append(Payload("cookie.key", k))
payloads.append(Payload("cookie.value", v))
else:
payloads.append(Payload("cookie.value", item))
# ---- 协议层标记 ----
# 把解析层发现的协议异常(CL/TE 冲突、重复 Content-Length 等)也变成
# 可匹配的片段。这样"请求走私"这类协议层检测就能**复用同一套规则引擎**,
# 不必为它单写一套判定逻辑 —— 少一套代码就少一类 bug。
for flag in req.flags:
payloads.append(Payload("flag", flag))
return payloads

· 整体路径、整体查询串 -> 覆盖跨字段的拼接式攻击 · 每个参数单独一份 -> 定位更准,也避免参数分隔符干扰正则 · 按 Content-Type 拆 body -> 通用型 WAF 的关键,见下面 _body_payloads · 常见攻击面请求头 -> UA / Referer 是注入的老窝

_body_payloads(req)按 Content-Type 把 body 拆成结构化片段#

out = []
body = req.body
if not body:
return out
ctype = req.content_type
if ctype == "application/x-www-form-urlencoded":
text = body.decode("latin-1")
out.append(Payload("body", text))
for kv in text.split("&"):
if not kv:
continue
if "=" in kv:
k, _, v = kv.partition("=")
out.append(Payload("body.key", k))
out.append(Payload("body.value", v))
else:
out.append(Payload("body.value", kv))
elif ctype == "application/json" or ctype.endswith("+json"):
text = body.decode("utf-8", "replace")
out.append(Payload("body", text))
parsed = _try_json(text)
if parsed is not None:
for name, value in _iter_json_strings(parsed):
out.append(Payload(name, value))
elif ctype == "multipart/form-data":
for name, filename, value in _parse_multipart(body, req.content_type and _boundary(req)):
out.append(Payload("body.value", value))
if filename:
# 文件名单独一个片段:上传 WebShell 的检测点就在这里
out.append(Payload("body.filename", filename))
out.append(Payload("body", body.decode("latin-1", "replace")))
else:
# 兜底:不认识的一律当文本看。绝不能让未知 Content-Type 变成检测盲区 ——
# 攻击者只要伪造一个冷门 Content-Type 就能躲过检查,那 WAF 就白写了
out.append(Payload("body", body.decode("latin-1", "replace")))
return out

不同的网站用不同的 body 格式。如果 WAF 只会看表单,那所有走 JSON 的 接口(绝大多数现代前后端分离站点、以及 WordPress 的 REST API)就全是盲区 —— 攻击者往 JSON 里塞 SQL 注入,WAF 只会看见一团无害的 JSON 文本。

所以通用型 WAF 必须做到:不管什么格式,都能拆到”值”这一层再检测。 本项目覆盖四种最常见的:

application/x-www-form-urlencoded key=value&key2=value2
application/json {"a": "b"}(含嵌套)
multipart/form-data 文件上传 + 普通字段
text/xml / application/xml 整个 body 当文本看
其他 整个 body 当文本看(兜底)

_boundary(req)从 Content-Type 参数里取出 multipart 的分隔串#

ct = req.header("content-type") or ""
m = re.search(r'boundary="?([^";]+)"?', ct, re.I)
return m.group(1) if m else ""

_try_json(text)#

import json
try:
return json.loads(text)
except Exception:
return None

包了 try/except 的 json.loads,失败返回 None。分析正文格式不能因为 JSON 非法就丢掉整段 ——非法 JSON 恰恰可能是攻击载荷

_iter_json_strings(obj, name="body.json")递归遍历 JSON,产出所有字符串值#

if isinstance(obj, str):
yield name, obj
elif isinstance(obj, dict):
for k, v in obj.items():
yield name + ".key", k
yield from _iter_json_strings(v, name)
elif isinstance(obj, list):
for item in obj:
yield from _iter_json_strings(item, name)
elif isinstance(obj, (int, float, bool)) or obj is None:
yield name, str(obj)

JSON 嵌套多深都往下走 —— 因为攻击者完全可以把载荷埋在{"a": {"b": {"c": ["恶意"]}}} 这样的深层结构里。只检查顶层字段的 WAF 会被这种结构绕过去。

_parse_multipart(body, boundary)极简 multipart/form-data 解析,产出 (字段名, 文件名, 值文本)#

if not boundary:
return
delim = b"--" + boundary.encode("latin-1", "replace")
chunks = body.split(delim)
for chunk in chunks[1:]:
# 最后一块是 "--" 结尾的终止符
if chunk.strip() in (b"", b"--", b"--\r\n"):
continue
if b"\r\n\r\n" in chunk:
head, _, value = chunk.partition(b"\r\n\r\n")
elif b"\n\n" in chunk:
head, _, value = chunk.partition(b"\n\n")
else:
continue
# 去掉尾部的 CRLF
value = value.rstrip(b"\r\n")
head_text = head.decode("latin-1", "replace")
name_m = re.search(r'name="([^"]*)"', head_text, re.I)
file_m = re.search(r'filename="([^"]*)"', head_text, re.I)
name = name_m.group(1) if name_m else ""
filename = file_m.group(1) if file_m else ""
# 值按 latin-1 解,保证任何二进制内容都能放进来(不丢字节)
yield name, filename, value.decode("latin-1", "replace")

只做检测需要的部分,不追求 RFC 完备: · 按 —boundary 切块 · 每块头部找 name= 和 filename= · 剩下的当值

真实 WAF 里这块要复杂得多(要处理各种畸形边界、嵌套 multipart、超长文件名等)。但解析差异本身就是绕过点,所以这里的原则和别处一致:宁可粗糙但要能看到原文,不要精细解析后把看不懂的部分丢掉。

waf/rules.py — 规则引擎#

这一层决定”这条请求算不算攻击”。规则文件每行 7 个字段用竖线分隔:

id | 名称 | 检测目标 | 解码视图 | 动作 | 分值 | 正则

字段含义: id 编号,会进审计日志。建议按段位划分功能块(1000 站注入 / 2000 站 XSS…) name 人读的名字,日志和阻断页里会显示 targets 要检查哪些片段,逗号分隔;支持精确名、前缀通配 header.*、以及 * view 在哪个解码视图上匹配(见 normalize.VIEWS),这一项决定了规则的检测深度 action block = 计入并可作为拦截依据 / log = 只计分 / allow = 命中即白名单放行 score 命中后累加的风险分,详见下面「为什么用评分制」 pattern Python 正则表达式,必须是最后一个字段(因为正则里经常有 | 符号, 放在末尾才能原样保留)

为什么用「异常评分」而不是「命中即拦」?

最直觉的设计是:一条规则命中就返回 403。但这个设计在真实环境里会死得很快。

原因是单条规则的判别力有限。举例: · 规则 “请求里出现 ../” —— 一个正常的文件管理接口天天在传这个 · 规则 “响应里出现 select” —— 一篇讲 SQL 的博客正文就会命中 · 规则 “含 user-agent: sqlmap” —— 攻击者改一个字符串就绕过了

如果一命中就拦,运维第二天就会因为误报把你的 WAF 关掉。 现实中 WAF 上线失败的头号原因不是”拦不住”,而是”误报太多被关掉”。

常量#

MAX_MATCH_TEXT = 8192
# 日志里记录命中内容的最大长度,防止把整个 body 塞进日志
MAX_HIT_SNIPPET = 160
VALID_ACTIONS = ("block", "log", "allow")
  • MAX_MATCH_TEXT = 8192 —— 单次匹配的文本长度上限。 Python 的 re 模块没有超时机制,一条写坏的正则(灾难性回溯,ReDoS) 就能把处理线程永久卡住。攻击者不用打后端,先把 WAF 挂掉就行。 限长是最省事也最有效的兜底
  • MAX_HIT_SNIPPET = 160 —— 日志里记录命中内容的最大长度, 防止把整个 body 塞进日志
  • VALID_ACTIONS = ("block", "log", "allow") —— 合法的动作值

RuleError#

pass

规则文件写错了就抛这个。文档字符串里写明了它的定位:这类错误必须在 启动时就抛出来,绝不允许”静默跳过一行” —— 一条写错的规则可能意味着 某类攻击完全没人管,而你还以为它生效了。

Rule 类 — 单条规则#

__slots__ = ("rid", "name", "targets", "view", "action", "score",
"pattern", "regex", "source")
def __init__(self, rid, name, targets, view, action, score, pattern, source=""):
self.rid = rid
self.name = name
self.targets = targets
self.view = view
self.action = action
self.score = score
self.pattern = pattern
self.source = source
try:
self.regex = re.compile(pattern)
except re.error as exc:
raise RuleError("规则 %s 的正则无法编译: %s(模式: %r)" % (rid, exc, pattern))
  • __init__():存下 7 个字段,并立刻编译正则。编译放在构造时而不是匹配时,有两个好处:一是把 “正则写错”提前到启动阶段暴露,二是避免每次匹配都重新编译。 编译失败直接抛 RuleError,带上规则编号和模式,方便定位
def match(self, payloads):
hits = []
for p in payloads:
if not target_matches(self.targets, p.target):
continue
text = p.view(self.view)
if not text:
continue
if len(text) > MAX_MATCH_TEXT:
text = text[:MAX_MATCH_TEXT]
try:
m = self.regex.search(text)
except Exception:
continue
if m:
snippet = m.group(0)[:MAX_HIT_SNIPPET]
# 注意这里是 self.view(规则声明的视图名),不是 p.view。
# p.view 是 Payload 的方法对象 —— 写错的话日志里记的就是
# "<bound method ...>" 这种字符串,而且不会报错,极难发现。
hits.append(Hit(self, p.target, self.view, snippet))
return hits

在片段列表上跑这条规则, 返回所有命中。注意遍历顺序 —— 外层是片段、内层才是正则匹配:

for p in payloads:
if not target_matches(self.targets, p.target):
continue
text = p.view(self.view) # 懒计算在这里触发
...
m = self.regex.search(text)
hits.append(Hit(self, p.target, self.view, snippet))

先过 target_matches 再取视图,意味着绝大多数无关片段在取视图之前 就被挡掉了 —— 真正跑正则的次数并不多

def __repr__(self):
return "<Rule %s %s view=%s score=%d>" % (self.rid, self.name, self.view, self.score)

调试用的一行摘要

Hit 类 — 一次命中#

审计日志里逐条记录,方便事后复盘”到底是哪条规则拦的”。

__slots__ = ("rule", "target", "view", "matched")
def __init__(self, rule, target, view, matched):
self.rule = rule
self.target = target
self.view = view
self.matched = matched
def to_dict(self):
return {
"rule": self.rule.rid,
"name": self.rule.name,
"target": self.target,
"view": self.view,
"score": self.rule.score,
"matched": self.matched,
}
def __repr__(self):
return "<Hit %s on %s[%s] %r>" % (self.rule.rid, self.target, self.view, self.matched)

记录”哪条规则、在哪个片段、哪个视图、匹配到什么文本”。

  • __init__():四个字段,同样用了 __slots__
  • to_dict():转成审计日志用的字典。注意 rule 字段存的是 self.rule.rid(编号)而不是整个规则对象 —— 日志要紧凑、可 JSON 序列化
  • __repr__():终端输出用

ScanResult 类 — 一次完整扫描的结论#

__slots__ = ("action", "score", "hits", "allow_rule", "threshold")
def __init__(self, action, score, hits, allow_rule=None, threshold=100):
self.action = action # "pass" / "log" / "block" / "allow"
self.score = score # 累计风险分
self.hits = hits # [Hit, ...]
self.allow_rule = allow_rule # 命中的白名单规则(如果有)
self.threshold = threshold
@property
def blocked(self):
return self.action == "block"
def to_dict(self):
return {
"action": self.action,
"score": self.score,
"threshold": self.threshold,
"hits": [h.to_dict() for h in self.hits],
}
def explain(self):
"""一句话说清"为什么放行 / 为什么拦",用于终端输出和阻断页。"""
if self.action == "allow":
return "白名单放行(命中规则 %s: %s)" % (
self.allow_rule.rid, self.allow_rule.name)
if self.action == "pass":
return "无命中"
if self.action == "log":
return "有命中但风险分不足(%d/%d):%s" % (
self.score, self.threshold,
"; ".join("%s=%d" % (h.rule.rid, h.rule.score) for h in self.hits))
return "风险分 %d 达阈值 %d,拦截:%s" % (
self.score, self.threshold,
"; ".join("%s(%s)" % (h.rule.rid, h.rule.name) for h in self.hits))

评分制的思路(ModSecurity CRS 也是这套): · 每条规则只贡献一个”证据分”,不直接下判决 · 弱信号给低分(比如 UA 是 sqlmap,给 15 分) · 强信号给高分(比如路径里出现真正的 ../ 穿越,给 60 分) · 多个弱信号叠加到阈值以上,才判定为攻击

于是(分数取自 rules/default.rules 实测值): UA 里出现 sqlmap(35,规则 3010) + 路径里出现 ../(70,规则 1001) + 参数里出现 union select(100,规则 1020) = 205 分 -> 超过阈值 100,拦截

  • __init__():action(pass/log/block/allow)、 score、hits、allow_rule、threshold
  • blocked 属性:self.action == "block" 的语法糖
  • to_dict():转字典
  • explain():一句话说清”为什么放行 / 为什么拦”, 用于终端输出和阻断页。四种动作各有一句,比如 log 是 "有命中但风险分不足(60/100):1001=70"。这个方法是”WAF 可解释性”的落点 —— 用户看到 403 时能立刻知道是哪几条规则、各贡献了多少分

RuleSet 类 — 规则集合#

一整套规则,以及扫描入口。

allow 规则和检测规则分开存:白名单必须先跑,命中就短路返回,绝不能让后面的黑名单再去累加分数。(否则”某个接口确实要传 SQL 语句”这种白名单就永远生效不了。)

def __init__(self, rules, threshold=100):
self.rules = rules
self.threshold = threshold
self.allow_rules = [r for r in rules if r.action == "allow"]
self.detect_rules = [r for r in rules if r.action != "allow"]
# ---- 扫描 ----
def scan(self, payloads):
# 第一步:白名单短路。
# 白名单命中就立刻放行,不给黑名单任何机会 —— 这是"确定性",
# 运维才能放心地把关键业务加进白名单。
for r in self.allow_rules:
hits = r.match(payloads)
if hits:
return ScanResult("allow", 0, hits, allow_rule=r,
threshold=self.threshold)
# 第二步:黑名单累加。每条规则最多计一次分。
score = 0
hits = []
for r in self.detect_rules:
rule_hits = r.match(payloads)
if rule_hits:
hits.extend(rule_hits)
score += r.score # 不管命中几个片段,只加一次
if score >= self.threshold:
action = "block"
elif hits:
action = "log"
else:
action = "pass"
return ScanResult(action, score, hits, threshold=self.threshold)
# ---- 加载 ----
@classmethod
def from_files(cls, paths, threshold=100):
rules = []
for path in paths:
if os.path.isdir(path):
for name in sorted(os.listdir(path)):
if name.endswith(".rules"):
rules.extend(load_rule_file(os.path.join(path, name)))
else:
rules.extend(load_rule_file(path))
# 启动时做一次冲突检查,把"看起来有问题"的规则提前暴露出来,
# 而不是等它在线上静默失效
_sanity_check(rules)
return cls(rules, threshold=threshold)
def summary(self):
lines = []
lines.append("已加载 %d 条规则(拦截阈值 %d 分)" % (len(self.rules), self.threshold))
lines.append("-" * 72)
for r in self.rules:
lines.append(" %-6s %-28s %-10s %-8s %3d 目标=%s" % (
r.rid, _clip(r.name, 28), r.view, r.action, r.score,
",".join(r.targets)))
lines.append("-" * 72)
return "\n".join(lines)
  • __init__():rules、threshold,然后把 allow 规则 和检测规则分开存。文档字符串解释了原因:白名单必须 先跑,命中就短路返回,绝不能让后面的黑名单再去累加分数 —— 否则”某个接口确实要传 SQL 语句”这种白名单就永远生效不了
  • scan(payloads) —— 核心方法,分两步:
    • 第一步:白名单短路。命中任何 allow 规则就立刻 返回 ScanResult("allow", ...),不给黑名单机会。这是”确定性”, 运维才能放心地把关键业务加进白名单
    • 第二步:黑名单累加。逐条跑检测规则, 每条规则最多计一次分:
      for r in self.detect_rules:
      rule_hits = r.match(payloads)
      if rule_hits:
      hits.extend(rule_hits)
      score += r.score # 不管命中几个片段,只加一次
      为什么只加一次:同一条规则重复命中带来的信息增量很小(同一个攻击手法 而已),但如果重复计分,一个参数很多的正常请求很容易被堆到阈值以上 —— 误报就是这么来的
    • 最后按 score >= threshold 决定 block / log / pass
  • from_files(paths, threshold):类方法,从若干文件或目录 加载规则。传目录时会读该目录下所有 .rules 文件,按文件名排序 以保证加载顺序稳定。加载完调一次 _sanity_check。 规则顺序有意义 —— 它影响审计日志的可读性
  • summary():打印一张规则表(run.py --list-rules 用的就是它)。启动时看一眼就知道哪些规则真的生效了

_clip(s, n)把字符串截到 n 个字符,超长时末尾加”…”#

return s if len(s) <= n else s[:n - 1] + "…"

用于 summary() 里对齐列宽

load_rule_file(path)读一个规则文件,逐行处理,返回 [Rule, …]#

if not os.path.exists(path):
raise RuleError("规则文件不存在: %s" % path)
rules = []
with open(path, "r", encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, 1):
line = raw.rstrip("\n").rstrip("\r")
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
rules.append(_parse_line(stripped, path, lineno))
return rules

跳过空行和 # 注释,其余交给 _parse_line

_parse_line(line, path, lineno)解析一行规则#

parts = line.split("|", 6)
if len(parts) != 7:
raise RuleError("%s:%d 字段数应为 7 段(用 | 分隔),实际 %d 段:%s"
% (path, lineno, len(parts), line[:100]))
rid = parts[0].strip()
name = parts[1].strip()
targets = [t.strip() for t in parts[2].split(",") if t.strip()]
view = parts[3].strip()
action = parts[4].strip().lower()
score_raw = parts[5].strip()
pattern = parts[6].strip()
# ---- 逐字段校验 ----
if not rid:
raise RuleError("%s:%d 缺少规则 id" % (path, lineno))
if not targets:
raise RuleError("%s:%d 规则 %s 没有指定检测目标" % (path, lineno, rid))
if not pattern:
raise RuleError("%s:%d 规则 %s 的正则为空" % (path, lineno, rid))
if action not in VALID_ACTIONS:
raise RuleError("%s:%d 规则 %s 的 action 非法:%r(可选 %s)"
% (path, lineno, rid, action, "/".join(VALID_ACTIONS)))
try:
score = int(score_raw)
except ValueError:
raise RuleError("%s:%d 规则 %s 的分值不是整数:%r" % (path, lineno, rid, score_raw))
# 视图名写错不会报错,但会退化成 raw —— 这等于悄悄降低了检测深度。
# 所以要显式警告,因为这几乎总是笔误。
if view not in VIEW_NAMES:
import sys
sys.stderr.write(
"[warn] %s:%d 规则 %s 的 view=%r 不是已知视图,将按 raw 处理。"
"已知视图: %s\n" % (path, lineno, rid, view, ", ".join(VIEW_NAMES)))
view = "raw"
return Rule(rid, name, targets, view, action, score, pattern,
source="%s:%d" % (path, lineno))
parts = line.split("|", 6)

用 split("|", 6) 而不是 split("|")。为什么:正则里 | 是”或”的意思, 几乎每条规则都会用到(比如 (union|select))。限定最多切 6 次, 第 7 段就会保留后面所有 | 原样不动:

"a|b|c|d|e|f|g|h".split("|", 6)
# -> ['a', 'b', 'c', 'd', 'e', 'f', 'g|h'] 第 7 段完整保留

之后是逐字段校验:id 不能空、目标不能空、正则不能空、 action 必须在 VALID_ACTIONS 里、分值必须是整数。 最后一项是视图名检查:视图名写错不会报错,但会退化成 raw —— 这等于悄悄降低了检测深度,所以要显式警告到 stderr

_sanity_check(rules)启动时的一致性检查#

import sys
seen = {}
hints = []
warnings = []
for r in rules:
# 1) 重复 id:日志里会分不清是哪条规则干的
if r.rid in seen:
warnings.append("规则 id %s 重复出现(%s 和 %s)—— 日志里会分不清是哪条"
% (r.rid, seen[r.rid].source, r.source))
else:
seen[r.rid] = r
# 2) 检测目标写成前缀但忘了通配符
# "header." 会被当成一个精确名字,永远匹配不到任何片段
for t in r.targets:
if t.endswith("."):
warnings.append("规则 %s 的目标 %r 以点结尾,是不是想写 %r?"
% (r.rid, t, t + "*"))
# 3) critical 级规则汇总成一条提示,而不是逐个报警
critical = [r for r in rules if r.score >= 100 and r.action != "allow"]
if critical:
hints.append("有 %d 条 critical 级规则(单条命中即拦截):%s"
% (len(critical), ", ".join(str(r.rid) for r in critical)))
# 4) 白名单为空 —— 部署到真实业务时,没有白名单几乎必然被误报淹没
if not any(r.action == "allow" for r in rules):
hints.append("规则集中没有任何 allow(白名单)规则 —— "
"接入真实业务前建议补上,否则正常业务流量容易被误判")
# 5) 阈值配得比所有规则分值都高 —— WAF 会永远不拦任何东西
if rules and max(r.score for r in rules) < 100:
hints.append("当前规则的最高分值是 %d —— 如果拦截阈值也高于它,"
"WAF 将永远不会拦截" % max(r.score for r in rules))
for h in hints:
sys.stderr.write("[hint] %s\n" % h)
for w in warnings:
sys.stderr.write("[warn] %s\n" % w)
  • [hint] —— 不是错误,是让你知道”你配了哪些一票否决规则”(比如汇总所有 100 分规则、提醒白名单为空、提醒阈值高过所有分值)
  • [warn] —— 大概率是笔误,需要你看一眼(比如重复的规则 id、目标写成 header. 却忘加通配符 *)

waf/ratelimit.py速率限制#

为什么正则规则拦不住这类攻击:看这三个请求,内容完全合法:

POST /login {"user":"admin","pass":"123456"}
POST /login {"user":"admin","pass":"password"}
POST /login {"user":"admin","pass":"qwerty"}

没有注入特征、没有 XSS、没有穿越 —— 任何签名规则都匹配不上。 但连着发一万次,这就是密码爆破。同样的道理适用于撞库、CC 攻击、 爬虫、漏洞扫描器。这些攻击的共性是:单个请求看不出问题, 只有看”单位时间内的请求量”才露出马脚。

最简单的是固定窗口:“每 10 秒重新计数,超过 50 次就拦”。 但它有个可利用的漏洞 —— 窗口边界:

第 9.9 秒发 50 次(刚好没超)
第 10.1 秒再发 50 次(新窗口,也没超)
-> 在 0.2 秒内实际发了 100 次,攻击者成功翻倍

本模块用的是滑动窗口:记录每一次请求的时间戳,判定时先丢掉 “窗口之外”的旧记录,再看剩下的条数。上面那种边界突刺就藏不住了 —— 第 10.1 秒时窗口里还留着第 9.9 秒的 50 条记录。

代价是要为每个 IP 存一串时间戳(内存开销)。真实生产系统会改用 “令牌桶 + 计数近似”(例如 Redis 的滑动窗口计数器)来省内存, 但思路完全一样。

为什么是”加分”而不是”直接拦”? 和签名规则保持一致:速率超限只是一个证据,累加到风险分里由统一阈值裁决。

这样带来一个很实用的能力:把分值调低(比如超限每多一次 +2 分),它就变成"只记录不拦截"的观测模式 —— 上生产前先用这个摸清自己站点的正常流量水位,再决定阈值。

RateEvent 类 — 一次速率超限事件#

__slots__ = ("client", "count", "limit", "window", "excess", "score")
def __init__(self, client, count, limit, window, excess, score):
self.client = client
self.count = count
self.limit = limit
self.window = window
self.excess = excess
self.score = score
def to_dict(self):
return {
"rule": "9001",
"name": "速率限制 · 单 IP 请求过量",
"target": "rate",
"view": "raw",
"score": self.score,
"matched": "%.1f 秒内 %d 次请求(上限 %d,超出 %d)" % (
self.window, self.count, self.limit, self.excess),
}
def __repr__(self):
return "<RateEvent %s %d/%d in %.1fs>" % (
self.client, self.count, self.limit, self.window)
  • __init__():client、count、limit、window、 excess(超出次数)、score
  • to_dict():转成审计日志格式。注意这里刻意伪装成和 Hit 一样的结构(rule/name/target/view/score/matched), 并且固定用 id = "9001" —— 这样限流事件能和其他规则命中 一起被日志统计工具处理,不必单写一套解析。matched 字段被渲染成 "10.0 秒内 137 次请求(上限 120,超出 17)" 这样的人话
  • __repr__():调试用

RateLimiter 类 — 滑动窗口限流#

def __init__(self, window=10.0, limit=100, score_per_excess=2,
max_score=90, max_buckets=20000):
self.window = window
self.limit = limit
self.score_per_excess = score_per_excess
self.max_score = max_score
self.max_buckets = max_buckets
self._buckets = {} # ip -> deque[timestamp]
self._lock = threading.Lock()
self._ops = 0
def record(self, client, now=None):
now = now if now is not None else time.time()
with self._lock:
bucket = self._buckets.get(client)
if bucket is None:
# 桶太多时先做一次回收,再决定要不要新建
if len(self._buckets) >= self.max_buckets:
self._evict_locked(now)
bucket = deque()
self._buckets[client] = bucket
# 丢掉滑出窗口的记录
cutoff = now - self.window
while bucket and bucket[0] < cutoff:
bucket.popleft()
bucket.append(now)
count = len(bucket)
# 顺手做一次低频回收,避免内存被海量不同 IP 慢慢撑爆
self._ops += 1
if self._ops % 4096 == 0:
self._evict_locked(now)
if count <= self.limit:
return None
excess = count - self.limit
score = min(excess * self.score_per_excess, self.max_score)
return RateEvent(client, count, self.limit, self.window, excess, score)
def _evict_locked(self, now):
"""清掉窗口内已经没有任何记录的 IP 桶。必须在持锁状态下调用。"""
cutoff = now - self.window
dead = [ip for ip, bucket in self._buckets.items()
if not bucket or bucket[-1] < cutoff]
for ip in dead:
del self._buckets[ip]
@property
def tracked_ips(self):
with self._lock:
return len(self._buckets)
  • window = 10.0 —— 窗口长度(秒)

  • limit = 100 —— 窗口内允许的请求数

  • score_per_excess = 2 —— 每超出一次加多少风险分

  • max_score = 90 —— 单次事件最多加多少分(避免一个高频 IP 直接爆表, 也让其他规则有机会在日志里露出马脚)

  • max_buckets = 20000 —— 最多跟踪多少个 IP(防内存被撑爆)

  • record(client, now=None):记录一次请求,超限时返回 RateEvent,否则返回 None。流程是:

    • 加锁 —— 多线程共享,必须保护
    • 取或新建该 IP 的桶(deque 存时间戳)。新建前如果桶数已达 max_buckets,先做一次回收
    • 丢掉滑出窗口的旧记录:while bucket and bucket[0] < cutoff: popleft()
    • 追加当前时间戳,count = len(bucket)
    • 低频回收:每 4096 次操作做一次全扫,避免内存被 海量不同 IP 慢慢撑爆
    • 出锁后再算分数:excess = count - limit, score = min(excess * score_per_excess, max_score)
  • _evict_locked(now):清掉窗口内已经没有任何记录的 IP 桶。 必须在持锁状态下调用 —— 函数名里的 _locked 后缀就是这个约定

  • tracked_ips 属性:返回当前跟踪的 IP 数量(加锁读), 供监控用

滑动窗口记录每一次请求的时间戳,判定时先丢掉窗口之外的旧记录,再看剩下 多少。上面那种边界突刺就藏不住了 —— 第 10.1 秒时窗口里还留着第 9.9 秒的 50 条记录。代价是要为每个 IP 存一串时间戳(内存开销), 生产系统会改用”令牌桶 + 计数近似”来省内存,但思路完全一样。

waf/engine.py — 决策层(最大的一个文件)#

这一层把五层串成一条流水线,并决定放行还是阻断。

TCP 连接建立 ─────────>│ ① read_request() 解析出 RawRequest│
└───────────────┬──────────────────┘
│
┌───────────────▼──────────────────┐
│ ② extract_payloads() 拆出检测片段 │
│ (path / query / body / header…)│
└───────────────┬──────────────────┘
│
┌───────────────▼──────────────────┐
│ ③ ruleset.scan() 规则匹配 + 累加分 │
│ limiter.record() 速率维度 │
└───────────────┬──────────────────┘
│
┌───────────────▼──────────────────┐
│ ④ 总分 >= 阈值 ? │
│ 是 -> 返回 403(不碰后端) │
│ 否 -> 原样转发给后端 │
└───────────────┬──────────────────┘
│
┌───────────────▼──────────────────┐
│ ⑤ audit.write() 落一行 JSONL │
└──────────────────────────────────┘

放行的请求必须一个字节都不改地发给后端,包括那个 .%2e 路径。

反过来说:如果 WAF 好心把 %2e 解码成 . 再转发(很多”半吊子”实现会这么干, 因为它们的 HTTP 库默认就会规范化),会发生什么? · 后端收到的是已经规范化的路径 · 漏洞根本没被触发 · 你看到 WAF”放行”了,以为规则没问题,实际是 WAF 自己把攻击洗干净了 这是假阴性,也是评估 WAF 时最容易骗过自己的一种情况。

所以这里刻意用 http.client 的 putrequest() 而不是高层的 request(): 前者把 request-target 原样写进请求行,不做任何加工。

本实现在阻断响应里加了 X-WAF-Rule-Ids 之类的头,方便你调试时 一眼看出”是哪条规则拦的”。但生产环境不应该这么干 —— 攻击者拿到规则编号就能精准构造绕过(比如知道你是 1001 号规则拦的 路径穿越,他就会去试 %25252e)。

所以 run.py 提供 —quiet-headers 开关,上线前记得关掉。

两个常量#

HOP_BY_HOP = frozenset([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
])
# 这些状态码按规定不能带响应体
NO_BODY_STATUS = (204, 304)
  • HOP_BY_HOP:逐跳头集合 —— connection、keep-alive、 proxy-authenticate、proxy-authorization、te、trailer、trailers、 transfer-encoding、upgrade。RFC 7230 规定这些头只在”相邻两跳”之间 有意义,代理转发时必须剥掉。最典型的是 Transfer-Encoding: 它描述的是”我这一跳怎么传的”,你把它连同已经解码完的 body 一起转发, 下一跳会按错误格式去解析 —— 请求走私漏洞有很大一部分就是这么来的
  • NO_BODY_STATUS = (204, 304):这些状态码按规定不能带响应体

Config 类 — 运行配置#

def __init__(self, listen_host, listen_port, backend_host, backend_port,
ruleset, limiter, audit_log, mode="block", timeout=15.0,
verbose=True, expose_rules=True):
self.listen_host = listen_host
self.listen_port = listen_port
self.backend_host = backend_host
self.backend_port = backend_port
self.ruleset = ruleset
self.limiter = limiter
self.audit_log = audit_log
self.mode = mode # "block"(真拦) 或 "audit"(只记录)
self.timeout = timeout
self.verbose = verbose
self.expose_rules = expose_rules # 是否在响应头/阻断页暴露规则细节
@property
def backend(self):
return "%s:%d" % (self.backend_host, self.backend_port)
  • __init__():把 listen_host/port、backend_host/port、 ruleset、limiter、audit_log、mode、timeout、verbose、 expose_rules 打包成一个对象。这样做是为了让 WAFHandler 能通过 self.server.cfg 一次拿到全部依赖,而不是满世界找全局变量
  • backend 属性:"%s:%d" 拼成可读的后端地址,用于打印和报错

终端彩色输出:C 类和 _action_style()#

class C:
RESET = "\033[0m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
@classmethod
def disable(cls):
for name in ("RESET", "DIM", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA"):
setattr(cls, name, "")
def _action_style(action):
"""把动作名映射成 (颜色, 中文标签)。
---------------------------------------------------------------------
为什么写成函数而不是模块级字典
---------------------------------------------------------------------
最初这里是个模块级的 _ACTION_STYLE 字典,结果 --no-color 完全失效:
_ACTION_STYLE = {"block": (C.RED, "拦截"), ...}
这一行在**模块被导入时**就求值了,字典里存的是当时 C.RED 那个字符串
"\033[31m"。之后 run.py 调用 C.disable() 把 C.RED 改成空串,
但字典里早就把旧值复制走了,改类属性影响不到它 ——
于是重定向到文件时,日志里依然混着一堆 [31m 转义码。
改成函数后,每次调用都重新读 C.RED,拿到的是 disable 之后的最新值。
这类"值被提前捕获"的 bug 很隐蔽:代码能跑、看起来也对,
只有在特定开关组合下才暴露。
"""
return {
"pass": (C.DIM, "放行"),
"allow": (C.BLUE, "白名单"),
"log": (C.YELLOW, "记录"),
"block": (C.RED, "拦截"),
}.get(action, ("", action))
  • C 类:一堆 ANSI 转义常量,加一个类方法 disable(),它把所有颜色常量设成空字符串,实现 --no-color
  • _action_style(action):把动作名映射成 (颜色, 中文标签)。

为什么写成函数而不是模块级字典? 最初这里是个模块级的 _ACTION_STYLE 字典,结果 —no-color 完全失效:

_ACTION_STYLE = {"block": (C.RED, "拦截"), ...}
这一行在**模块被导入时**就求值了,字典里存的是当时 C.RED 那个字符串
"\033[31m"。之后 run.py 调用 C.disable() 把 C.RED 改成空串,
但字典里早就把旧值复制走了,改类属性影响不到它 ——
于是重定向到文件时,日志里依然混着一堆 [31m 转义码。
改成函数后,每次调用都重新读 C.RED,拿到的是 disable 之后的最新值。
这类"值被提前捕获"的 bug 很隐蔽:代码能跑、看起来也对,
只有在特定开关组合下才暴露。

BackendError#

pass

自定义异常,表示”后端连不上或响应异常”。forward_request() 抛它, _process() 捕获后回 502。

forward_request(cfg, req) — 原样转发#

conn = http.client.HTTPConnection(
cfg.backend_host, cfg.backend_port, timeout=cfg.timeout)
try:
conn.putrequest(req.method, req.target,
skip_host=True, skip_accept_encoding=True)
for key, value in req.header_list:
low = key.lower()
if low in HOP_BY_HOP:
continue
if low == "content-length":
continue # 下面统一重写
conn.putheader(key, value)
if req.body:
conn.putheader("Content-Length", str(len(req.body)))
elif req.method.upper() in ("POST", "PUT", "PATCH"):
# 有些后端框架对 POST 不带 Content-Length 会卡住等 body
conn.putheader("Content-Length", "0")
conn.endheaders()
if req.body:
conn.send(req.body)
resp = conn.getresponse()
body = resp.read()
return resp.status, resp.reason, resp.getheaders(), body
except (OSError, http.client.HTTPException) as exc:
raise BackendError("%s: %s" % (type(exc).__name__, exc))
finally:
conn.close()

“原样转发”是这一层最要紧的设计。放行的请求必须一个字节都不改地 发给后端,包括那个 .%2e 路径。

反过来想:如果 WAF 好心把 %2e 解码成 . 再转发(很多”半吊子”实现会这么干, 因为它们的 HTTP 库默认就会规范化),会发生什么?

  • 后端收到的是已经规范化的路径
  • 漏洞根本没被触发
  • 你看到 WAF”放行”了,以为规则没问题,实际是 WAF 自己把攻击洗干净了

实现上有三个刻意的选择:

  • 用 putrequest() 而不是 request():request() 会帮我们 拼 URL、加 Host、加 Accept-Encoding —— 全是”加工”。putrequest 则把 target 原样写进请求行,这正是”透明代理”需要的
  • skip_host=True:把客户端原始的 Host 头照搬过去。 如果自己按 backend 地址重新生成 Host,那些靠 Host 头做虚拟主机路由的 站点(一台服务器挂几十个域名)会全部返回 404
  • 重写 Content-Length:body 可能已经从 chunked 解码成完整字节了。此时如果还把原来的 Transfer-Encoding: chunked 转发过去,后端会去解析一段非 chunked 数据。所以统一改成 Content-Length。 对 POST/PUT/PATCH 即使 body 为空也补一个 Content-Length: 0 —— 有些后端框架对 POST 不带它会卡住等 body

build_response_headers(orig_headers, body, close, is_head, status, extra=None)#

out = []
original_length = None
for key, value in orig_headers:
low = key.lower()
if low in HOP_BY_HOP:
continue
if low == "content-length":
original_length = value
continue
out.append((key, value))
if is_head:
# HEAD 请求不返回 body,但 Content-Length 要保留原值 ——
# 它表示"如果这是 GET,body 会有多长",客户端依赖它预判资源大小
if original_length is not None:
out.append(("Content-Length", original_length))
elif status not in NO_BODY_STATUS:
out.append(("Content-Length", str(len(body))))
out.append(("Connection", "close" if close else "keep-alive"))
if extra:
out.extend(extra)
return out

基于后端的原始响应头,重写出一份可以发给客户端的头:

  • 剥掉逐跳头
  • 把 Content-Length 换成我们实际要发的长度
  • is_head 时保留原 Content-Length: HEAD 请求不返回 body,但这个头表示”如果这是 GET,body 会有多长”, 客户端依赖它预判资源大小
  • 按需追加 X-WAF-* 头,由调用方通过 extra 传入

send_response()和 send_simple()#

lines = ["HTTP/1.1 %d %s" % (status, reason or "")]
for key, value in headers:
lines.append("%s: %s" % (key, value))
head = ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1", "replace")
sock.sendall(head)
if body:
sock.sendall(body)

把状态行、头、body 依次写回客户端。头部分用 encode("latin-1", "replace") ——和解析层一致,保证任何字节都能原样发出去

body = ("%s\n" % message).encode("utf-8", "replace")
headers = [
("Content-Type", "text/plain; charset=utf-8"),
("Content-Length", str(len(body))),
("Connection", "close" if close else "keep-alive"),
]
send_response(sock, status, reason, headers, body)

发一个最小的纯文本响应,用于解析失败(400)、后端不可达(502)、内部错误(500)等场景

render_block_page()和 _esc()#

hits = list(scan.hits)
if rate_event is not None:
hits.append(rate_event)
rows = []
for h in hits:
if isinstance(h, dict):
rule_id, name, target = h["rule"], h["name"], h["target"]
view, score, matched = h["view"], h["score"], h["matched"]
else:
rule_id, name = h.rule.rid, h.rule.name
target, view, score, matched = h.target, h.view, h.rule.score, h.matched
rows.append(
"<tr>"
"<td class='mono'>%s</td>"
"<td>%s</td>"
"<td class='mono'>%s</td>"
"<td class='mono'>%s</td>"
"<td class='num'>+%d</td>"
"<td class='mono wrap'>%s</td>"
"</tr>" % (
_esc(str(rule_id)), _esc(name), _esc(str(target)),
_esc(str(view)), score, _esc(str(matched)),
)
)
if not rows:
rows.append("<tr><td colspan='6'>(无规则命中,仅因累计分达阈值)</td></tr>")
html = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>403 Forbidden</title>
<style>
body { margin:0; padding:40px 24px; background:#faf9f5;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
color:#2c2c2a; line-height:1.6; }
.card { max-width:900px; margin:0 auto; background:#fff;
border:1px solid #e3e1da; border-radius:12px; padding:28px 32px; }
h1 { font-size:18px; font-weight:500; margin:0 0 4px; color:#8c2d2d; }
.sub { font-size:13px; color:#6b6a66; margin-bottom:20px; }
.req { font-family:ui-monospace,Menlo,monospace; font-size:13px;
background:#f4f2ec; border-radius:6px; padding:10px 12px;
word-break:break-all; margin-bottom:20px; }
table { width:100%%; border-collapse:collapse; font-size:12.5px; }
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid #eeecE5;
vertical-align:top; }
th { font-weight:500; color:#6b6a66; background:#faf9f5; }
.mono { font-family:ui-monospace,Menlo,monospace; }
.num { text-align:right; font-family:ui-monospace,Menlo,monospace;
color:#8c2d2d; white-space:nowrap; }
.wrap { word-break:break-all; max-width:280px; }
.total { margin-top:18px; font-size:13px; }
.total b { color:#8c2d2d; font-size:15px; }
.note { margin-top:22px; padding-top:16px; border-top:1px solid #eeecE5;
font-size:12px; color:#8a8880; }
</style>
</head>
<body>
<div class="card">
<h1>403 Forbidden</h1>
<div class="sub">请求被 WAF 拦截</div>
<div class="req">%s %s</div>
<table>
<thead>
<tr><th>规则</th><th>名称</th><th>片段</th><th>视图</th><th>分值</th><th>命中内容</th></tr>
</thead>
<tbody>
%s
</tbody>
</table>
<div class="total">累计风险分 <b>%d</b> / 阈值 %d</div>
<div class="note">
这是本地沙箱环境的调试页面,用于观察 WAF 的判定依据。<br>
生产部署应关闭规则细节暴露(--quiet-headers),只回一个素净的 403。
</div>
</div>
</body>
</html>""" % (
_esc(req.method), _esc(req.target),
"\n".join(rows),
total_score, cfg.ruleset.threshold,
)
return html.encode("utf-8")

生成拦截页面。它会同时处理两种命中来源 ——scan.hits(Hit 对象)和 rate_event(可能是字典形态),用一个 isinstance(h, dict) 分支统一取值。页面是一张表:规则编号、名称、片段、视图、分值、命中内容

return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;"))

HTML 转义函数,防止命中内容里的尖括号破坏页面结构,防止xss

WAFHandler 类 — 请求处理器#

每个 TCP 连接一个线程。 为什么用 BaseRequestHandler 而不是 http.server 那套:因为我们要自己控制”怎么读报文”这一层。BaseHTTPRequestHandler 会在你拿到请求之前就把请求行和头解析完了(还顺手做了大小写归一和路径解码), 那我们前面写的解析层就白费了。

def handle(self):
cfg = self.server.cfg
self.request.settimeout(cfg.timeout)
reader = SocketReader(self.request)
while True:
try:
req = read_request(reader, self.client_address)
except ProtocolError as exc:
# 报文本身就不合法 —— 这通常意味着攻击者在试探解析器差异。
# 用一个明确的 400 收尾,并且记进日志。
self._log_bad_request(cfg, str(exc))
send_simple(self.request, 400, "Bad Request",
"报文不符合 HTTP 语法:%s" % exc)
return
except (socket.timeout, OSError):
return
if req is None:
return # 对端正常关闭
keep_alive = self._wants_keep_alive(req)
try:
keep_alive = self._process(cfg, req, keep_alive)
except (socket.timeout, OSError, BrokenPipeError):
return
except Exception as exc:
# 兜底:处理链里任何未预期的异常都不该让线程静默死掉,
# 否则你看到的现象是"请求卡住"而不是"报错",极难排查
send_simple(self.request, 500, "Internal Server Error",
"WAF 内部错误:%s: %s" % (type(exc).__name__, exc))
return
if not keep_alive:
return
# ---- 连接复用判断 ----
@staticmethod
def _wants_keep_alive(req):
"""客户端是否希望复用这条 TCP 连接。
HTTP/1.1 默认复用,除非显式写了 Connection: close;
HTTP/1.0 默认不复用,除非显式写了 Connection: keep-alive。
"""
conn = (req.header("connection") or "").lower()
if "close" in conn:
return False
if req.version.upper() == "HTTP/1.0":
return "keep-alive" in conn
return True
# ---- 主处理流程 ----
def _process(self, cfg, req, keep_alive):
started = time.time()
req_id = uuid.uuid4().hex[:12]
client_ip = self.client_address[0]
# ② 提取检测片段(同时触发 ① 的产物:协议层 flags 也变成片段)
payloads = extract_payloads(req)
# ③ 规则匹配 + 评分
scan = cfg.ruleset.scan(payloads)
# ③' 速率维度
rate_event = cfg.limiter.record(client_ip)
total_score = scan.score + (rate_event.score if rate_event else 0)
# ④ 决策
# 白名单优先级最高:命中 allow 规则就直接放行,连速率都不判 ——
# 否则"某个爬自己站点的监控脚本"这类合法高频流量就永远加不进白名单。
if scan.action == "allow":
action = "allow"
elif total_score >= cfg.ruleset.threshold:
action = "block"
elif scan.hits or rate_event is not None:
action = "log"
else:
action = "pass"
# audit 模式:照样完整跑一遍检测,但把"拦截"降级成"记录"。
# 这一条是本项目最实用的功能 —— 接到真实业务上时先跑几天 audit,
# 看看有哪些正常请求会被误判,再去调分数,而不是上线即误杀。
would_block = False
if cfg.mode == "audit" and action == "block":
action = "log"
would_block = True
backend_status = None
backend_error = None
resp_status = None
if action == "block":
# 直接在后端之前掐断:这类请求根本不会到达后端
body = render_block_page(req, scan, rate_event, total_score, cfg)
extra = []
if cfg.expose_rules:
rule_ids = sorted({str(h.rule.rid) for h in scan.hits})
if rate_event is not None:
rule_ids.append("9001")
extra = [
("X-WAF-Action", "blocked"),
("X-WAF-Score", "%d/%d" % (total_score, cfg.ruleset.threshold)),
("X-WAF-Rule-Ids", ",".join(rule_ids)),
]
headers = [
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(body))),
("Connection", "close" if not keep_alive else "keep-alive"),
] + extra
send_response(self.request, 403, "Forbidden", headers, body)
resp_status = 403
keep_alive = False if not keep_alive else keep_alive
else:
# 放行:原样转发
try:
status, reason, headers, rbody = forward_request(cfg, req)
except BackendError as exc:
backend_error = str(exc)
send_simple(self.request, 502, "Bad Gateway",
"后端不可达(%s):%s" % (cfg.backend, exc),
close=not keep_alive)
resp_status = 502
else:
is_head = req.method.upper() == "HEAD"
send_body = b"" if (is_head or status in NO_BODY_STATUS) else rbody
out_headers = build_response_headers(
headers, send_body, close=not keep_alive,
is_head=is_head, status=status)
send_response(self.request, status, reason, out_headers, send_body)
backend_status = status
resp_status = status
elapsed_ms = (time.time() - started) * 1000.0
# ⑤ 审计日志
record = {
"ts": _iso_now(),
"req_id": req_id,
"client": "%s:%d" % self.client_address,
"method": req.method,
"target": req.target,
"host": req.host,
"ua": req.header("user-agent") or "",
"action": action,
"would_block": would_block,
"score": total_score,
"threshold": cfg.ruleset.threshold,
"hits": [h.to_dict() for h in scan.hits],
"rate": rate_event.to_dict() if rate_event else None,
"flags": req.flags,
"body_bytes": len(req.body),
"body_encoding": req.body_encoding,
"backend_status": backend_status,
"backend_error": backend_error,
"resp_status": resp_status,
"elapsed_ms": round(elapsed_ms, 2),
}
cfg.audit_log.write(record)
if cfg.verbose:
self._print_line(action, req, total_score, scan, rate_event,
resp_status, elapsed_ms, would_block)
return keep_alive
# ---- 终端输出 ----
@staticmethod
def _print_line(action, req, total_score, scan, rate_event, resp_status,
elapsed_ms, would_block):
color, label = _action_style(action)
if would_block:
label = "记录(本应拦截)"
detail = ""
if scan.hits:
# 去重后再显示:同一条规则可能在 path 和 query 上同时命中,
# 分数只算一次,终端上也没必要把同一个编号列两遍
detail = " " + ",".join(sorted({str(h.rule.rid) for h in scan.hits}))
elif rate_event is not None:
detail = " rate"
target = req.target
if len(target) > 72:
target = target[:69] + "..."
lines = ["%s[%s]%s %-4s %-3s %s %s分%s %.0fms" % (
color, label, C.RESET,
req.method, resp_status if resp_status else "---",
target, total_score, detail, elapsed_ms)]
# 被拦截时把命中细节展开,这样你不用翻日志就知道为什么被拦
if action == "block" or would_block:
for h in scan.hits:
lines.append(" %s%s%s %s[%s] %r" % (
C.DIM, h.rule.rid, C.RESET,
h.target, h.view, h.matched[:100]))
if rate_event is not None:
lines.append(" %s9001%s %s" % (
C.DIM, C.RESET, rate_event.to_dict()["matched"]))
# -----------------------------------------------------------------
# 一次性写出整个块,而不是每行 print 一次。
# -----------------------------------------------------------------
# 为什么:每个 print 是一次独立的 IO 调用,多线程环境下
# 别的线程随时可能插进来。结果是"某条请求的命中详情"被打印到
# **另一条请求的标题**下面 —— 看起来像 WAF 判错了规则,
# 实际上只是日志交错。这个 bug 在 e2e 测试里真实出现过:
#
# [拦截] /?id=1+union+select+1,2,3 100分 1020
# 2001 query.value[html] '<script>' <- 上一个请求的
#
# sys.stdout.write 是单次调用,CPython 的 TextIOWrapper 内部有锁,
# 所以拼成一个字符串再一次写出就足够了,不需要额外加锁。
sys.stdout.write("\n".join(lines) + "\n")
@staticmethod
def _log_bad_request(cfg, message):
cfg.audit_log.write({
"ts": _iso_now(),
"req_id": uuid.uuid4().hex[:12],
"action": "bad_request",
"error": message,
})
if cfg.verbose:
sys.stdout.write("%s[畸形报文]%s %s\n" % (C.MAGENTA, C.RESET, message))
  • handle():连接级循环。设置超时、建 SocketReader, 然后 while True 反复读请求(支持 keep-alive)。异常分三类处理:
    • ProtocolError → 记日志 + 回 400
    • socket.timeout / OSError → 直接返回
    • _process 里任何未预期异常 → 回 500 并返回。 兜底很重要,否则你看到的现象是”请求卡住”而不是”报错”,极难排查
  • _wants_keep_alive(req):静态方法,判断客户端是否希望 复用连接。HTTP/1.1 默认复用(除非显式 Connection: close), HTTP/1.0 默认不复用(除非显式 keep-alive)
  • _process(cfg, req, keep_alive) —— 整个 WAF 的主流程, 对应文件开头那张流程图:
    1. extract_payloads(req) 拆片段(同时触发解析层的 flag 变成片段)
    2. cfg.ruleset.scan(payloads) 规则匹配 + 评分
    3. cfg.limiter.record(client_ip) 速率维度
    4. total_score = scan.score + rate_score 合并总分
    5. 决策。白名单优先级最高 —— 命中 allow 就直接放行, 连速率都不判,否则”某个爬自己站点的监控脚本”这类合法高频流量就永远 加不进白名单
    6. audit 模式降级 —— 照样完整跑一遍检测, 但把 block 改成 log 并标记 would_block = True。 这是本项目最实用的功能 —— 接到真实业务上时先跑几天 audit, 看看有哪些正常请求会被误判,再去调分数,而不是上线即误杀
    7. 拦截分支 —— 直接在后端之前掐断(这类请求根本不会到达 后端),回 403 + 拦截页,按 expose_rules 决定要不要加 X-WAF-* 头
    8. 放行分支 —— forward_request() 转发, 处理 HEAD / 204 / 304 不返回 body 的情况
    9. 审计日志 —— 组装一个 20 多字段的 record 字典 (时间、req_id、客户端、方法、target、host、UA、动作、would_block、 总分、阈值、hits、rate、flags、body 大小、后端状态、耗时…)写盘
    10. 终端输出
  • _print_line(...):静态方法,格式化一行终端输出。 两个细节:
    • 命中编号先去重再显示 —— 同一条规则可能在 path 和 query 上同时命中,分数只算一次,终端上也没必要把同一个编号列两遍
    • 一次性写出整个块(sys.stdout.write("\n".join(lines) + "\n")), 而不是每行 print 一次。为什么:每个 print 是一次独立的 IO 调用, 多线程下别的线程随时可能插进来。结果是”某条请求的命中详情”被打印到 另一条请求的标题下面 —— 看起来像 WAF 判错了规则,实际上只是日志交错。 这个 bug 在 e2e 测试里真实出现过
  • _log_bad_request(cfg, message):静态方法,把畸形报文 记进审计日志(action = "bad_request")并打印一行

_iso_now()#

from datetime import datetime
return datetime.now().astimezone().isoformat(timespec="milliseconds")

返回带毫秒和时区偏移的 ISO 时间字符串(datetime.now().astimezone().isoformat(timespec="milliseconds"))。比 time.time() 的可读性好得多,日志排查时省事。

WAFServer 类 — 多线程 TCP 服务器#

allow_reuse_address = True
daemon_threads = True
def __init__(self, address, handler_cls, cfg):
self.cfg = cfg
super().__init__(address, handler_cls)
def handle_error(self, request, client_address):
exc = sys.exc_info()[1]
if isinstance(exc, (BrokenPipeError, ConnectionResetError, socket.timeout)):
return
sys.stdout.write("%s[异常]%s 处理 %s 时出错:%s: %s\n" % (
C.MAGENTA, C.RESET, client_address,
type(exc).__name__, exc))
  • 两个类属性:allow_reuse_address = True避免”端口刚被上一个 进程释放,要等 60 秒 TIME_WAIT”这种烦人的启动失败;daemon_threads = True 让主线程退出时工作线程跟着一起死,不留孤儿线程
  • __init__():把 cfg 挂到 self.cfg 上, WAFHandler 就能通过 self.server.cfg 拿到配置
  • handle_error(request, client_address):覆盖默认实现。 默认会把整个 traceback 打到 stderr,而客户端主动断开 (BrokenPipe / ConnectionReset)是家常便饭,刷屏会把真正有用的日志冲掉。 这里只对非预期异常记一行,其余静默

serve(cfg)和 _handle_term()启动 WAF,阻塞运行直到 Ctrl-C 或收到 SIGTERM#

def _handle_term(signum, frame):
raise KeyboardInterrupt
try:
signal.signal(signal.SIGTERM, _handle_term)
except (ValueError, OSError):
# 非主线程里注册信号会抛 ValueError(比如被别的程序嵌入运行时),
# 这不是致命问题,忽略即可
pass
# 先试着连一下后端,把"配错地址"这种错误在启动时就暴露出来,
# 而不是等第一个请求打进来才发现 502
backend_ok, backend_msg = probe_backend(cfg)
if not backend_ok:
print("%s[!]%s 后端 %s 当前不可达:%s" % (C.YELLOW, C.RESET, cfg.backend, backend_msg))
print(" WAF 仍会启动,但所有放行的请求都会返回 502。")
print(" 如果你的靶场还没起来,先把靶场拉起来再访问。")
server = WAFServer((cfg.listen_host, cfg.listen_port), WAFHandler, cfg)
print()
print("=" * 72)
print(" WAF 已启动")
print("=" * 72)
print(" 监听 : http://%s:%d" % (cfg.listen_host, cfg.listen_port))
print(" 后端 : http://%s" % cfg.backend)
print(" 模式 : %s" % ("拦截模式(block)" if cfg.mode == "block"
else "审计模式(audit,只记录不拦截)"))
print(" 拦截阈值 : %d 分" % cfg.ruleset.threshold)
print(" 规则数量 : %d 条" % len(cfg.ruleset.rules))
print(" 审计日志 : %s" % cfg.audit_log.log_dir)
print("=" * 72)
print(" 把攻击脚本的 target 指向监听地址即可观察拦截效果:")
print(" python3 <某个 exploit.py> --target http://%s:%d"
% (cfg.listen_host, cfg.listen_port))
print(" Ctrl-C 退出")
print()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[*] 正在关闭 ...")
finally:
server.shutdown()
server.server_close()
cfg.audit_log.close()
print("[*] 已退出。审计日志目录:%s" % cfg.audit_log.log_dir)

先调 probe_backend在启动时就暴露”配错地址”,而不是等第一个请求打进来才发现 502。然后建服务器、打印一屏启动信息,serve_forever() 阻塞。KeyboardInterrupt 时走 finally:shutdown + server_close + audit_log.close

probe_backend(cfg)探测后端是否可达#

try:
conn = http.client.HTTPConnection(
cfg.backend_host, cfg.backend_port,
timeout=min(cfg.timeout, 3.0))
conn.request("HEAD", "/")
conn.getresponse().read()
conn.close()
return True, "ok"
except Exception as exc:
return False, "%s: %s" % (type(exc).__name__, exc)

探测后端是否可达:发一个 HEAD 请求,返回 (是否可达, 说明)。超时取 min(cfg.timeout, 3.0),因为它只是个启动检查,不该让 WAF 迟迟起不来。

waf/audit.py — 审计日志#

为什么是 JSONL(每行一个 JSON)而不是普通文本: WAF 日志的用途有两个 ——事后复盘和统计分析。普通文本日志做统计要靠正则去刮,很容易被载荷里的特殊字符搞乱。JSONL 的每一行本身就是合法 JSON,于是:

Terminal window
# 看今天所有被拦的请求
python3 -c "
import json
for line in open('logs/waf-2026-10-01.jsonl'):
r = json.loads(line)
if r['action'] == 'block':
print(r['ts'], r['client'], r['target'][:80])
"
# 统计哪条规则命中最多(装了 jq 的话)
jq -r '.hits[].rule' logs/*.jsonl | sort | uniq -c | sort -rn

一行一条还有个工程上的好处:多线程同时写,只要保证”一次 write 一个完整行”, 就不会出现两行日志互相插花的情况。

AuditLog 类#

def __init__(self, log_dir, echo=False):
self.log_dir = log_dir
self.echo = echo
self._lock = threading.Lock()
self._fh = None
self._day = None
os.makedirs(log_dir, exist_ok=True)
def _file_for_today(self):
"""拿到今天的日志文件句柄,跨天时自动切换。"""
day = datetime.now().strftime("%Y-%m-%d")
if self._fh is None or self._day != day:
if self._fh is not None:
self._fh.close()
path = os.path.join(self.log_dir, "waf-%s.jsonl" % day)
self._fh = open(path, "a", encoding="utf-8")
self._day = day
return self._fh
def write(self, record):
"""写入一条审计记录。record 是一个普通 dict。"""
line = json.dumps(record, ensure_ascii=False, default=str)
with self._lock:
fh = self._file_for_today()
fh.write(line + "\n")
# 立刻 flush,而不是攒着。
# 原因:WAF 可能被 kill -9,也可能所在进程崩溃。
# 日志的价值恰恰在这种时刻最高 —— 攻击者把你打挂了,
# 你得能从日志里看出他是怎么打的。
fh.flush()
if self.echo:
print(line)
@property
def path(self):
"""当前正在写的日志文件路径(供启动时打印)。"""
with self._lock:
return os.path.join(self.log_dir, "waf-%s.jsonl" % self._day) if self._day else ""
def close(self):
with self._lock:
if self._fh is not None:
self._fh.close()
self._fh = None
  • __init__(log_dir, echo=False):存目录和 echo 开关, 建一把锁,_fh / _day 先置空,然后 os.makedirs(log_dir, exist_ok=True)
  • _file_for_today():拿到今天的日志文件句柄, 跨天时自动切换(判断 self._day != day)。文件路径是 <log_dir>/waf-YYYY-MM-DD.jsonl
  • write(record):序列化 + 加锁写入 + 立刻 flush。 为什么每次都 flush:WAF 可能被 kill -9,也可能所在进程崩溃, 而日志的价值恰恰在这种时刻最高 —— 攻击者把你打挂了,你得能从日志里 看出他是怎么打的。锁保证”序列化 + 写入 + flush”三步原子完成, 否则两个线程同时写会得到半行拼半行的垃圾
  • path 属性:当前正在写的日志文件路径,供启动时打印
  • close():关闭文件句柄(加锁),由 serve() 的 finally 调用

为什么按天分文件: WAF 日志增长非常快(每个请求一行),单文件几周就能到GB 级,既难查也难删。按天切分后,删旧数据就是删文件,“排查昨天的攻击”只需要看一个文件。这是日志系统的标配做法。

read_records(path)#

with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except ValueError:
continue

逐行读一个 JSONL 日志文件,跳过损坏的行。为什么容忍坏行:日志被kill -9 截断时,最后一行可能是半截 JSON。如果直接 json.loads 整个文件会抛异常,导致你连前面 10 万条正常记录都看不了。实现上就是个生成器,遇到 ValueError 就 continue。

rules/default.rules — 30 条规则逐条#

这里设置了waf的默认规则

格式(每行一条,7 个字段用 | 分隔):
id | 名称 | 检测目标 | 解码视图 | 动作 | 分值 | 正则
检测目标 逗号分隔,可选值:
method uri path query query.key query.value
body body.key body.value body.filename
header.* headers cookie.key cookie.value flag
支持前缀通配(header.*)、全通配(*)
解码视图 raw / url / url2 / url3 / form / utf8 / uni / lower / html / sql / path
动作 block = 计分并可作为拦截依据 / log = 只计分 / allow = 白名单直接放行
正则 必须是最后一个字段(因为正则里常用 | 表示"或")

例子 输入 + 视图

输入: %252e%252e%252fetc%252fpasswd (双重编码的 ../etc/passwd)
raw %252e%252e%252fetc%252fpasswd
url -> %2e%2e%2fetc%2fpasswd
url2 -> ../etc/passwd
url3 -> ../etc/passwd
form -> %2e%2e%2fetc%2fpasswd
utf8 -> %2e%2e%2fetc%2fpasswd
uni -> ../etc/passwd
lower %252e%252e%252fetc%252fpasswd
html -> ../etc/passwd
sql -> ../etc/passwd
path -> ../etc/passwd
输入: 1+union/**/select (SQL 混淆:加号=空格 + 注释插中间)
raw 1+union/**/select
url 1+union/**/select
url2 1+union/**/select
url3 1+union/**/select
form -> 1 union/**/select
utf8 1+union/**/select
uni -> 1 union/**/select
lower 1+union/**/select
html -> 1 union/**/select
sql -> 1 union select ← 只有它把注释也吃掉了
path 1+union/**/select
输入: %3Cimg+src%3Dx+onerror%3D1%3E (百分号编码的 XSS 标签)
raw %3Cimg+src%3Dx+onerror%3D1%3E
url -> <img+src=x+onerror=1>
url2 -> <img+src=x+onerror=1>
url3 -> <img+src=x+onerror=1>
form -> <img src=x onerror=1> ← 加号变空格
utf8 -> <img+src=x+onerror=1>
uni -> <img src=x onerror=1>
lower -> %3cimg+src%3dx+onerror%3d1%3e
html -> <img src=x onerror=1>
sql -> <img src=x onerror=1>
path -> <img+src=x+onerror=1>
输入: &lt;script&gt; \u003cscript\u003e (HTML 实体 + JS 转义)
raw &lt;script&gt; \u003cscript\u003e
url &lt;script&gt; \u003cscript\u003e
url2 &lt;script&gt; \u003cscript\u003e
url3 &lt;script&gt; \u003cscript\u003e
form &lt;script&gt; \u003cscript\u003e
utf8 &lt;script&gt; \u003cscript\u003e
uni -> &lt;script&gt; <script> ← 只有它解 JS 转义
lower &lt;script&gt; \u003cscript\u003e
html -> <script> \u003cscript\u003e ← 只有它解 HTML 实体
sql &lt;script&gt; \u003cscript\u003e
path -> &lt;script&gt; /u003cscript/u003e ← 反斜杠被当成路径分隔符了
输入: UnIoN sElEcT (大小写混淆)
raw / url / url2 / url3 / form / utf8 / uni / html / sql / path —— 全部无变化
lower -> union select ← 只有它变了
  • raw:原样返回,不做任何变换。用来匹配”攻击者 Literally 写出来的东西”,比如编码痕迹本身
  • url:百分号解码 1 次,按 latin-1 解释(字节保真)
  • url2: 百分号解码 2 次。最常用的一层,专治双重编码
  • url3:百分号解码 3 次。层数越多误报越高
  • form:表单语义:先把 + 当空格,再百分号解码 1 次
  • utf8:百分号解码 1 次,按 UTF-8 解释(“后端多半会看到的样子”)
  • uni:展开 JS/JSON 转义:\u003c → <,\x3c → <
  • lower:全小写。专治 UnIoN sElEcT
  • html:展开 HTML 实体:&lt; → <,&#x3c; → <
  • sql 归一化:解码 + 吃掉注释 + 折叠空白
  • path:路径归一化:解码 2 次 + 反斜杠转斜杠 + 折叠重复斜杠

1000 段 —— 注入类(路径穿越 / SQL / 命令 / SSRF)#

  • 1001 路径穿越 · 明文多级回溯|path 视图|block|70 分 在 path 视图找真正的 ../。因为 path 视图已经把 %2e、%252e、 反斜杠、重复斜杠全归一了,所以 . %2e/%2e%2e/ 和 ..%2f..%2f 到这里 都变成明文 ../../。这一条正是用来抓 CVE-2021-41773 那类多级回溯的

  • 1002 路径穿越 · 编码点号痕迹|raw 视图|log|35 分 和 1001 是同一个攻击的两个视角:1001 看”解完之后长什么样”, 1002 看”原文里有没有编码痕迹”。两个视角互补 —— 有些手法解码后会混进 正常文本看不出,但原文里留着明显的 %2e。单独 35 分不拦,和 1001 叠加过线

  • 1010 敏感文件访问 · 系统文件|path 视图|block|100 分(一票否决) 直接点名系统文件(/etc/passwd、/proc/self、~/.ssh 等)。正常业务不会 去读这些,特征完全由攻击者控制,正常流量里出现概率低于万分之一

  • 1011 危险协议包装器 · 攻击类|url2 视图|block|100 分 php://filter、data://、expect://、gopher:// 等。这些是任意文件读取 / RCE / SSRF 链里最常用的”跳板”。注意 data: 只匹配带 // 的写法, 不带 // 的 data:text/html 由 2003 号 XSS 规则负责,两条规则分工不重叠

  • 1012 危险协议包装器 · file 协议|url2 视图|log|55 分 file:// 单独拆出来给较低分。原因:用户在自己的输入框里粘贴 file:///Users/... 是完全正常的操作,而 php://filter 这种只可能出现在 攻击载荷里。混在一条规则里要么漏报要么误报,拆开是唯一正确的做法

  • 1020 SQL 注入 · UNION 查询|sql 视图|block|100 分 sql 视图已经吃过注释、折叠过空白、解过编码,所以 un/**/ion sel/**/ect、%75nion%20select 到这里都是 union select。 正常业务参数里出现它几乎不可能,一票否决

  • 1021 SQL 注入 · 恒真条件|sql 视图|block|55 分 布尔盲注的经典判据:or 1=1、' or '1'='1'。两段分别覆盖”无引号”和 “带引号”。必须要求前后有 or/and —— 单纯的 1=1 在正常业务里太常见 (版本区间、参数校验),不能作为独立信号

  • 1022 SQL 注入 · 危险函数|sql 视图|log|45 分 时间盲注(sleep/benchmark/pg_sleep)和报错注入(updatexml)的 标志性函数,顺带覆盖 load_file、into outfile 这类读写文件的手法

  • 1023 SQL 注入 · 元信息探测|sql 视图|log|35 分 information_schema、sysobjects、sqlite_master 等。查数据库结构是 拿到注入点后的下一步动作

  • 1024 SQL 注入 · 引号闭合特征|sql 视图|log|45 分 引号紧随 SQL 关键字,说明攻击者正在拼接语句。单独不够(程序员传 SQL 片段 也会这样),但和 UNION 规则叠加就很确定

  • 1030 命令注入 · 命令串联|url2 视图|block|60 分 shell 元字符后直接跟命令名。视图用 url2 而不是 raw —— 攻击者会把 分号编码成 %3B、空格编码成 %20,在 raw 视图上这些还都是 %XX。 **结尾的 (?:\s|$|[;&|),])是关键**,两处细节都是踩过坑写出来的: 必须有"边界要求"否则?id=1里的id会被当成命令注入;字符类里必须带上)否则$(whoami)` 匹配不上

  • 1031 命令注入 · 命令替换|url2 视图|log|45 分 $(...) 和反引号是绕过关键词过滤的常见写法

  • 1040 SSRF · 内网地址探测|url2 视图|log|45 分 参数里出现指向内网/回环的 URL。169.254.169.254 是云厂商元数据服务地址, SSRF 拿云凭证必打这个

2000 段 —— XSS / HTML 注入#

  • 2001 XSS · script 标签|html 视图|block|70 分 html 视图已展开实体并做过 URL 解码,所以 &lt;script&gt;、 %3Cscript%3E 到这里都还原成 <script>

  • 2002 XSS · 内联事件处理器|html 视图|block|60 分 onerror / onload 这类。注意这个模式在正常 HTML 里也出现 (富文本编辑器),所以是”强信号”但不是”一票否决”

  • 2003 XSS · 危险伪协议|html 视图|block|55 分 javascript: / vbscript: / data:text/html

  • 2004 XSS · 危险标签|html 视图|block|55 分 iframe / svg / object / embed 等可执行或可嵌入内容的标签

  • 2005 XSS · DOM 劫持特征|html 视图|log|30 分 往页面注入带 id/name 的元素,顶替页面本不存在的全局变量 —— CVE-2026-64638 走的就是这条路。单看 id=xxx 太常见,所以限定在 “HTML 标签注入”的上下文里,且只认几个典型的被劫持目标名(ajaxurl、 nonce、document 等),给 30 分做纯辅助信号

  • 2006 HTML 注入 · 标签带属性|html 视图|log|45 分 凭空造出一个带属性的 HTML 标签

  • 2007 XSS · 弹窗与危险函数|html 视图|log|45 分 alert( / eval( / atob( 等,也就是载荷的执行部分。单独出现可能只是 代码片段(技术论坛的帖子),所以和标签规则配合使用

  • 2008 XSS · 危险标签携带外链|html 视图|log|50 分 iframe/svg 等标签带 src 或 href —— 这是完整的利用形态

  • 2009 HTML 注入 · 尖括号后接分隔符|html 视图|log|40 分 抓 < area、%3C%20div 这类”尖括号和标签名之间隔了空白”的写法。 它抓的不是某个具体恶意标签,而是”解析器可能产生分歧的写法” —— 有的解析器认为 < area 是标签,有的认为是纯文本,攻击者利用这个分歧 让 WAF 放行、让浏览器执行。CVE-2026-64638 整条链的起点就是尖括号后面 那一个空格

  • 2010 模板/表达式注入|url2 视图|log|50 分 Spring SpEL(${...})、Freemarker、Velocity、EL 的常见起始符

3000 段 —— HTTP 协议层#

这三条的检测目标是 flag —— 也就是解析层发现的协议异常。把协议异常也做成 “可匹配的片段”,好处是协议层检测能复用同一套规则引擎。

  • 3001 请求走私 · CL 与 TE 并存|raw 视图|block|100 分 同时出现 Content-Length 和 Transfer-Encoding。不同中间件对”以谁为准” 处理不一致,这个分歧就是走私的成因。合法客户端几乎不会两个都发 —— 一票否决

  • 3002 请求走私 · 重复 Content-Length|raw 视图|block|100 分 同一个请求里出现多个值不同的 Content-Length

  • 3003 请求走私 · 畸形 Transfer-Encoding|raw 视图|block|100 分 Transfer-Encoding 的值不是标准写法(比如 chunked, chunked), 目的都是让不同解析器得出不同分块方式

  • 3010 扫描器指纹 · 工具 UA|lower 视图|log|35 分 匹配 sqlmap、nmap、nuclei 等工具名。给 35 分而不是 100 是刻意的 —— 这条规则体现了一个重要教训:任何”特征完全由攻击者控制”的检测都不可靠。 攻击者改一个字节就绕过。但它在日志分析上很有价值, 所以留着,但绝不让它单独构成拦截

  • 3020 可疑方法 · TRACE(XST 攻击)|lower 视图|block|100 分 TRACE 会把请求原样回显给调用方,配合 XSS 可以读走带 HttpOnly 的 Cookie。 正常业务不用这个动词

4000 段 —— 白名单#

白名单命中会直接短路放行,后面的规则一条都不跑。这是降低误报最有效的 手段,接入真实业务时必须认真配。一条经验:白名单要写得窄 —— 像 “allow if User-Agent 含 Mozilla” 这种宽条件等于把 WAF 关掉。

  • 4001 白名单 · 健康检查与探活|raw 视图|allow|0 分 放行 /health、/healthz、/ping、/status、/readyz、/livez

  • 4002 白名单 · 静态资源|raw 视图|allow|0 分 放行常见静态资源扩展名。收益有两个:一是这类文件里出现 onerror= 之类 字样是正常的(压缩后的 JS 就是这样),能省掉一批误报;二是省掉对它们的 解码和正则开销。扩展名列表里刻意不含 php/jsp/asp 等可执行后缀 —— 免得有人上传 shell.jsp 之后被白名单放过去

9000 段 —— 由程序实现的检测#

速率限制(单 IP 请求过量)没有写在这里,因为它的判定不基于文本匹配, 而是基于”单位时间内的请求次数”。实现在 waf/ratelimit.py,参数通过命令行给: --rate-limit / --rate-window / --rate-score。日志里它固定用 id 9001, 方便和规则命中一起统计。

tools/selftest.py — 内存自检#

为什么要有这个脚本:WAF 最怕的不是”拦不住”,而是你不知道它拦不住。改一条规则的正则、调一个分值,很可能会顺手弄坏另一条规则的效果。如果每次要起靶场、跑 PoC、看日志才能验证,没人会愿意改规则。这个脚本把这套验证压缩成”跑一条命令”,同时扮演两个角色:回归测试和文档(每个用例都写明了”为什么这个请求该被拦/放行”)。

注意它直接用内存里的 RawRequest 对象,不经过 socket —— 测的是”检测 逻辑”本身;网络层、转发、超时交给 e2e_test.py。

make_request(method, target, body, headers, flags)直接构造一个 RawRequest#

headers = headers or {}
header_list = [("Host", "target.local")]
# 如果调用方自己给了 User-Agent,就别再塞默认的,
# 否则会出现两个 UA 头,而 header() 只会返回第一个
has_ua = any(k.lower() == "user-agent" for k in headers)
if not has_ua:
header_list.append(("User-Agent", DEFAULT_UA))
for k, v in headers.items():
header_list.append((k, v))
return RawRequest(
method=method,
target=target,
version="HTTP/1.1",
header_list=header_list,
body=body if isinstance(body, bytes) else body.encode("utf-8"),
client=("10.0.0.1", 44444),
flags=flags or [],
)
# CVE-2026-64638 的真实载荷(用户名里塞 HTML 注入)
XSS2SHELL_PAYLOAD = (
"< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click>"
"< div id=color-picker class=reset-pass-submit>"
'< button class="wp-generate-pw color-option">X'
)
  • headers 的键按原样保留大小写 —— 因为解析层本来就是保原样的,“content-type” 和 “Content-Type” 在真实流量里都合法。

  • 补user-agent,如果调用方自己给了 UA 就别再塞默认的,否则会出现两个 UA 头,而 header() 只会返回第一个

case(desc, expect, **kw)一条用例:说明 + 期望动作 + 请求参数#

return {"desc": desc, "expect": expect, **kw}
# =============================================================================
# 用例表
# =============================================================================
NORMAL_CASES = [
case("首页", "pass"),
case("普通页面", "pass", target="/index.html"),
case("带查询参数的搜索", "pass", target="/?q=hello+world&page=2"),
case("REST 接口取数据", "pass", target="/api/user?id=123"),
case("含撇号的人名(不该被当成 SQL 注入)", "pass", target="/?name=O'Brien"),
case("外链跳转(不该被当成 SSRF)", "pass",
target="/?redirect=" + urllib.parse.quote("https://example.com/page", safe="")),
case("表单登录", "pass", method="POST", target="/login",
body=b"username=admin&password=s3cr3t",
headers={"Content-Type": "application/x-www-form-urlencoded"}),
case("JSON 评论提交", "pass", method="POST", target="/api/comment",
body=b'{"post": 42, "comment": "Great article, thanks!"}',
headers={"Content-Type": "application/json"}),
case("价格区间查询(不该被当成恒真条件)", "pass", target="/?min=100&max=500"),
case("正文里出现未闭合的数学小于号", "pass", target="/?expr=x+%3C+b"),
case("健康检查 → 白名单放行", "allow", target="/health"),
case("静态 JS → 白名单放行", "allow", target="/static/app.js"),
case("图片 → 白名单放行", "allow", target="/uploads/pic.png"),
]
ATTACK_CASES = [
# ---- 路径穿越 ----
case("CVE-2021-41773 路径穿越(.%2e / %2e%2e 混合编码)", "block",
target="/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd"),
case("明文目录遍历读系统文件", "block",
target="/?file=" + urllib.parse.quote("../../../../etc/passwd", safe="")),
case("双重编码穿越(%252e)", "block", target="/?file=%252e%252e%252fetc/passwd"),
case("只读敏感文件、不带穿越", "block", target="/?p=/etc/passwd"),
# ---- SQL 注入 ----
case("SQL 注入 · UNION 查询", "block", target="/?id=1+union+select+1,2,3"),
case("SQL 注入 · UNION 带引号闭合", "block",
target="/?id=1%27%20union%20select%201,2,3--"),
case("SQL 注入 · 布尔恒真条件", "block",
target="/?id=1%27%20or%20%271%27%3D%271"),
case("SQL 注入 · 注释混淆绕过", "block",
target="/?id=1+union%2f**%2fselect+1,2,3"),
# ---- XSS ----
case("XSS · script 标签(明文)", "block", target="/?q=<script>alert(1)</script>"),
case("XSS · script 标签(URL 编码)", "block",
target="/?q=%3Cscript%3Ealert(1)%3C/script%3E"),
case("XSS · 事件处理器", "block",
target="/?q=%3Cimg+src%3Dx+onerror%3Dalert(1)%3E"),
case("XSS · javascript 伪协议", "block", target="/?next=javascript:alert(1)"),
# ---- 命令注入 / 文件包含 ----
case("命令注入 · 分号串联命令", "block",
target="/?cmd=%3Bcat%20/etc/passwd"),
case("命令注入 · $() 命令替换", "block", target="/?host=%24(whoami)"),
case("本地文件包含 · php://filter 读源码", "block",
target="/?f=php://filter/convert.base64-encode/resource=index.php"),
# ---- 协议层 ----
case("请求走私 · CL 与 TE 并存", "block", method="POST", target="/",
body=b"a=1", flags=["cl_te_conflict"]),
case("请求走私 · 重复 Content-Length", "block", method="POST", target="/",
body=b"a=1", flags=["cl_conflict"]),
case("TRACE 方法(XST 攻击)", "block", method="TRACE"),
# ---- 其他 ----
case("CVE-2026-64638 XSS2Shell 载荷", "block", method="POST",
target="/wp-login.php",
body=urllib.parse.urlencode({"log": XSS2SHELL_PAYLOAD, "pwd": "x"}).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"}),
case("扫描器指纹(只记日志、不拦)", "log", target="/",
headers={"User-Agent": "sqlmap/1.7#stable (https://sqlmap.org)"}),
case("模板注入 · SpEL 表达式", "log",
target="/?name=" + urllib.parse.quote("${7*7}", safe="")),
]
  • NORMAL_CASES(13 条):应当放行的正常流量。这些用例的 价值在于防误报,而且每条都标注了”为什么它不该被拦”:

    • 首页、普通页面、带查询参数的搜索、REST 取数据
    • 含撇号的人名(O'Brien)—— 不该被当成 SQL 注入
    • 外链跳转 —— 不该被当成 SSRF
    • 表单登录、JSON 评论提交
    • 价格区间查询(min=100&max=500)—— 不该被当成恒真条件
    • 正文里未闭合的数学小于号(expr=x < b)—— 不该被当成 XSS
    • 健康检查 / 静态 JS / 图片 —— 应当命中白名单(期望 allow)
  • ATTACK_CASES(21 条):应当拦截的攻击流量,按类型分组 —— 路径穿越(含 CVE-2021-41773 那条混合编码)、SQL 注入(UNION / 引号闭合 / 恒真 / 注释混淆)、XSS(明文 / URL 编码 / 事件处理器 / 伪协议)、 命令注入、文件包含、协议层(走私 flag / TRACE)、CVE-2026-64638 真实载荷。 最后两条期望 log 而不是 block,用来验证”弱信号只记录不拦”这个设计

run_case(ruleset, c)#

把用例变成请求 → 拆片段 → 扫规则,返回 (scan, payloads)

req = make_request(
method=c.get("method", "GET"),
target=c.get("target", "/"),
body=c.get("body", b""),
headers=c.get("headers"),
flags=c.get("flags"),
)
payloads = extract_payloads(req)
scan = ruleset.scan(payloads)
return scan, payloads

main()#

跑两张用例表,逐条打印[ok] / [!!]、实际动作、分值、命中规则。不通过时额外打印scan.explain() 和每条命中的细节 —— 这样你一眼就知道是哪条规则的问题。之后还有两段:速率限制单独测和归一化视图演示(打印同一载荷在 raw/url/url2/path/form/sql各视图下的样子)

ruleset = RuleSet.from_files([RULES_DIR], threshold=THRESHOLD)
total = 0
failed = 0
for title, cases, kind in (
("正常流量(应当放行)", NORMAL_CASES, "normal"),
("攻击流量(应当拦截)", ATTACK_CASES, "attack"),
):
print()
print("=" * 78)
print(" %s" % title)
print("=" * 78)
for c in cases:
total += 1
scan, _ = run_case(ruleset, c)
actual = scan.action
ok = (actual == c["expect"])
if not ok:
failed += 1
mark = "[ok]" if ok else "[!!]"
rules = ",".join(sorted({str(h.rule.rid) for h in scan.hits})) or "-"
print("%s %-5s %-34s %3d分 规则=%s" % (
mark, actual, _clip(c["desc"], 34), scan.score, rules))
if not ok:
print(" 期望 %s,实际 %s —— %s" % (
c["expect"], actual, scan.explain()))
for h in scan.hits:
print(" 命中 %s [%s] 视图=%s 内容=%r" % (
h.rule.rid, h.target, h.view, h.matched[:90]))
# ---- 速率限制单独测 ----
print()
print("=" * 78)
print(" 速率限制(行为维度,不走正则规则)")
print("=" * 78)
total += 1
limiter = RateLimiter(window=10.0, limit=5, score_per_excess=10, max_score=90)
events = []
for i in range(12):
ev = limiter.record("10.0.0.9", now=1000.0 + i * 0.01)
if ev:
events.append(ev)
if events:
print("[ok] 12 次请求触发 %d 次限流,首次触发时 %d 次/5 上限,+%d 分" % (
len(events), events[0].count, events[0].score))
else:
failed += 1
print("[!!] 12 次请求没有触发限流 —— 检查 RateLimiter")
# ---- 归一化视图演示 ----
print()
print("=" * 78)
print(" 归一化视图演示:同一个载荷在各视图下长什么样")
print("=" * 78)
demo = Payload("path", "/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd")
print(" 原始输入: /icons/.%%2e/%%2e%%2e/%%2e%%2e/%%2e%%2e/etc/passwd")
print()
for view in ("raw", "url", "url2", "path", "form", "sql"):
print(" %-6s -> %s" % (view, demo.view(view)))
print()
print(" 说明:规则 1001 在 path 视图上找 '../',所以无论攻击者用")
print(" .%2e 还是 %252e,最终都会在这里露出明文。")
print()
print("=" * 78)
if failed:
print(" 结果:%d/%d 通过,%d 个未通过" % (total - failed, total, failed))
else:
print(" 结果:全部 %d 个用例通过" % total)
print("=" * 78)
return 1 if failed else 0

_clip(s, n)#

return s if len(s) <= n else s[:n - 1] + "…"
if __name__ == "__main__":
sys.exit(main())

截断辅助,用于对齐输出列宽

tools/e2e_test.py — 端到端测试#

和 selftest 的区别:e2e_test.py 起真的后端进程和真的 WAF 进程,用 socket 发真的 HTTP 请求,测的是整条链路。两者都需要 —— 检测逻辑全对但 转发时把路径改坏了,WAF 一样是废的。

selftest.py:直接在内存里构造 RawRequest,测的是检测逻辑

e2e_test.py:起真的后端进程和真的 WAF 进程,用 socket 发真的 HTTP 请求,测的是整条链路:解析、检测、转发、响应、日志落盘。

它回答三个问题:正常请求能不能被正确转发?(不误伤);攻击请求能不能被拦下? (不回源);放行的请求,后端收到的是不是原样?(有没有被偷偷规范化)。

check(desc, ok, detail)把结果记进全局 results 列表并打印一行#

results.append((desc, ok, detail))
mark = "[ok]" if ok else "[!!]"
line = "%s %s" % (mark, desc)
if detail:
line += " —— %s" % detail
print(line)

wait_port(port, timeout=15.0)等端口能连上,说明进程起来了#

deadline = time.time() + timeout
while time.time() < deadline:
try:
sock = socket.create_connection(("127.0.0.1", port), timeout=0.5)
sock.close()
return True
except OSError:
time.sleep(0.1)
return False

轮询尝试连接端口,用来判断子进程起来了没有。验证服务真的在监听”的正规做法。

raw_request(method, target, headers, body, host, timeout)用 putrequest 发一个”路径原样透传”的请求#

conn = http.client.HTTPConnection("127.0.0.1", WAF_PORT, timeout=timeout)
try:
conn.putrequest(method, target, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", host or ("127.0.0.1:%d" % WAF_PORT))
conn.putheader("User-Agent", "waf-lab-e2e/1.0")
for key, value in (headers or {}).items():
conn.putheader(key, value)
if body is not None:
raw = body if isinstance(body, bytes) else body.encode()
conn.putheader("Content-Length", str(len(raw)))
conn.endheaders()
conn.send(raw)
else:
conn.endheaders()
resp = conn.getresponse()
data = resp.read()
return resp.status, dict((k.lower(), v) for k, v in resp.getheaders()), \
data.decode("utf-8", "replace")
finally:
conn.close()
# =============================================================================
# 测试主体
# =============================================================================
NORMAL_CASES = [
("正常首页", "GET", "/", None, None),
("普通页面", "GET", "/index.html", None, None),
("带参数查询", "GET", "/search?q=hello&page=2", None, None),
("POST 表单", "POST", "/login", {"Content-Type": "application/x-www-form-urlencoded"},
"username=admin&password=s3cr3t"),
]
ATTACK_CASES = [
("CVE-2021-41773 路径穿越", "GET",
"/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd", None, None),
("XSS script 标签", "GET", "/?q=%3Cscript%3Ealert(1)%3C/script%3E", None, None),
("SQL 注入 UNION", "GET", "/?id=1+union+select+1,2,3", None, None),
("命令注入", "GET", "/?cmd=%3Bcat%20/etc/passwd", None, None),
("TRACE 方法", "TRACE", "/", None, None),
]
# 这些路径看起来"有编码痕迹"但不构成攻击,必须原样转发过去。
# 如果 WAF 把它们规范化了,说明转发层动了手脚。
PASSTHROUGH_CASES = [
("编码过的点号(非穿越)", "/a%2eb"),
("重复斜杠", "//double//slash"),
("大小写混合路径", "/CaseSensitive/PATH"),
("百分号编码的斜杠(非穿越)", "/dir%2fname"),
]

为什么不用urllib.request:它会帮我们规范化 URL(把 %2e 转成 .、把 //合成 /),那我们就没法测”WAF 是否原样转发”了

  • NORMAL_CASES:正常首页、普通页面、带参数查询、POST 表单
  • ATTACK_CASES:路径穿越、XSS、SQL 注入、命令注入、TRACE
  • PASSTHROUGH_CASES:这组最关键 —— “编码过的点号”、 “重复斜杠”、大小写混合路径、%2f。这些路径看起来”有编码痕迹”但不构成攻击, 必须原样转发过去。如果 WAF 把它们规范化了,说明转发层动了手脚

main()#

print("=" * 78)
print(" 端到端冒烟测试")
print("=" * 78)
print(" 后端 : 127.0.0.1:%d(回显后端)" % BACKEND_PORT)
print(" WAF : 127.0.0.1:%d" % WAF_PORT)
print()
# ---- 起后端 ----
backend = subprocess.Popen(
[sys.executable, os.path.join(HERE, "echo_backend.py"), str(BACKEND_PORT)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not wait_port(BACKEND_PORT):
print("[!!] 回显后端没起来,测试中止")
backend.terminate()
return 1
# ---- 起 WAF ----
waf_out = open(WAF_LOG, "w", encoding="utf-8")
waf = subprocess.Popen(
[sys.executable, os.path.join(ROOT, "run.py"),
"--backend", "127.0.0.1:%d" % BACKEND_PORT,
"--listen", "127.0.0.1:%d" % WAF_PORT,
"--log-dir", LOG_DIR,
"--no-color"],
stdout=waf_out, stderr=subprocess.STDOUT, cwd=ROOT)
try:
if not wait_port(WAF_PORT):
print("[!!] WAF 没起来,输出如下:")
waf_out.close()
with open(WAF_LOG, encoding="utf-8") as fh:
print(fh.read())
return 1
print()
print("-" * 78)
print(" 1. 正常流量:应当放行并正确转发")
print("-" * 78)
for desc, method, target, headers, body in NORMAL_CASES:
try:
status, hdrs, text = raw_request(method, target, headers, body)
ok = status == 200 and "RAW_REQUEST_LINE" in text
check(desc, ok, "HTTP %d" % status)
except Exception as exc:
check(desc, False, "%s: %s" % (type(exc).__name__, exc))
print()
print("-" * 78)
print(" 2. 攻击流量:应当在 WAF 层被拦下(后端收不到)")
print("-" * 78)
for desc, method, target, headers, body in ATTACK_CASES:
try:
status, hdrs, text = raw_request(method, target, headers, body)
blocked = status == 403
# 再确认后端确实没收到:拦截响应里不该出现回显后端的标记
not_reached = "X-Echo-Backend" not in hdrs
check(desc, blocked and not_reached,
"HTTP %d,规则=%s" % (status, hdrs.get("x-waf-rule-ids", "-")))
except Exception as exc:
check(desc, False, "%s: %s" % (type(exc).__name__, exc))
print()
print("-" * 78)
print(" 3. 透明转发:放行的请求必须原样到达后端,字节都不能改")
print("-" * 78)
for desc, target in PASSTHROUGH_CASES:
try:
status, hdrs, text = raw_request("GET", target)
# 从回显里抠出后端看到的 request-target。
# 回显行的格式是 "RAW_REQUEST_LINE: GET /a%2eb HTTP/1.1",
# 所以先切掉前缀,再取第 2 个空格分隔的字段。
# (这里踩过一次坑:直接 split(" ", 2)[1] 取到的是方法名 GET)
seen = ""
for line in text.splitlines():
if line.startswith("RAW_REQUEST_LINE:"):
parts = line[len("RAW_REQUEST_LINE:"):].split()
if len(parts) >= 2:
seen = parts[1]
break
ok = status == 200 and seen == target
check(desc, ok, "发出 %r -> 后端看到 %r" % (target, seen))
except Exception as exc:
check(desc, False, "%s: %s" % (type(exc).__name__, exc))
print()
print("-" * 78)
print(" 4. 连接复用:同一个连接连发两个请求")
print("-" * 78)
try:
conn = http.client.HTTPConnection("127.0.0.1", WAF_PORT, timeout=6)
ok = True
for i in (1, 2):
conn.request("GET", "/keepalive-%d" % i,
headers={"Host": "127.0.0.1"})
resp = conn.getresponse()
body = resp.read().decode("utf-8", "replace")
if resp.status != 200 or ("/keepalive-%d" % i) not in body:
ok = False
break
conn.close()
check("同一 TCP 连接处理两个请求", ok)
except Exception as exc:
check("同一 TCP 连接处理两个请求", False,
"%s: %s" % (type(exc).__name__, exc))
finally:
waf.terminate()
backend.terminate()
time.sleep(0.3)
waf_out.close()
# ---- 检查审计日志 ----
print()
print("-" * 78)
print(" 5. 审计日志落盘")
print("-" * 78)
log_files = sorted(f for f in os.listdir(LOG_DIR)
if f.startswith("waf-") and f.endswith(".jsonl"))
if not log_files:
check("生成 JSONL 日志文件", False, "logs/ 下没有 waf-*.jsonl")
else:
sys.path.insert(0, ROOT)
from waf.audit import read_records
latest = os.path.join(LOG_DIR, log_files[-1])
records = list(read_records(latest))
check("生成 JSONL 日志文件", True,
"%s(%d 条记录)" % (log_files[-1], len(records)))
blocked = [r for r in records if r.get("action") == "block"]
check("日志里记录了拦截事件", bool(blocked),
"拦截 %d 条" % len(blocked))
if blocked:
sample = blocked[0]
check("拦截记录含规则命中详情",
bool(sample.get("hits")) and bool(sample["hits"][0].get("rule")),
"示例:规则 %s 在 %s 片段上命中" % (
sample["hits"][0]["rule"], sample["hits"][0]["target"]))
# ---- 汇总 ----
failed = [r for r in results if not r[1]]
print()
print("=" * 78)
if failed:
print(" 结果:%d/%d 通过,%d 个未通过" % (
len(results) - len(failed), len(results), len(failed)))
for desc, _, detail in failed:
print(" [!!] %s %s" % (desc, detail))
else:
print(" 结果:全部 %d 项通过" % len(results))
print("=" * 78)
print(" WAF 进程输出已保存到:%s" % os.path.relpath(WAF_LOG, ROOT))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
  1. 起回显后端(子进程),wait_port 确认起来了
  2. 起 WAF(子进程,输出重定向到 logs/e2e-waf-output.txt), wait_port 确认;没起来就把 WAF 输出打出来方便排查
  3. 第一组 —— 正常流量应返回 200 且响应里含 RAW_REQUEST_LINE(证明到了回显后端)
  4. 第二组 —— 攻击流量应返回 403,并且响应里不该出现 X-Echo-Backend 头(证明后端确实没收到)
  5. 第三组 —— 透明转发。从回显里抠出后端看到的 request-target,和发出的比对。这里踩过一个坑: 回显行格式是 RAW_REQUEST_LINE: GET /a%2eb HTTP/1.1, 直接 split(" ", 2)[1] 取到的是方法名 GET,要先切掉前缀再取
  6. 第四组 —— 同一个 TCP 连接连发两个请求,验证 keep-alive
  7. finally 里 terminate() 两个子进程
  8. 第五组 —— 检查审计日志确实落盘、里面有 block 记录、 且记录里含规则命中详情
  9. 汇总 —— 全过打印”全部 N 项通过”,否则列出失败项

tools/echo_backend.py — 纯 socket 回显后端#

为什么不用 http.server 那套现成的:因为这次要验证的是”WAF 有没有偷偷 改动请求”,而 http.server 自己就会改。

客户端发出 GET //double//slash HTTP/1.1
http.server 看到 self.path == '/double//slash' <- 少了一个斜杠!

原因是 BaseHTTPRequestHandler 用 urlsplit 解析请求行,而 urlsplit('//double//slash') 会把开头的 // 当成”网络位置分隔符”。 这就很要命:如果拿它当验证工具,WAF 明明忠实转发了,你却会看到一个 “被改过”的路径,然后去 WAF 的代码里找一个根本不存在的 bug(详见证坑 7)。

所以这个后端用最原始的方式实现:自己 readline 读请求行,原样保留, 不做任何 urlsplit / 解码 / 路径规范化。它唯一做的事情就是把收到的字节 原封不动地报出来。

EchoHandler 类#

  • handle():每个连接一个线程,可连续处理多个请求 (支持 keep-alive)。用 sock.makefile("rb") 简化读行。读请求行、读头、 按 Content-Length 读体,然后组装回显内容:
    • RAW_REQUEST_LINE: —— 后端的原始请求行
    • HEADER: —— 每个头
    • BODY(n): —— 正文
    • BACKEND_RAW_TARGET: —— 后端自己解析出的路径部分。 如果这一行和 RAW_REQUEST_LINE 里的不一致,就说明有中间层动了手脚 最后按 Connection 头决定是否复用连接
def handle(self):
sock = self.request
sock.settimeout(10.0)
rfile = sock.makefile("rb")
while True:
try:
request_line = rfile.readline(65536)
except OSError:
return
if not request_line:
return
request_line = request_line.rstrip(b"\r\n")
if not request_line:
continue
# ---- 读头 ----
headers = []
while True:
line = rfile.readline(65536)
if not line:
return
line = line.rstrip(b"\r\n")
if not line:
break
headers.append(line.decode("latin-1"))
# ---- 读体(只看 Content-Length;chunked 在测试里用不到)----
length = 0
for h in headers:
if h.lower().startswith("content-length:"):
try:
length = int(h.split(":", 1)[1].strip())
except ValueError:
length = 0
break
body = rfile.read(length) if length > 0 else b""
# ---- 组装回显内容:把收到的东西**原样**列出来 ----
lines = ["RAW_REQUEST_LINE: %s" % request_line.decode("latin-1")]
for h in headers:
lines.append("HEADER: %s" % h)
if body:
lines.append("BODY(%d): %r" % (len(body), body[:400]))
# 顺便回报一下"后端自己的解析结果",方便对比 ——
# 如果这一行和 RAW_REQUEST_LINE 不一致,就说明有中间层动了手脚
try:
path_part = request_line.decode("latin-1").split(" ")[1]
except IndexError:
path_part = ""
lines.append("BACKEND_RAW_TARGET: %s" % path_part)
payload = ("\n".join(lines) + "\n").encode("utf-8")
# ---- 决定是否复用连接 ----
keep_alive = True
for h in headers:
if h.lower().startswith("connection:"):
if "close" in h.lower():
keep_alive = False
break
head = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"X-Echo-Backend: yes\r\n"
"Content-Length: %d\r\n"
"Connection: %s\r\n"
"\r\n" % (len(payload), "keep-alive" if keep_alive else "close")
).encode("latin-1")
try:
sock.sendall(head + payload)
except OSError:
return
if not keep_alive:
return

EchoServer 类 和 main()#

  • EchoServer:多线程 TCP 服务器,同样的 allow_reuse_address 和daemon_threads
allow_reuse_address = True
daemon_threads = True
  • handle_error():整个覆盖成 pass ——客户端半路断开是常态,别刷屏
pass
  • main():端口取 sys.argv[1](默认 8080),启动提示写 stderr,然后 serve_forever()
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
server = EchoServer(("127.0.0.1", port), EchoHandler)
sys.stderr.write("[echo_backend] listening on 127.0.0.1:%d\n" % port)
sys.stderr.flush()
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()

.gitignore — 让哪些文件不进版本库#

  • __pycache__/、*.pyc、*.pyo —— Python 字节码缓存
  • logs/*.jsonl、logs/*.txt —— 审计日志。体积大,且含真实请求内容, 不该进版本库(可能含 Cookie、令牌、攻击载荷)
  • .DS_Store、.idea/、.vscode/ —— 编辑器与系统文件

为什么日志要单独排除:WAF 日志里会有完整的请求 target、UA、命中内容, 很可能包含别人的敏感信息。一个安全工具把自己的运行日志提交到公开仓库, 是比代码 bug 更尴尬的事故。


详细讲解一下规则#

字段解析#

  • id —— 编号,会进审计日志。建议按段位划分功能块 (1000 段注入 / 2000 段 XSS / 3000 段协议层 / 4000 段白名单)
  • 名称 —— 人读的名字,日志和 403 页面里会显示
  • 检测目标 —— 要检查哪些片段,逗号分隔。可用值: method uri path query query.key query.value body body.key body.value body.filename header.* headers cookie.key cookie.value flag 支持前缀通配(header.*)和全通配(*)
  • 解码视图 —— 在哪个视图上匹配,决定了规则的检测深度
  • 动作 —— block(计分并可作为拦截依据)/ log(只计分)/ allow(白名单,命中直接放行)
  • 分值 —— 命中后累加的风险分
  • 正则 —— Python 正则,必须是最后一个字段

分数#

  • 15-35:弱信号,正常流量里也偶尔出现,必须靠组合才有意义
  • 36-69:中信号,通常要两条以上叠加
  • 70-99:强信号,基本可以确定是攻击
  • 100:单条即拦(critical),这类特征正常业务里根本不该出现

启动时会打印所有 100 分的规则,方便确认哪些是”一票否决”级的。

写规则时踩的坑#

  1. 视图选错,不是精度问题,是直接漏报

同一个载荷在不同视图下完全不同:

原始输入: /icons/.%2e/%2e%2e/etc/passwd
raw -> /icons/.%2e/%2e%2e/etc/passwd <- 找 '../' 找不到
url2 -> /icons/../../etc/passwd <- 找 '../' 找得到
path -> /icons/../../etc/passwd <- 找 '../' 找得到

如果一条抓路径穿越的规则写成 view=raw、正则写 \.\./, 它永远匹配不上任何编码过的穿越。规则看起来没报错、也加载成功了, 但就是不生效 —— 这是最危险的一类错误。

  1. 忘了要求”边界”,到处误报

看这条命令注入规则的正则:

(?i)(?:[;|`&]\s*|\$\(\s*)(?:cat|whoami|uname|...)(?:\s|$|[;&|`),])

结尾那个 (?:\s|$|[;&|),]) 不是装饰,是必需品。 如果没有它,?id=1里的id会被当成命令注入里的 id 命令命中; 有了它,只有;id |id` 这种”元字符 + 命令 + 分隔符”的组合才算。

这类边界处理是 WAF 规则最花时间的地方,也是误报率的分水岭。

加规则可以直接加#

比如你想保护一个管理后台,禁止从非内网 IP 访问 /admin:

4101 | 站点规则 · 管理后台来源限制 | path | raw | log | 30 | ^/admin(/|$)

然后启动时加载:

Terminal window
python3 run.py --rules rules/default.rules --rules rules/site-mysite.rules

为什么不直接改进 default.rules:因为默认规则集要能不改动就 挡在任意网站前面。站点专属规则混进去之后,换个网站就得回头改, 就失去了”通用型”的意义。


直接放在前面两个漏洞复现的靶场里验证一下#

CVE-2021-41773(Apache 2.4.49 路径穿越)#

这个漏洞的攻击路径是 /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd。 WAF 是怎么抓住它的 —— 三条规则同时命中:

  • 1001(70 分) 在 path 视图找明文 ../。 path 视图已经把 %2e、%252e、反斜杠全归一了, 所以 .%2e/%2e%2e/ 到这里就变成明文的 ../../
  • 1002(35 分) 在 raw 视图找编码痕迹 .%2e。 和 1001 是同一个攻击的两个视角
  • 1010(100 分) 在 path 视图找 /etc/passwd

累计 205 分,远超 100 的阈值。

为什么这几条规则能覆盖所有变体:因为 PoC 里穷举了三种编码风格 (mixed / dotted / plain),实测全部被同一条 1001 规则拦下 —— 这就是归一化的价值:不同写法解开之后是同一个字符串。

CVE-2026-64638(WordPress XSS2Shell)#

这个载荷更刁钻,它的起点是 < 后面多了一个空格:

< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click>

两个 PHP 函数(wp_strip_all_tags 和 wp_kses_post)对 < area 是不是合法标签判断不一致,攻击者就利用这个分歧 让恶意标签凭空出现在登录页上。

WAF 侧的命中:

  • 2005(30 分) DOM 劫持特征 —— 注入带 id=ajaxurl 的元素
  • 2006(45 分) HTML 标签带属性 —— 凭空造出带属性的标签
  • 2009(40 分) 尖括号后接分隔符 —— < area 这个模式本身 (正常 HTML 不会写 < div)

累计 115 分。

2009:它抓的不是某个具体的恶意标签,而是”解析器可能产生分歧的写法”。这类漏洞的共同特征是”同一个输入,不同解析器理解不一样”,而 < 后面的空白正是最容易产生分歧的地方之一。


记录开发过程中遇到的坑#

坑 1:html 视图不认 +,导致真实载荷漏报#

当时的做法:view_html 只做 html.unescape(view_url2(s)),即”百分号解码两次 + 展开 HTML 实体”。

现象:跑自检时,CVE-2026-64638 的真实载荷完全没命中,0 分。

排查过程:把载荷打印出来对比,发现解析出的片段是%3C+area+id%3Dajaxurl...,解码后变成 <+area+id=ajaxurl。

根本原因:< 和 area 之间的空格,在application/x-www-form-urlencoded 里被编码成了 +。而 + 在表单语义里就代表空格 —— 但 url2 视图只解百分号,不认 +。所以尖括号后面那个关键的空格消失了,规则 2009 的<\s+[a-z] 自然匹配不上。

这暴露了一个我在写代码前就讲过的原则,结果自己踩了:我在 normalize.py 的注释里写过”视图的语义必须贴合后端/浏览器实际怎么解析”,却在实现 html 视图时忘了 + 这条表单规则。

修复:view_html 和 view_uni 的起点从 view_url2 改成view_form 双调用(含 + -> 空格)。

坑 2:正则结尾的字符类漏了 ),$(whoami) 漏报#

规则:

(?i)(?:[;|`&]\s*|\$\(\s*)(?:cat|whoami|...)(?:\s|$|[;&|`])

现象:?host=$(whoami) 这个用例只拿到 45 分(来自 1031 命令替换规则),没到 100 的阈值,被放行。

排查过程:手工拿正则去匹配 $(whoami),发现 whoami 能匹配上,但后面那个”必须跟分隔符”的收尾条件匹配失败 — 因为 whoami 后面紧跟的是右括号 ),而字符类 [;&|]` 里没有它。

修复:字符类改成 [;&|),]`。

教训:这类”边界条件”的正则,人眼很难检查。必须靠用例表(selftest.py)来兜底 —— 靠想是想不全的。

坑 3:命中记录里存的是”方法对象”而不是视图名#

代码:

hits.append(Hit(self, p.target, p.view, snippet))

问题:p 是 Payload 对象,p.view 是它的方法,不是视图名。正确的应该是 self.view(规则自己声明的视图名)。

为什么难发现:程序不报错,也不崩溃。只是在终端上打印出视图=<bound method Payload.view of <Payload ...>>。而 Hit.to_dict() 里 json.dumps 遇到方法对象会被 default=str兜住,变成一串垃圾字符串写进日志 —— 依然不报错。

教训:p.view 和 self.view 只差两个字符,语义却完全不同。变量命名在”对象”和”对象的方法”之间产生歧义时,这类错误很难靠读代码发现。只能靠实际跑一遍、看输出内容。

坑 4:stdout 块缓冲,日志整块丢失#

现象:把 WAF 的输出重定向到文件跑 e2e 测试,结果文件里只有一行启动提示,所有请求记录都不见了。

根本原因:Python 在 stdout 不是终端时(重定向到文件、管道、被守护进程接管)默认使用块缓冲,攒够 4~8KB 才真正写一次。进程被 terminate() 时缓冲区里的内容整块丢弃。

为什么这对 WAF 特别致命:最需要日志的时刻(进程被打挂、正在被攻击)恰恰是日志最容易丢的时刻。

修复(run.py):启动时显式sys.stdout.reconfigure(encoding="utf-8", line_buffering=True)。

顺带修掉的第二个问题:同一次排查中还发现中文变成了 [鎷︽埅]。原因是重定向时 Python 会退回使用 locale 的编码,而那个环境的 locale 不是 UTF-8。所以 reconfigure 里同时钉死了 encoding="utf-8"。

坑 5:--no-color 完全不生效#

代码:

_ACTION_STYLE = {
"block": (C.RED, "拦截"),
...
}
# 之后在 run.py 里:
C.disable() # 把 C.RED 改成空字符串

问题:_ACTION_STYLE 这个字典在模块被导入时就求值了,字典里存的是当时 C.RED 的值 "\033[31m"。之后 C.disable()改的是类属性,字典里早就把旧值复制走了,完全影响不到。

修复:改成函数 _action_style(action),每次调用重新读 C.RED。

教训:这类”值被提前捕获”的 bug 很隐蔽 —— 代码能跑、看起来也对,只有在特定开关组合下才暴露。模块级常量引用可变配置时,一定要确认它是不是在配置定型之前就求值了。

坑 6(最有教育意义的一个):差点用”假的成功”骗过自己#

现象:我启动 WAF 后跑 PoC,看到”未探测到可利用的穿越组合”,同时靶场日志新增 0 行。于是得出结论:拦截成功。

但是:查了一下才发现,lsof 显示 8888 端口根本没有任何进程在监听 — WAF 压根没启动起来。

为什么没启动:启动命令是

Terminal window
rm -f logs/*.jsonl logs/*.txt && python3 run.py ...

而 zsh 有个默认行为:glob 模式匹配不到任何文件时会直接报错并中止整条命令(no matches found)。第一次执行时 logs/ 里还有 .txt 文件所以没事,第二次 .txt 已被删光,logs/*.txt 匹配为空 → 整条命令在展开阶段就失败了,run.py 从未执行,连输出重定向都没建立。

那为什么 PoC 显示”未探测到”而不是”连接失败”:因为exploit.py 的 raw_request 在连接失败时返回 status = None,而 probe() 里有一句 if verbose and status is not None and status != 404 — 连接失败时不打印任何东西。于是 21 次连接失败,屏幕上干干净净,最后输出”未探测到可利用的穿越组合”,和”被 WAF 拦住了”长得一模一样。

这次教训有两层:

  • 工具层面的:exploit.py 把”连接失败”和”被拦截”显示成同样的结果, 这是一个真实的可改进点 —— 连接错误应该显式报出来

  • 方法层面的:“攻击没成功”不等于”防御有效”。它们可能都只是 因为服务没跑起来。要证明拦截有效,必须确认三件事同时成立:

    1. WAF 进程确实在监听(lsof -iTCP:8888 -sTCP:LISTEN)
    2. 攻击请求确实拿到了 403(而不是连接错误)
    3. 后端确实没收到(靶场 access log 新增 0 行)

修完 zsh glob 问题后重跑,三项全部确认,那才是真的。

坑 7:连 Python 标准库都会偷偷改你的路径#

现象:端到端测试里,“透明转发”那组用例全部失败,报告说发出 //double//slash、后端却看到 /double//slash — 看起来像是 WAF 把路径规范化了。

排查过程:先用 socket 绕过所有 HTTP 库直发,后端依然报 /double//slash。再用一个最小复现脚本打印 BaseHTTPRequestHandler 的两个属性:

raw_requestline : b'GET //double//slash HTTP/1.1\r\n'
self.path : '/double//slash'

结论:是 http.server(测试后端)自己把开头的 // 规范化了,和 WAF 无关。WAF 的转发是完全忠实的。

根因:BaseHTTPRequestHandler 用 urlsplit 解析请求行,而 urlsplit('//double//slash') 会把开头的 // 当成”网络位置分隔符”(像 //host/path 那样)。

修复:把测试后端 tools/echo_backend.py 改成纯 socket 实现,自己 readline 读请求行,不做任何 urlsplit。

这个坑的价值:它本身就是最好的教材 ——**“HTTP 库会替你做规范化”是真实存在的,连标准库都不例外。**这正是 WAF 必须自己解析原始报文的理由。而且它也提醒我:评估工具本身也可能引入偏差,“测出来的现象”和”被测对象的行为”是两件事。

坑 8:一条”看起来能拦”的规则,其实永远拦不住任何东西#

现象:站点专属规则的示例里有一条后台路径探测:

4101 | 站点规则 · 后台路径探测 | path | raw | log | 30 | ^/(admin|manage|dashboard)(/|$)

照着写进 rules/site-mysite.rules 加载,启动一切正常,没有报错。但实测 GET /admin → 30 分 / 不拦。

根因:两个机制叠在一起,而且它和能拦的写法只差两个字段:

  • log 动作的定义是”只计分,不作为拦截依据”

  • 拦截阈值 100,而这条只给 30 分

于是这条规则命中一万次也不会拦下一次 —— 它只是往日志里写一行。危险的地方在于:七个字段里只有”动作”和”分值”两段不同,不改的话没人会注意到。

修复:文档里的示例改成两段式,先观察后拦截,并把差别明确写出来:

第一步(观察):4101 | ... | log | 30 | ^/(admin|manage|dashboard)(/|$)
第二步(拦截):4101 | ... | block | 100 | ^/(admin|manage|dashboard)(/|$)

实测对照:30 分 / 不拦 → 100 分 / 拦。

代码其实有兜底提示(rules.py 第 427~430 行):

[hint] 当前规则的最高分值是 30 —— 如果拦截阈值也高于它,WAF 将永远不会拦截

但这个提示只在”整个规则集的最高分都低于阈值”时才打。单独加载一个站点规则文件时会看到它;把站点规则和 default.rules 一起加载时(default.rules 里有 100 分的规则),最高分值就是 100,它一声不吭。所以这个 [hint] 是设计上的兜底,不能当成护栏 ——真正的护栏是读懂 log 和 block 的语义差别:log 是”我想观察”,block 才是”我想拦”。

坑 9:规则文件带 BOM,整个规则集加载失败#

现象:从 Windows 记事本另存出来的规则文件,一加载就抛错,一条规则都读不进去:

/var/folders/.../bom.rules:1 字段数应为 7 段(用 | 分隔),实际 1 段:# 注释行

注意报错行末尾那个看着像空格、其实是隐形字符的位置 —— 那就是 BOM,是这个 bug 的识别特征。

最小复现:

Terminal window
printf '\xef\xbb\xbf' > /tmp/bom.rules # BOM(三个字节)
printf '# 注释行\n9001 | t | path | raw | log | 30 | ^/x$\n' >> /tmp/bom.rules
python3 -c "import sys; sys.path.insert(0,'.'); \
from waf.rules import load_rule_file; print(load_rule_file('/tmp/bom.rules'))"

根因:文件第一行实际是 "\ufeff# 注释行",不是 "# 注释行"。

而注释判断(rules.py 第 328 行)是:

if not stripped or stripped.startswith("#"):
continue

"\ufeff# 注释行" 的第一个字符是 \ufeff 而不是 #,判断失败 ——这一行注释被当成规则送去 _parse_line,split("|", 6) 只切出 1 段,直接抛 字段数应为 7 段,实际 1 段,整个文件加载中断。

修复:rules.py 第 323 行把打开方式从 encoding="utf-8" 改成encoding="utf-8-sig" —— 只改一个词,解析逻辑一行没动。

为什么这是安全的替换(实测,不是推断):utf-8-sig 是 utf-8 的严格超集 —— 文件有 BOM 就吃掉,没有 BOM 时两者行为完全一样。拿生产用的 rules/default.rules(文件头三字节是 b'# =',无 BOM)分别用两种编码读,逐条比对规则对象的七个字段:

无 BOM 的生产规则文件,两种编码读出来的规则数:32 / 32
逐条比对是否完全一致: True
文件头 3 字节: b'# ='

这个坑的价值:它提醒配置文件的编码也是接口的一部分。“我这台机器上是好的”从来不等于”别人那台机器上是好的” ——WAF 这种会被别人拿去部署的工具,对输入文件的容错度要按”最不讲究的编辑方式”来定。

坑 10:--quiet-headers 只挡住了响应头,正文一直在往外吐规则细节#

现象:切到 --mode block 之后做验收,我按老习惯用 curl -I(只看响应头)确认 X-WAF-* 那几个头确实消失了,就以为”规则细节暴露”这件事已经关掉。直到改成 curl 看正文,才发现 403 页面里整张命中表原封不动地摆在最显眼的位置:规则编号 1013、规则名「敏感文件访问 · 源码与配置泄露」、片段、视图、分值、命中内容,还有一句 累计风险分 100 / 阈值 100,连「请求被 WAF 拦截」这个标题都在。

为什么这个坑比看上去严重:那三个响应头只泄露”你被 1013 拦了”,而正文等于把规则库的判据整张送给攻击者 —— 他知道你认的是 /.env 这个文件名,就会去试 /.env.local、%2eenv、/appsettings.json;知道你按命中次数累加分值,就会去试编码变形把一条规则拆成多个片段。响应头泄露是一条线索,正文泄露是一张地图。

根因:Config.expose_rules(engine.py)这个名字里没有 “header”,语义上它该管”所有往外暴露规则细节的地方”。但实现里它只被消费了一次 ——_process() 里的 if cfg.expose_rules:,只决定要不要往响应里塞X-WAF-Action / X-WAF-Score / X-WAF-Rule-Ids 三个头。而 render_block_page() 压根没读过这个开关,无论真假都走详细版那条路。

于是形成了三方不一致:

  • run.py 的帮助文本写着「阻断响应里不暴露规则编号」
  • 本项目文档里写着「403 页面里带 X-WAF-Rule-Ids,生产必须关掉」
  • 实现只关了头,正文照吐

注释和行为不一致,而且不一致的方向是”听起来已经改好了” ——这种 bug 比崩溃更难发现,因为它不报错,只会让你放心。

修复:把它变成一个真正的开关。加一个模块级常量 _BARE_BLOCK_PAGE ,并在 render_block_page() 开头加一个提前返回(第 340~341 行):

if not cfg.expose_rules:
return _BARE_BLOCK_PAGE.encode("utf-8")

素净版正文一共 542 字节,全文只有两句人话:

<h1>403 Forbidden</h1>
<p>你的请求不符合本站的安全策略,已被拒绝。</p>

规则编号、规则名、分值、命中内容,连 “WAF” 这个词都不出现 ——判定依据全部留在审计日志里:运维看得到,攻击者看不到。

顺手踩到的一个语法坑:这个常量本来想写成 b"""...""" (bytes 字面量返回时不用再 encode,看着更顺),但 Python 的 bytes 字面量只允许 ASCII 字符,页面里有中文,直接抛:

SyntaxError: bytes can only contain ASCII literal characters

所以只能写成 str 常量、返回时 .encode("utf-8")。

实测验证(curl 不带 -I,直接数字节):

目标 '/.env' score=100 详细版= 2435 字节 素净版=542 字节
目标 '/.git/config' score=100 详细版= 2442 字节 素净版=542 字节
目标 '/index.php?x=1 union select 1,2' score=100 详细版= 2448 字节 素净版=542 字节
目标 '/../../etc/passwd' score=170 详细版= 2810 字节 素净版=542 字节

注意素净版恒为 542 字节,与命中了什么规则完全无关 —— 这正是它该有的样子:连长度本身都不泄露信息。线上实测(--quiet-headers 生效)同样是 542 字节。

再逐个 grep 敏感串,七个全部落空:

'1013' -> 无 '累计风险分' -> 无
'敏感文件' -> 无 '规则' -> 无
'分值' -> 无 'WAF' -> 无
'命中内容' -> 无

顺带一个容易漏的点:详细版页面自己也得做 HTML 转义(_esc())。命中的原文 h.matched 是用户可控的 —— 不转义的话,被拦下来的载荷能反过来在调试页里执行脚本,等于把拦截页变成 XSS 靶场。素净版没这个问题,因为它压根不回显任何输入。

这个坑的价值:验证”安全开关生效了吗”时,必须按它声称的范围去验。开关名字叫 --quiet-headers,我就只测了 headers —— 而这恰好是 bug 的藏身处。正确做法是忘掉名字,去问”这个开关承诺了什么”,再照着承诺逐项 curl 正文。更普适的一条:帮助文本、注释、文档三处都写了”已经关了”,不等于代码真的关了。 那三处是”意图”,代码是”事实”,两者必须实测对齐。

坑 11(一次踩出三个):规则写对了、值也算对了,但两条加起来不够 100 分#

这是打自己的线上站点时踩出来的,一次牵出三个独立缺陷。

现象:发起一次真实的攻击演示,十条攻击里八条老老实实回了 403,唯独这一条回了 200:

/?id=1' and extractvalue(1,concat(0x7e,(select database())))--

而这条载荷已经能把数据库名读出来了。也就是说 WAF 放行的不是”无害的探测”,是一条已经拿到数据的注入。

先看它为什么没过线(把命中明细打出来):

有命中但风险分不足(90/100):1022=45; 1024=45
命中 1022 [query] [sql] 'extractvalue('
命中 1024 [query] [sql] "' and"

两个中信号:1022(危险函数)45 分 + 1024(引号闭合)45 分 = 90 分,差 10 分。阈值是 score >= 100 才拦(rules.py 第 253 行),所以放行。

顺带一提:这里显示的是修复后的排版。当时的输出长这样: 1022=45; 1022=45; 1024=45; 1024=45 —— 因为同一条规则在 query 和 query.value 两个片段上各命中一次,而 explain() 是照着命中列表逐条打印的。 四个 45 看着像 180 分,实际得分 90。读数本身就在骗人, 所以这次把 explain() 和详细阻断页的”分值”列一起改了:同一条规则只列一次, 重复行写”已计”。不然排查的时候第一眼就会被这行字带偏。

缺陷一:90 分这条线上根本没有”差一点”这回事#

第一反应是”那就把 1022 调高一点”。但先往后走几步看看这条链能走多远 ——这才是判断严重程度的关键:

第 1 步:只取库名 90 分 → 放行
第 2 步:查表名(走 information_schema) 125 分 → 拦
第 2 步变体:绕开 information_schema 换系统表 90 分 → 放行
绕开 extractvalue:只取 @@version 45 分 → 放行
绕开 extractvalue:只取 user() 45 分 → 放行
堆叠查询:drop table users 0 分 → 放行

两点结论:

  • 第 2 步那个 125 分是个”假后盾”。 它看起来兜住了”查表名”这一步,但那只是因为攻击者恰好走了一条会撞上 1023 的路。换成mysql.innodb_table_stats(sqlmap 里现成的、就是为了绕 information_schema过滤而存在的写法),分值立刻掉回 90 —— 整条枚举链畅通无阻。分值设计的稳健性,不能依赖”攻击者会老实走那条会被抓的路径”。

  • 90 分和 0 分在结果上没有区别,但它们给人的感觉完全不同:0 分会让人立刻跳起来,90 分会让人以为”再调一点就好了”。实际情况是:差 10 分就是全放行,链子能一路走到底。所以定分值的时候不能看”这条规则像不像攻击”,要看”它和谁组合、组合完落在阈值的哪一边”。

缺陷二:\b 写在分号后面,把堆叠查询整条漏掉了#

1'; drop table users-- 的得分是 0。不是”分不够”,是一次命中都没有。

拆成两个人看:

  • 引号闭合规则(1024)本该命中 ';,但它没命中。原正则结尾是:

    (?i)['"]\s*(or|and|union|select|from|where|having|order\s+by|group\s+by|;)\b

    问题在最后那个 \b。\b 的含义是”左右必须一个是单词字符、一个不是”。分号 ; 不是单词字符,它后面的空格也不是,两边都不是单词字符,中间就不存在词边界 —— \b 判定失败,整条分支作废。(反直觉的是 1';DROP 反而能命中:D 是单词字符,边界又出现了。也就是说这条规则的表现取决于分号后面有没有空格,纯属巧合。)

  • **整个规则集对 drop / delete / truncate / insert 一个字都没覆盖。**全文件 grep 一遍,drop 只在一句注释里出现过。所以哪怕引号那一支修好了,drop table 本身仍然是 0 分。

缺陷三:修”引号闭合”的时候差点把自己修进另一个坑#

修 1024 的 \b 时,很自然的写法是把结尾换成 \b 的替代品,比如(?!\w)(后面不能跟单词字符)。但这么写分号那一支还是坏的 ——';drop 里 ; 后面跟的正是 d,一个单词字符,前瞻同样失败。

分号本身就是天然分隔符,它不需要”边界要求”。所以正确的修法是把分号单独拆成一支、不给它套任何边界断言,其余关键字保留 \b(它们的边界要求不能去掉,否则 'orange 里的 or 会被误判成关键字):

(?i)['"]\s*(?:(?:or|and|union|select|from|where|having|order\s+by|group\s+by)\b|;)

这里有一条通用的教训:给正则加”边界要求”之前,先问一句”这个字符自己是不是就是边界”。分隔符(; | &)和标识符字符(字母数字)要用完全不同的处理方式,混在一个字符类后面套同一个断言,就会出现”有的样本能中、有的样本不能中”这种最难查的漏报。

修复:五条改动,两条修改 + 三条新增(另加一条扩表)#

规则改动为什么
1024修尾锚点,分号单独成支让”引号 + 分号”能计分
102245 → 100 分,正则收窄为无歧义函数单条即拦,直接跨过 90 分死区
102335 → 55 分,表名从”具体几个”扩成”语法类别”把”假后盾”变成真后盾
1025(新)时间盲注函数(sleep( / benchmark( …)60 分从 1022 拆出来,避免给英文词一票否决
1026(新)破坏性语句(drop / truncate / delete from …)70 分补上一整类语法空白
1027(新)堆叠查询(; + 语句动词)70 分覆盖不带引号的数字型注入点
1028(新)数据窃取表达式(@@version / user() / group_concat()55 分按语法类别覆盖,不给”换个函数名”留口子

分数怎么配的(这是本轮最该记住的一条纪律):

45 + 55 = 100 两条中信号叠加刚好过线 ← 刻意配成这个数
45 + 45 = 90 叠加也不过线 ← 上面那个坑就出在这里
70 + 45 = 115 过线

所以中信号(3669 这一档)只取 45 / 55 / 70 这几档固定值,不随手写 50。规则分值不是”这条看起来有多可疑”,是**“它和谁相加、加到多少”**。这条已经写进 rules/default.rules 的文件头(第 2857 行)。

还有个判断标准也值得单独记:什么特征可以给 100 分一票否决?答案是”这个名字在 SQL 之外还有没有意义”。

  • extractvalue( / load_file( —— 只有注入会这么写 → 语法结构 → 可以给 100

  • drop table —— 技术博客的搜索框里搜 “drop table mysql syntax” 完全正常 → 关键词 → 只能给 70,靠”关键词 + 注入上下文”两条一起过线

按这个标准,sleep 也必须从 1022 里拆出去:它是个普通英文词,旧规则连 “I need to sleep more” 都会给 45 分。

实测验证#

改之前先把用例写进 tools/selftest.py,让它先失败,这样”改好了没有”有一个能对照的基线(这一组是误报边界用例,和攻击用例配对):

改之前:结果:38/48 通过,10 个未通过
[!!] log SQL 注入 · 报错型(extractvalue 读库名) 90分
[!!] pass SQL 注入 · 堆叠查询(引号 + drop table) 0分
[!!] log 绕开 information_schema 换系统表 45分
...
改之后:结果:全部 52 个用例通过
[ok] block SQL 注入 · 报错型(extractvalue 读库名) 200分 规则=1022,1024,1028
[ok] block SQL 注入 · 堆叠查询(引号 + drop table) 185分 规则=1024,1026,1027
[ok] block SQL 注入 · 堆叠查询(数字型注入点) 140分 规则=1026,1027
[ok] block SQL 注入 · 绕开 information_schema 换系统表 155分 规则=1023,1024,1028
[ok] log 搜索「drop table mysql syntax」 70分 ← 该放行的仍然放行

端到端那一层(真起进程、真走 socket、验后端收没收到)也从 17 项加到 20 项全过。上一节那条注入链的九步里,八步发生了”放行 → 拦”的翻转,只剩本来就拦得住的那一步。

误报面单独验过:专门挑”含分号 / 含 SQL 动词 / 含引号”的正常请求(分号分隔的 id 列表、CSS 片段里的 ;、代码片段 printf(";")、搜 “update the set of rules” 等 15 条),误拦 0 条。

这个坑的价值#

  • “有命中”和”拦住了”是两件事,中间隔着一条阈值线。看日志要看到 score,不能看到命中规则就以为拦住了。

  • 单条规则的分数没有意义,组合分才有意义。 改分值前先算一遍”它和谁相加、加到多少”,而不是感觉”45 是不是太低了”。

  • 稳健性不能依赖攻击者走哪条路。 只要规则的写法是”具体标识符”,就一定存在”换个名字就绕过去”的路径 —— 补规则要补语法类别。

  • **每加一条规则,必须同时加一条”不许越过的线”的用例。**关键词类规则的分数上限是它自己决定的,上限由误报边界用例守着。

  • 还有一条最低成本的:改规则前后必须跑回归(selftest.py + e2e_test.py),而且要在改之前先把新用例写进去。不然”改好了没有”只能靠眼睛看。


上线方式#

方式一:改 Nginx 的 proxy_pass(推荐)#

在 Nginx 站点配置里,把原本指向上游的地址改成 WAF:

location / {
proxy_pass http://127.0.0.1:8888; # 原来是上游应用地址
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

然后让 WAF 指向真正的上游:

Terminal window
python3 run.py --backend 127.0.0.1:9000 --listen 127.0.0.1:8888

后端一行代码都不用改,这是反向代理式 WAF 最大的优势。

但有一个必须知道的副作用:真实客户端 IP 会丢,限速的分桶会退化。

WAF 挂在 nginx 后面时,TCP 对端永远是 127.0.0.1。而限速是按 IP分桶的 —— 所有访客会归并成同一个桶。后果是几个正常人同时在线(静态博客首页会连带拉几十个 /_astro/ 资源)就能把额度打满,再叠加任意一条 30 分以上的规则命中,正常访客就被误拦了。

代码里已经处理了这件事:engine.py 的 _client_ip() 只在 TCP 对端是回环地址时才采信上面的 X-Real-IP。所以:

  • 方式一(nginx 反代到回环口):X-Real-IP 会被采信,限速正常工作

  • 方式二(WAF 直接监听 0.0.0.0):对端不是回环,X-Real-IP 一律忽略 (否则攻击者伪造这个头就能把限速拆成无数个桶绕过),于是所有人都落进同一个 IP 桶 → 限速基本失效,要么接受,要么把 --rate-limit 0 关掉

如果确实要裸跑(方式二),记得 --rate-limit 0,别让它继续按”一个 IP”计分。

还有一件事要连带处理:后端 nginx 的 access.log 默认记 TCP 对端 IP,所以接上 WAF 之后它会全是 127.0.0.1 —— 任何按 access.log 第 1 列做来源统计的脚本(比如访客分析)会直接失效。解法是在后端 server 块里自定义 log_format,把 $remote_addr 换成 $http_x_real_ip。(WAF 自己的审计日志不受影响,它的 client 字段记的就是真实 IP。)

挂到 nginx 后面之后,有些攻击你永远不会在 WAF 日志里看到#

这一条是部署在我自己的博客上测出来的。

nginx 会比 WAF 更早解析请求行。 凡是它自己就判定为非法的请求 —— 最典型的就是 %2e%2e / .. 想跳出站点根目录的路径穿越 —— nginx 在 URI 解析阶段就直接回 400,请求根本不会进入 proxy_pass,WAF 连看都看不到。

实测对比(WAF 直接监听 vs 挂在 nginx 后面):

  • 直接打 WAF:400,并且 WAF 记了日志 score=205 rules=1001,1002,1010

  • 打公网(nginx → WAF):400,WAF 日志里没有这一条

怎么快速判断一个 400 是谁发的:看后端 nginx 的 access.log 有几个请求对应几行。

正常情况下一个请求会产生两行(一行 127.0.0.1 来自 WAF 转发、一行真实 IP来自客户端)—— 如果只有一行、且没有对应的 127.0.0.1 行,就说明请求在公开那层就被拒了,没到 WAF。

这意味着什么:

  • 这是纵深防御,不是缺陷:前置 nginx 比 WAF 还严

  • 但要知道 default.rules 里的路径穿越规则(1001 / 1002)在这种架构下基本是死代码。以后翻日志发现”穿越规则从来没命中过”,别怀疑规则写错了

  • 换成 proxy_pass http://backend$request_uri; 也救不回来 ——拒绝发生在 location 匹配之前,那个阶段已经过去了

  • SQL 注入、XSS、命令注入这些放在 query string 或 body 里的载荷不受影响,它们会被正常送给 WAF 检测(因为 nginx 不认为它们非法)

一句话:每一层能看到的流量范围不一样,“装了 WAF 就全都由 WAF 判定”是个错误预期。要评估 WAF 的实际覆盖,得先搞清楚哪些请求到得了它。

方式二:直接监听公网端口#

Terminal window
python3 run.py --backend 127.0.0.1:80 --listen 0.0.0.0:8000

适合本地测试,生产不建议这么裸跑(缺 HTTPS、缺并发优化)。

上线前必做的六件事#

  • 先跑审计模式观察

    Terminal window
    python3 run.py --mode audit --backend 127.0.0.1:9000

    audit 模式会完整跑一遍检测,但把”拦截”降级成”记录”。跑几天,看日志里有多少正常请求会被误判,再决定要不要开拦截。一上来就硬拦的 WAF,通常活不过一周。

  • 想清楚限速这一维要不要留

    Terminal window
    python3 run.py --rate-limit 0 ... # 关掉
    python3 run.py --rate-limit 600 ... # 或者调大

    默认是 单 IP 120 请求 / 10 秒(--rate-limit 数字就是窗口内允许的请求数,--rate-window 默认 10 秒)。静态站点特别容易误触发 ——首页会连带拉几十个 /_astro/ 资源,几篇页面同时打开就够呛。而且如果真实 IP 取不到(WAF 直接监听 0.0.0.0 的情况),所有人共用一个桶,误报会来得更快。拿不准就先关掉 ——限速是本项目里最可能造成误杀的一维。

  • 关掉规则细节暴露

    Terminal window
    python3 run.py --quiet-headers ...

    生产环境的 403 页面不该告诉攻击者”你是被 1001 号规则拦的”。

  • 配白名单。后台管理接口、文件上传接口、监控探活接口, 都该加到 rules/site-*.rules 里用 allow 动作放行。没有白名单的 WAF 一定会被误报淹没。

  • 调阈值。默认 100 分是保守值。上线初期可以调到 150, 减少误报;观察一段时间后再往回收。

  • 日志接出去。logs/waf-*.jsonl 是本地文件, 生产环境应该接到日志系统(ELK / Loki / 云日志服务)。

性能与限制#

  • 单机单进程。多线程模型在中等流量下够用,但没有做连接池、没有 keep-alive 复用后端连接。高并发场景需要改造。

  • 正则匹配是主要开销。30 条规则 × 每次请求约 20 个片段= 600 次匹配。规则数上千时需要改成按 target 分桶索引。

  • 没有响应方向检测。目前只检查请求(inbound),不检查响应内容 —— 所以防不了”WebShell 回显泄露数据”这类问题。

  • 没有 TLS 卸载。HTTPS 要靠前面的 Nginx 或负载均衡终止,这也正好符合”WAF 在反向代理位置”的部署习惯。


附录:文件清单#

waf-lab/
├── run.py 203 行 启动入口(命令行参数、各层组装)
├── waf/
│ ├── __init__.py 20 行 包说明与模块分工
│ ├── httpmsg.py 400 行 ① 报文解析层
│ ├── normalize.py 536 行 ② 归一化层(11 个解码视图)
│ ├── rules.py 453 行 ③ 规则引擎(异常评分)
│ ├── ratelimit.py 161 行 ③' 速率限制(滑动窗口)
│ ├── engine.py 846 行 ④ 决策层(主服务 + 反向转发)
│ └── audit.py 123 行 ⑤ 审计日志(JSONL 按天分文件)
├── rules/
│ └── default.rules 401 行 36 条规则,覆盖四类攻击面
├── tools/
│ ├── selftest.py 340 行 内存自检(52 个用例)
│ ├── fp_check.py 154 行 误报自检(23 条"像攻击的正常请求")
│ ├── e2e_test.py 302 行 端到端测试(真起进程走 socket)
│ ├── echo_backend.py 148 行 纯 socket 回显后端(验证透明转发)
│ ├── live_attack_demo.py 108 行 打真实入口的攻击演示(服务器上跑)
│ ├── live_sqli_probe.py 76 行 SQL 注入变体探测(自动关联审计取分)
│ ├── live_sqli_chain_probe.py 68 行 报错型注入的完整链路探测
│ └── verify_rule_1024.py 47 行 验证 1024 的 \b 边界问题(本地跑)
├── evidence/ 真实攻击事件的原始日志包(见其 README.md)
├── deploy-blog/ 部署到服务器用的 nginx / systemd 配置
└── logs/ 审计日志输出目录

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
26
分类
13
标签
19
总字数
180,600
运行时长
0 天
最后活动
0 天前

文章目录