我决定要做一个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 条
- 危险标签(iframe
svgobject…)及其携带外链的形态(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 步:看看规则都加载了什么
python3 run.py --list-rulesrun.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 步:跑自检(不需要起任何服务)
python3 tools/selftest.pytools/selftest.py是自检脚本- 它在内存里构造 35 个请求(正常的 + 攻击的),直接喂给检测链,看判定对不对
- 连起来:不开服务、不占端口,几秒钟检查一遍规则有没有把能力弄坏
预期结尾:
结果:全部 35 个用例通过第 3 步:端到端冒烟测试
python3 tools/e2e_test.py- 这个脚本会真的起两个进程:一个回显后端 + 一个 WAF
- 然后用真实的 socket 发真实的 HTTP 请求
- 测完自动把两个进程关掉
- 连起来:走一遍完整链路,确认转发、拦截、日志都正常
预期结尾:
结果:全部 17 项通过第 4 步:接上你自己的靶场
这一步需要三个终端窗口。
终端 1 —— 起靶场:
cd /CVE-2021-41773docker compose up -d终端 2 —— 起 WAF:
cd /waf-labpython3 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 —— 打:
cd /CVE-2021-41773python3 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启动入口
- 读命令行参数 ——
--backend(后端在哪)、--listen(自己守哪个端口)、--rules(用哪些规则文件)、--mode(拦截还是只记录)等 - 加载规则 —— 调
RuleSet.from_files()把rules/*.rules读进内存。 如果参数里有--list-rules,打印一张规则表就退出,不启动服务 - 组装各层 —— 建限流器
RateLimiter、审计日志AuditLog, 全部塞进一个Config对象 - 启动服务 —— 调
serve(cfg),然后阻塞在那里等请求,直到 Ctrl + C
还有一件容易被忽略但很关键的事:它在启动时把 stdout / stderr 重定向成
UTF-8 + 行缓冲(stream.reconfigure(...))。不这么做的话,输出重定向到
文件时中文会变成 [鎷︽埅],日志还会因为块缓冲被整块丢掉。
最常用的三条命令:
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())流程:
- 重定向输出编码。这段注释详细记录了两个真实踩过的坑:
- 必须显式指定
encoding="utf-8":Python 在 stdout 不是终端时 会退回使用 locale 编码。实测踩过一次 —— 输出重定向到文件后, 中文全变成了[鎷︽埅](因为写出去的 UTF-8 字节被按别的编码解释了) - 必须行缓冲:默认块缓冲要攒够 4~8KB 才写一次,进程被
kill -9时 缓冲区整块丢失。
- 必须显式指定
--no-color或输出不是终端时,调C.disable()- 加载规则。默认读
rules/目录。RuleError时打印 错误并return 2 --list-rules就直接打印规则表退出- 如果一条检测规则都没有,警告(否则 WAF 会把所有 流量原样放行,而你可能以为是规则在生效)
- 组装限流器。
--rate-limit 0时传一个”上限极大、 永不触发”的限流器,等于关闭 —— 比到处写if limiter判断干净 - 建审计日志、解析地址、组装
Config - 调
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 Trueread_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 Noneread_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) returnhandle() 调 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 语法,或者触碰了本模块设定的上限。""" passRawRequest 类 —— 一个请求的”原始快照”
三个刻意保存成”原始状态”的字段(__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 Noneheaders(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 实体解码:< -> < ,< -> < ,& -> &
return html.unescape(view_form(view_form(s)))这是 XSS 检测的必需视图。浏览器会把实体当真正的字符解释,所以 WAF 也必须先展开实体再做标签匹配,否则 <script> 就漏了。
注意 html.unescape 遵循 HTML5 规范,连”没分号的实体”也会解(比如 < 也能解成 <),这恰好和浏览器的宽容行为一致。
先过 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、thresholdblocked属性: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
- 第一步:白名单短路。命中任何 allow 规则就立刻
返回
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(超出次数)、scoreto_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("&", "&").replace("<", "<").replace(">", ">").replace('"', """))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→ 记日志 + 回 400socket.timeout/OSError→ 直接返回_process里任何未预期异常 → 回 500 并返回。 兜底很重要,否则你看到的现象是”请求卡住”而不是”报错”,极难排查
_wants_keep_alive(req):静态方法,判断客户端是否希望 复用连接。HTTP/1.1 默认复用(除非显式Connection: close), HTTP/1.0 默认不复用(除非显式keep-alive)_process(cfg, req, keep_alive)—— 整个 WAF 的主流程, 对应文件开头那张流程图:extract_payloads(req)拆片段(同时触发解析层的 flag 变成片段)cfg.ruleset.scan(payloads)规则匹配 + 评分cfg.limiter.record(client_ip)速率维度total_score = scan.score + rate_score合并总分- 决策。白名单优先级最高 —— 命中 allow 就直接放行, 连速率都不判,否则”某个爬自己站点的监控脚本”这类合法高频流量就永远 加不进白名单
- audit 模式降级 —— 照样完整跑一遍检测,
但把
block改成log并标记would_block = True。 这是本项目最实用的功能 —— 接到真实业务上时先跑几天 audit, 看看有哪些正常请求会被误判,再去调分数,而不是上线即误杀 - 拦截分支 —— 直接在后端之前掐断(这类请求根本不会到达
后端),回 403 + 拦截页,按
expose_rules决定要不要加X-WAF-*头 - 放行分支 ——
forward_request()转发, 处理 HEAD / 204 / 304 不返回 body 的情况 - 审计日志 —— 组装一个 20 多字段的
record字典 (时间、req_id、客户端、方法、target、host、UA、动作、would_block、 总分、阈值、hits、rate、flags、body 大小、后端状态、耗时…)写盘 - 终端输出
_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,于是:
# 看今天所有被拦的请求python3 -c "import jsonfor 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.jsonlwrite(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>
输入: <script> \u003cscript\u003e (HTML 实体 + JS 转义) raw <script> \u003cscript\u003e url <script> \u003cscript\u003e url2 <script> \u003cscript\u003e url3 <script> \u003cscript\u003e form <script> \u003cscript\u003e utf8 <script> \u003cscript\u003e uni -> <script> <script> ← 只有它解 JS 转义 lower <script> \u003cscript\u003e html -> <script> \u003cscript\u003e ← 只有它解 HTML 实体 sql <script> \u003cscript\u003e path -> <script> /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 实体:
< → <,< → < - 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 解码,所以<script>、%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, payloadsmain()
跑两张用例表,逐条打印[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 注入、命令注入、TRACEPASSTHROUGH_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())- 起回显后端(子进程),
wait_port确认起来了 - 起 WAF(子进程,输出重定向到
logs/e2e-waf-output.txt),wait_port确认;没起来就把 WAF 输出打出来方便排查 - 第一组 —— 正常流量应返回 200 且响应里含
RAW_REQUEST_LINE(证明到了回显后端) - 第二组 —— 攻击流量应返回 403,并且响应里不该出现
X-Echo-Backend头(证明后端确实没收到) - 第三组 —— 透明转发。从回显里抠出后端看到的
request-target,和发出的比对。这里踩过一个坑:
回显行格式是
RAW_REQUEST_LINE: GET /a%2eb HTTP/1.1, 直接split(" ", 2)[1]取到的是方法名GET,要先切掉前缀再取 - 第四组 —— 同一个 TCP 连接连发两个请求,验证 keep-alive
- finally 里
terminate()两个子进程 - 第五组 —— 检查审计日志确实落盘、里面有 block 记录、 且记录里含规则命中详情
- 汇总 —— 全过打印”全部 N 项通过”,否则列出失败项
tools/echo_backend.py — 纯 socket 回显后端
为什么不用 http.server 那套现成的:因为这次要验证的是”WAF 有没有偷偷 改动请求”,而 http.server 自己就会改。
客户端发出 GET //double//slash HTTP/1.1http.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: returnEchoServer 类 和 main()
EchoServer:多线程 TCP 服务器,同样的allow_reuse_address和daemon_threads
allow_reuse_address = Truedaemon_threads = Truehandle_error():整个覆盖成pass——客户端半路断开是常态,别刷屏
passmain():端口取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 页面里会显示
- 检测目标 —— 要检查哪些片段,逗号分隔。可用值:
methoduripathqueryquery.keyquery.valuebodybody.keybody.valuebody.filenameheader.*headerscookie.keycookie.valueflag支持前缀通配(header.*)和全通配(*) - 解码视图 —— 在哪个视图上匹配,决定了规则的检测深度
- 动作 ——
block(计分并可作为拦截依据)/log(只计分)/allow(白名单,命中直接放行) - 分值 —— 命中后累加的风险分
- 正则 —— Python 正则,必须是最后一个字段
分数
- 15-35:弱信号,正常流量里也偶尔出现,必须靠组合才有意义
- 36-69:中信号,通常要两条以上叠加
- 70-99:强信号,基本可以确定是攻击
- 100:单条即拦(critical),这类特征正常业务里根本不该出现
启动时会打印所有 100 分的规则,方便确认哪些是”一票否决”级的。
写规则时踩的坑
- 视图选错,不是精度问题,是直接漏报
同一个载荷在不同视图下完全不同:
原始输入: /icons/.%2e/%2e%2e/etc/passwd
raw -> /icons/.%2e/%2e%2e/etc/passwd <- 找 '../' 找不到 url2 -> /icons/../../etc/passwd <- 找 '../' 找得到 path -> /icons/../../etc/passwd <- 找 '../' 找得到如果一条抓路径穿越的规则写成 view=raw、正则写 \.\./,
它永远匹配不上任何编码过的穿越。规则看起来没报错、也加载成功了,
但就是不生效 —— 这是最危险的一类错误。
- 忘了要求”边界”,到处误报
看这条命令注入规则的正则:
(?i)(?:[;|`&]\s*|\$\(\s*)(?:cat|whoami|uname|...)(?:\s|$|[;&|`),])结尾那个 (?:\s|$|[;&|),]) 不是装饰,是必需品。 如果没有它,?id=1里的id会被当成命令注入里的 id 命令命中; 有了它,只有;id |id` 这种”元字符 + 命令 + 分隔符”的组合才算。
这类边界处理是 WAF 规则最花时间的地方,也是误报率的分水岭。
加规则可以直接加
比如你想保护一个管理后台,禁止从非内网 IP 访问 /admin:
4101 | 站点规则 · 管理后台来源限制 | path | raw | log | 30 | ^/admin(/|$)然后启动时加载:
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 压根没启动起来。
为什么没启动:启动命令是
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把”连接失败”和”被拦截”显示成同样的结果, 这是一个真实的可改进点 —— 连接错误应该显式报出来 -
方法层面的:“攻击没成功”不等于”防御有效”。它们可能都只是 因为服务没跑起来。要证明拦截有效,必须确认三件事同时成立:
- WAF 进程确实在监听(
lsof -iTCP:8888 -sTCP:LISTEN) - 攻击请求确实拿到了 403(而不是连接错误)
- 后端确实没收到(靶场 access log 新增 0 行)
- WAF 进程确实在监听(
修完 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 的识别特征。
最小复现:
printf '\xef\xbb\xbf' > /tmp/bom.rules # BOM(三个字节)printf '# 注释行\n9001 | t | path | raw | log | 30 | ^/x$\n' >> /tmp/bom.rulespython3 -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 | 修尾锚点,分号单独成支 | 让”引号 + 分号”能计分 |
| 1022 | 45 → 100 分,正则收窄为无歧义函数 | 单条即拦,直接跨过 90 分死区 |
| 1023 | 35 → 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。规则分值不是”这条看起来有多可疑”,是**“它和谁相加、加到多少”**。这条已经写进 57 行)。rules/default.rules 的文件头(第 28
还有个判断标准也值得单独记:什么特征可以给 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 指向真正的上游:
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 的实际覆盖,得先搞清楚哪些请求到得了它。
方式二:直接监听公网端口
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:9000audit模式会完整跑一遍检测,但把”拦截”降级成”记录”。跑几天,看日志里有多少正常请求会被误判,再决定要不要开拦截。一上来就硬拦的 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/ 审计日志输出目录文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!