将我的waf项目部署到我的博客网站上
waf搞定了要验收所以直接放在自己的博客上,然后自己打一遍看看有没有用
架构
上线前是这样子的(实测 curl -sI https://lyh-blogs.top/ 返回
Server: nginx/1.18.0 (Ubuntu)):
浏览器 ──HTTPS:443──> nginx(终止 TLS + 直接读 /var/www/lyh-blogs 的静态文件)现在已经变成:
浏览器 ──HTTPS:443──> nginx 公开 server(只做反代,不再读文件) │ │ HTTP 127.0.0.1:8888 ▼ waf-lab WAF(检测 + 决策) │ │ HTTP 127.0.0.1:8080 ▼ nginx 内部 server(读 /var/www/lyh-blogs)为什么必须让 nginx 留在最前面:waf-lab 全仓库没有任何 TLS 代码(实测 grep ssl / wrap_socket / SSLContext / context= → 零匹配)。WAF 用的是 socketserver.ThreadingTCPServer,只会讲明文 HTTP。所以 TLS 只能由 nginx 终止,WAF 必须挂在回环端口上,绝不能直接监听 443。
但”nginx 在最前面”也带来一个必须知道的后果:公开的 nginx 会先于 WAF解析请求行。凡是它自己就判定为非法的请求(典型:%2e%2e 想跳出根目录的路径穿越),它会在 URI 解析阶段直接回 400,请求根本不会进入 proxy_pass,WAF 也就看不到。这是纵深防御,不是缺陷 —— 但它意味着「装了 WAF,所有攻击都由 WAF 判定」是个错误预期。
解决的一些问题
问题 1(致命):真实 IP 全丢失,限速会把全站访客当成同一个人
waf/engine.py 第 471 行:
client_ip = self.client_address[0]WAF 监听在 127.0.0.1:8888,唯一会连它的是本机 nginx。**于是所有请求的 client_address[0] 都是 127.0.0.1。**也就是说waf会把所有访客的ip看作同一个,然后会因为额度不够而导致讲正常访客拦住。
而限速是按 IP 分桶的(waf/ratelimit.py 第 113 行 record(client)),默认参数(run.py 第 94~99 行):120 请求 / 10 秒,超出每超一次 +3 分,单次事件封顶 90 分(ratelimit.py 第 102 行 max_score=90)。
后果推演:
-
首页 402413 字节,一个真人打开一篇文章会顺带拉一堆
/_astro/资源 -
两三个访客同时在线就能把「127.0.0.1」这一个桶的 120 额度打满
-
单靠限速 90 分 < 阈值 100,拦不住;但只要任何一个请求再命中一条 30 分以上的规则(
1002是 35 分、2009是 40 分、1001是 70 分…),总分立刻过线 → 误拦正常访客
统计首页 HTML 里引用的唯一静态资源:
_astro/*.js 10 个_astro/*.css 1 个图片(png/webp/avif/…) 2 个其它 /_astro/ 与裸文件引用 20 个------------------------------------合计唯一静态资源 33 个 → 打开一次首页 ≈ 34 个 HTTP 请求对照额度 120:一次页面加载就用掉约 28%。
于是可以断定:这个缺陷跟”有多少人访问”没有关系 —— 一个访客就能触发。
- 0 个访客:桶里是 0,缺陷潜伏着,看不出来
- 1 个访客:10 秒内点 3~4 个页面(或打开一个图多的文章页)就能自己把桶填满
- N 个访客:大家一起填同一个桶,触发得更快
访问量只决定”多久暴露一次”,不决定”会不会发生”。人多的效果是放大,不是原因 —— 真实原因是所有访客被错误地算成了同一个人。
另外确认了限速的窗口语义:ratelimit.py 用的是滑动窗口(deque[timestamp]),不是固定分片窗口。所以它不会在窗口边界处清零,而是持续滚动计数 —— 这也让人多时更容易累积。
两个选项,二选一:
-
(a) 关闭限速:
--rate-limit 0。已实测确认支持 ——run.py第 162~170 行在这个分支里会换成一个limit=10**9, score_per_excess=0的空转限流器,等于彻底不参与计分 -
(b) 让 WAF 读真实 IP:从 nginx 传来的
X-Real-IP头取 IP。安全性上没有额外风险,因为 WAF 只监听回环,只有本机 nginx 能连它,这个头不可能被外部伪造。需要改engine.py第 471 行,大约两行代码
最终做法:选了 (b),并在实现时补了一条 (a) 里没写出来的安全前提。
实际改动落在 waf/engine.py,新增了一个函数:
原代码(_process 开头):
def _process(self, cfg, req, keep_alive): started = time.time() req_id = uuid.uuid4().hex[:12] client_ip = self.client_address[0] # ← 问题就在这一行改成:
def _process(self, cfg, req, keep_alive): started = time.time() req_id = uuid.uuid4().hex[:12] client_ip = self._client_ip(self.client_address[0], req)这一行本身只是把”直接取对端 IP”换成”经过一个函数判断后再取”。 真正的逻辑在下面这个新函数里。
新增 WAFHandler._client_ip()
-
新增
WAFHandler._client_ip(peer, req)。它的判断条件是「TCP 对端是不是回环地址」,而不是「有没有 X-Real-IP 这个头」。只有对端是127.0.0.1/::1/::ffff:127.x时才采信X-Real-IP,否则一律退回 TCP 对端地址 -
为什么必须加这个前提:如果哪天有人图省事把 WAF 直接监听
0.0.0.0, 攻击者随手写一个X-Real-IP: 1.2.3.4就能让每个请求都落进不同的限速桶, 等于把限速彻底废掉。信任边界是「回环对端」,不是「有这个头」 -
调用点在第 505 行:
client_ip = self._client_ip(self.client_address[0], req)
# ---- 真实客户端 IP ----
# 只认回环地址为"可信的代理对端" _LOOPBACK = ("127.0.0.1", "::1")
@staticmethod def _client_ip(peer, req): if peer in WAFHandler._LOOPBACK or peer.startswith("::ffff:127."): real = req.header("x-real-ip") if real: return real.strip() return peer逐行说明:
-
_LOOPBACK = ("127.0.0.1", "::1")—— 类属性,不写进函数里是因为它是个常量, 没必要每次调用都重建元组 -
if peer in WAFHandler._LOOPBACK or peer.startswith("::ffff:127.")——这是整个改动的核心判断。三个条件对应三种回环表示:127.0.0.1—— 最常见的 IPv4 回环::1—— IPv6 回环::ffff:127.x—— IPv4-mapped IPv6。双栈监听时对端可能长这样, 漏掉它会导致”看起来是回环却不被信任”的诡异现象
-
real = req.header("x-real-ip")——RawRequest.header()内部把请求头名统一转小写后查表,所以这里写小写;它会返回None(头不存在)或字符串 -
if real:而不是if real is not None:—— 顺带把空串也挡掉了,免得X-Real-IP:(值为空)被当成合法 IP 塞进限速桶 -
return real.strip()—— 去掉首尾空白。HTTP 头值允许有前后空格,不 strip 的话" 203.0.113.9"和"203.0.113.9"会算成两个不同的桶 -
return peer—— 所有其他情况的兜底。注意这一句在if外面:只要对端不是回环,或者头不存在/为空,一律用 TCP 对端地址,绝不采信任何来自公网的X-Real-IP
顺带改掉了同一次排查里发现的另一个问题(本来不在计划内):审计日志的client 字段记的是 TCP 对端( "%s:%d" % self.client_address),挂到 nginx 后面就是永久 127.0.0.1,导致后续所有来源分析都没法做。
现在拆成两个字段:
-
client—— 逻辑客户端,即拆掉代理后的真实访客 IP,供分析用 -
peer—— 真正的 TCP 对端,排查转发链路时用
问题 2:后端访问日志里的来源 IP 会全部变成 127.0.0.1,且每次请求写两行
注意这里说的是两个不同的日志,别混:
-
WAF 自己的审计日志(
/opt/waf-lab/logs/*.jsonl)—— 已经在问题 1 里顺手修好了,现在client字段是真实访客 IP -
后端 nginx 的
access.log—— 下面讲的就是它
同一个请求在 /var/log/nginx/access.log 里出现两行,一行真 IP、一行 127.0.0.1:
$ curl -sk -o /dev/null "https://lyh-blogs.top/?probe-1791773803"$ sudo grep "probe-1791773803" /var/log/nginx/access.log 127.0.0.1 - - [12/Oct/2026:10:56:43 +0800] "GET /?probe-1791773803 ..." 200 402413 123.207.244.94 - - [12/Oct/2026:10:56:43 +0800] "GET /?probe-1791773803 ..." 200 402413原因:nginx.conf 第 39 行有一条全局 access_log /var/log/nginx/access.log;,而它写在 http 块里 —— 于是所有 server 都往同一个文件里写:
-
公开 server(443)记的是 TCP 对端,也就是真实访客 IP
-
内部 server(8080)的对端永远是 WAF,记的是 127.0.0.1
真实的业务日志里也是这个模式(同一个访客、同一秒、成对出现):
36.141.24.209 - - [12/Oct/2026:10:45:23 +0800] "GET /posts/2026-gdutctf-writeup/ ..." 200 132781 127.0.0.1 - - [12/Oct/2026:10:45:24 +0800] "GET /posts/htmlandcss/ ..." 200 119926 36.141.24.209 - - [12/Oct/2026:10:45:24 +0800] "GET /posts/htmlandcss/ ..." 200 119889WAF 转发是「原样转发」(engine.py 的 putrequest(..., skip_host=True),请求头逐条复制),nginx 传的 X-Real-IP 能正常传到后端。但——后端 nginx 的 access.log 默认记的是 TCP 对端 IP,也就是 127.0.0.1。直接影响:LYH-Blogs/deploy/visitor-report.sh(181 行)是按 access.log 的第 1 列(IP)做统计的,上线后会完全失效,所有记录都变成 127.0.0.1。
改动落在内部 server(lyh-blogs-internal),公开 server 一个字没动。
改动 A:让内部 server 也记真实 IP(realip 模块)
原代码(server 块开头):
server { listen 127.0.0.1:8080; server_name lyh-blogs.top www.lyh-blogs.top;
root /var/www/lyh-blogs;改成:
server { listen 127.0.0.1:8080; server_name lyh-blogs.top www.lyh-blogs.top;
# ---- 改动 A:还原真实访客 IP ---- set_real_ip_from 127.0.0.1; real_ip_header X-Real-IP;
# ---- 改动 B:把内部日志分出去 ---- access_log /var/log/nginx/access-internal.log waf_backend;
root /var/www/lyh-blogs;(文件里这两段各自带了大段注释,这里只摘指令行。完整内容见
deploy-blog/nginx-internal-8080.conf。)
set_real_ip_from + real_ip_header 是 nginx 内置的 realip 模块(这个模块 Ubuntu 的 nginx 包默认就编译进来了,实测nginx -V | grep realip 有 --with-http_realip_module,不用装任何东西)。它做的是:当对端地址在 set_real_ip_from 列出的范围内时,就把指定头里的地址覆写进 $remote_addr。
改动 B:给内部 server 单独的日志文件
只加 realip 是不够的 —— 两行日志依然存在,翻倍问题还在。
所以还要把内部 server 的日志分出去:公开的 access.log 恢复
「一次客户端请求一行」,内部单独留一份。
改动 C:新增一个日志格式(/etc/nginx/conf.d/waf-realip-logformat.conf)
log_format waf_backend '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' 'peer=$realip_remote_addr';为什么要多记一个 peer=:realip 把 $remote_addr 改写掉之后,“到底是谁连上来的”这条信息就丢了 —— 而排查回源问题时它恰恰有用。所以这个格式同时记两个地址,语义和 WAF 审计日志的 client / peer 一一对应:
-
$remote_addr→ 拆掉代理后真正的访客 IP -
$realip_remote_addr→ 真正的 TCP 对端(这里是 WAF 的127.0.0.1)
为什么这个文件放 conf.d 而不是改 nginx.conf:log_format 只允许定义在http 上下文,不能写在 server 块里。而 nginx.conf 第 59 行有include /etc/nginx/conf.d/*.conf;,这一行本身就在 http 块内部 ——所以往 conf.d 丢一个文件就够了,nginx.conf 一个字都不用动。
最关键的一点:为什么采信 X-Real-IP 在这里是安全的
这个问题和问题 1 里的 _client_ip() 是同一个原理,值得对照看:
-
内部 server 只
listen 127.0.0.1,只有本机进程能连上来,唯一会连它的是 waf-lab WAF → “对端是 127.0.0.1” 这个条件结构上必然成立,不存在”外部伪造一个头”的可能 -
所以
set_real_ip_from 127.0.0.1;是精确到位的信任边界
这两行绝对不能放进 http 块或公开 server:公开 server 的对端是全世界,一旦采信客户端自带的 X-Real-IP,任何人加一个 X-Real-IP: 1.2.3.4就能污染访客报表。信任边界是”谁连上来的”,不是”请求里有没有这个头”。
实测验证(从外网发伪造头):
$ curl -sk -o /dev/null -H "X-Real-IP: 9.9.9.9" \ -H "X-Forwarded-For: 8.8.8.8" "https://lyh-blogs.top/?spoof-1791773920"
=== 公开日志(第 1 列仍是真实出口 IP,不是 9.9.9.9)===123.207.244.94 - - [12/Oct/2026:10:58:40 +0800] "GET /?spoof-1791773920 ..." 200 402413
=== 内部日志(同样真实 IP)===123.207.244.94 - - [12/Oct/2026:10:58:40 +0800] "GET /?spoof-1791773920 ..." 200 402413 peer=127.0.0.1
=== 有没有 9.9.9.9 / 8.8.8.8 混进任何一份日志 ===/var/log/nginx/access.log:0/var/log/nginx/access-internal.log:0伪造被挡住,原因是公开 server 里有一行proxy_set_header X-Real-IP $remote_addr; ——proxy_set_header 是覆盖语义,客户端自带的那个头会被直接替换掉。
单变量对照:同一个伪造头,换个位置发,结果完全不同。
这组对照是”信任边界”这件事最直观的证明 ——三次测试只改了「从哪里发」这一个变量,头的内容一模一样:
(1)从外网发,经过公开 nginx(对端 = 全世界) curl -H "X-Real-IP: 203.0.113.9" https://lyh-blogs.top/ → 内部日志记的是真实出口 IP,203.0.113.9 被丢掉 ← 不采信 ✔
(2)本机直连 8888(WAF),带同一个头 → 对端是回环,WAF 采信它并当成限速桶的 key
(3)本机直连 8080(内部 server),带同一个头 curl -H "X-Real-IP: 203.0.113.9" http://127.0.0.1:8080/ → 内部日志:203.0.113.9 - - [...] "GET / HTTP/1.1" 200 402413 peer=127.0.0.1 ← 采信了结论:决定”信不信这个头”的不是头本身,而是”谁连上来的”。(1)和(3)的头长得一模一样,区别只在 TCP 对端是不是回环地址 ——而 (3) 这种”从本机打”的能力在服务器上本来就等价于已经拿到了本机权限,所以采信它是安全的。
顺带验证了 realip 的兜底路径:直连 8080 不带 X-Real-IP 时不会报错,
$remote_addr 保持 127.0.0.1:
127.0.0.1 - - [...] "GET / HTTP/1.1" 200 402413 peer=127.0.0.1顺带发现一个坑:同一段配置里还有proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;,而 $proxy_add_x_forwarded_for 是追加语义 —— 客户端自带的X-Forwarded-For: 8.8.8.8 会变成 8.8.8.8, 真实IP。**所以 XFF 是客户端可控的,永远不能当真实 IP 用。**这次选 X-Real-IP(覆盖语义)而不是 XFF,正好避开了这个坑。
验证
1)日志分离 + 真实 IP 还原(改动后的新请求):
站点状态: 200
=== 公开 access.log:命中行数(期望 1)===1123.207.244.94 - - [12/Oct/2026:10:58:31 +0800] "GET /?verify-1791773911 ..." 200 402413
=== 内部 access-internal.log:命中行数(期望 1)===1123.207.244.94 - - [12/Oct/2026:10:58:31 +0800] "GET /?verify-1791773911 ..." 200 402413 peer=127.0.0.12)改成”改前 vs 改后”的量化对比(改动时间点 10:57):
=== 公开 access.log 里 127.0.0.1 的行数 === 今天全部: 166 ← 10:57 之前累积的 10:57 之后: 0 ← 改动生效后再没出现过
=== 内部 access-internal.log 的第 1 列分布 === 4 123.207.244.94 ← 全是真 IP,没有 127.0.0.1
=== 内部日志的 peer= 列 === 4 peer=127.0.0.1 ← 恒为 WAF,符合预期3)报表脚本不受影响:
=== 日志范围 === 文件数: 15 最早: [03/Oct/2026:00:02:07 +0800] 最新: [12/Oct/2026:10:59:53 +0800]脚本读的是 access.log.1 和 access.log(第 27 / 30 行写死的路径),新文件 access-internal.log 不会被它读到,所以脚本一行都不用改。(access.log.*.gz 这个通配也不会误匹配 access-internal.log.1.gz ——前缀就对不上。)
4)权限对齐(一个小坑):
nginx 新建 access-internal.log 时用 root:root 0644 创建,而原有的 access.log 是 www-data:adm 0640。差别在于新文件是全局可读的 ——访客 IP 会被机器上任何本地用户看到。已改成和原文件一致:
-rw-r----- 1 www-data adm 253 /var/log/nginx/access-internal.log-rw-r----- 1 www-data adm 196201 /var/log/nginx/access.log改完再打一个请求,grep -c 返回 1 —— 说明 nginx 仍能正常写入(worker 用的是从 master 继承下来的文件描述符,不受属主限制)。
5)日志轮转不用管:/etc/logrotate.d/nginx 的规则是/var/log/nginx/*.log { daily rotate 14 ... },新文件自动被覆盖,而且 create 0640 www-data adm 会让轮转后重建的文件恢复正确权限。
6)回归:首页 200、404 页 348453 字节(自定义 404 仍在)、静态资源 200、安全头只出现 1 次、waf-lab 与 nginx 均 active、WAF 审计日志继续写(client=123.207.244.94 peer=127.0.0.1:47154)。
问题 3:单点故障 —— WAF 一挂,博客全挂
中间多了一层进程,它挂掉就是 502。需要:
-
systemd 单元 +
Restart=always,进程死了自动拉起 -
一条「一键回滚」命令(见第六节),随时能切回直连静态文件
实测结果:停掉 WAF 后立刻 502,拉起来后 1~2 秒内恢复 200。故障窗口是真实存在的,靠 Restart=always + RestartSec=2 自动拉起把窗口压到最短。要彻底消除只能上双实例 + 负载均衡,对个人博客不值得。
其他
-
上线先跑审计模式。README 自己的原话:「一上来就硬拦的 WAF,通常活不过一周」。
--mode audit会完整跑一遍检测但把「拦截」降级成「记录」 -
加
--quiet-headers。默认的 403 页面里带X-WAF-Rule-Ids,等于告诉攻击者你是被哪条规则拦的。生产必须关掉 -
每请求新建一条到后端的 TCP 连接。
engine.py第 200 行建连接、第 232 行conn.close(),没有连接池。静态博客流量低,可以接受,但要知道这个开销存在 -
白名单观察点。
default.rules里只有两条allow(4001健康检查、4002静态资源扩展名),博客特有的/pagefind/、/404.html、RSS、sitemap 这些路径要在 audit 阶段重点看有没有被误判 -
default.rules刻意不含站点特征(第 29~35 行注释写明「没有 /wp-admin、没有 /api/v1」)。博客专属规则以后放rules/site-lyhblogs.rules
具体实现
在本地准备文件
- 确认本地自检全过:
python3 tools/selftest.py # 35/35 通过python3 tools/e2e_test.py # 17/17 通过- 准备三份配置文件(
deploy-blog/setup-waf-on-server.sh会从/tmp读它们):
-
deploy-blog/nginx-internal-8080.conf→ 上传为/tmp/nginx-internal-8080.conf—— 内部 server 块,只监听127.0.0.1:8080,含 realip 还原与独立日志(改动 A/B 都在这个文件里) -
deploy-blog/nginx-realip-logformat.conf→ 上传为/tmp/nginx-realip-logformat.conf—waf_backend日志格式定义,装进/etc/nginx/conf.d/(log_format只能定义在http上下文,而nginx.conf里本来就有include /etc/nginx/conf.d/*.conf;) -
deploy-blog/waf-lab.service→ 上传为/tmp/waf-lab.service—— systemd 单元
顺序有讲究:脚本第 3a 步先装日志格式文件、第 3b 步才装 server 块。 因为 server 块里的
access_log ... waf_backend引用了那个格式名, 格式没定义就reload会直接失败。所以这两个文件必须都在, 不能只传 server 块。
调成audit 模式还需要第四份文件
deploy-blog/nginx-public-proxy.conf—— 公开站点配置,把443从”直接读静态文件”改成”反代到127.0.0.1:8888”。它会覆盖/etc/nginx/sites-available/lyh-blogs,所以改前必须先备份
服务器准备
# 0. 先备份真实的线上配置 —— certbot 改写过它,和仓库里的版本不一样sudo cp /etc/nginx/sites-available/lyh-blogs /etc/nginx/sites-available/lyh-blogs.bak.$(date +%Y%m%d)sudo nginx -T | sed -n '/lyh-blogs/,/^}/p' # 看实际生效的内容
# 1. 传 WAF 代码到 /opt/waf-lab# 2. 确认能加载规则cd /opt/waf-lab && python3 run.py --list-rules | head -20实际执行方式:把代码打包后用 tar 管道推进 /tmp/waf-lab-src,再由
deploy-blog/setup-waf-on-server.sh 一次装完。脚本里有一处刻意的写法值得记一下:
# 用 $USER 而不是写死 root:这个脚本会被普通用户执行,内部自己调 sudo。# 如果整个脚本用 sudo 跑,$USER 会变成 root,后面 chown 就把 /opt/waf-lab# 的属主设成 root,普通用户再也写不进去。sudo chown -R "$USER":"$USER" "$DEST"加内部静态 server(8080),线上行为不变
在 nginx 里新增一个只监听回环的 server,照搬现有静态站配置:
server { listen 127.0.0.1:8080; server_name lyh-blogs.top www.lyh-blogs.top; root /var/www/lyh-blogs; index index.html; location / { try_files $uri $uri/index.html =404; } location ^~ /_astro/ { expires 1y; } location ~* \.(woff2?|ttf|otf|webp|avif|png|jpe?g|gif|svg|ico)$ { expires 30d; } location ^~ /pagefind/ { expires 7d; } error_page 404 /404.html; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;}对应的 server 块同步落到
/etc/nginx/sites-available/lyh-blogs-internal(源文件是
deploy-blog/nginx-internal-8080.conf)。
验证(这一步公开的 443 还没动,线上无变化):
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: lyh-blogs.top" http://127.0.0.1:8080/# 实测 200起 WAF 服务(systemd),不接管流量
这一阶段的 WAF 已经跑起来了,但没有任何流量经过它 ——公开的 443 仍然直连静态文件。所以它此刻是「空转」,出了问题也不影响博客。
systemd 单元(deploy-blog/waf-lab.service):
ExecStart=/usr/bin/python3 /opt/waf-lab/run.py \ --backend 127.0.0.1:8080 --listen 127.0.0.1:8888 \ --mode audit --quiet-headers --quiet \ --log-dir /opt/waf-lab/logsRestart=always注意这里没有 --rate-limit 0 —— 因为限速是选 (b) 修的(读 X-Real-IP),
限速保留、正常参与计分。这正是前面那些排查的价值:不修的话这里只能把限速关掉。
公开 443 改成反代到 WAF(audit 模式)
实际做法:不是只改 location /,而是把公开 server 改成”只会转发”的形态 ——root / index / try_files / 三个 expires location / error_page / gzip全部从公开 server 里删掉(它们本来就已经在内部 server 上有一份了)。
为什么必须删干净,而不是”留着静态规则、只把 location / 改成反代”:**只要公开 server 自己还留着任何一条能直接返回文件的 location,那部分请求就会绕过 WAF。**最坑的是这种缺口不容易被发现 —— 你测首页是”过了 WAF”,测图片却没经过。
最终配置:
server { server_name lyh-blogs.top www.lyh-blogs.top;
location / { proxy_pass http://127.0.0.1:8888; # ← 唯一的 location,其余全删 proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 30s; }
# certbot 管理的 6 行原样保留 listen [::]:443 ssl ipv6only=on; # managed by Certbot listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/lyh-blogs.top/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/lyh-blogs.top/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;}这里刻意没有 gzip 和 add_header。 gzip 和三个安全响应头在内部 server上已经加了一份,WAF 是「原样转发」,后端返回什么客户端就收到什么。在公开 server 再加一遍会得到重复响应头(客户端会看到两个X-Content-Type-Options)。
WAF 参数没有改。 原计划里的 --rate-limit 0 没有用上,因为限速是走
「改代码读 X-Real-IP」那条路修的(见问题 1),限速保留、正常参与计分。
实际操作命令:
# 1. 备份真实生效的配置(certbot 改写过,和仓库里的版本不一样)sudo cp /etc/nginx/sites-available/lyh-blogs \ /etc/nginx/sites-available/lyh-blogs.bak.$(date +%Y%m%d-%H%M)
# 2. 覆盖成反代配置sudo cp /tmp/lyh-blogs.new /etc/nginx/sites-available/lyh-blogs
# 3. 检查 + 重载(reload 不是 restart:现有连接不断)sudo nginx -t && sudo systemctl reload nginx观察后切 block
先看 audit 日志里有多少正常请求会被判定为「本应拦截」:
jq -c 'select(.would_block==true)' /opt/waf-lab/logs/*.jsonl | head -50实测分析(88 个请求):would_block=true 共 7 条,7 条 User-Agent 全部是 curl/7.81.0,非 curl 的 would_block 条数为 0 ——也就是说窗口内没有一个正常访客会被误拦。
确认后改一行、重启:
sudo cp /etc/systemd/system/waf-lab.service \ /etc/systemd/system/waf-lab.service.bak.$(date +%Y%m%d-%H%M)sudo sed -i 's/--mode audit/--mode block/' /etc/systemd/system/waf-lab.servicesudo systemctl daemon-reload && sudo systemctl restart waf-lab改动只有 audit → block 这一个词。 验证方式是同时看三处:
$ grep -n "mode" /etc/systemd/system/waf-lab.service26: --mode block \
$ ps -eo args | grep -m1 "[r]un.py"/usr/bin/python3 /opt/waf-lab/run.py --backend 127.0.0.1:8080 \ --listen 127.0.0.1:8888 --mode block --quiet-headers --quiet \ --log-dir /opt/waf-lab/logs回滚路径双向验证过:切回 audit 后 /.env 变回 404(后端 nginx 自己的 404,说明请求被放行到了后端),/ 仍是 200;再切回 block 后 /.env 变 403、/ 仍 200。
回滚方案
上线前就准备好了,并且验证过可用
当前实际备份文件:/etc/nginx/sites-available/lyh-blogs.bak.20261012-0931
sudo cp /etc/nginx/sites-available/lyh-blogs.bak.20261012-0931 \ /etc/nginx/sites-available/lyh-blogs \ && sudo nginx -t \ && sudo systemctl reload nginx \ && sudo systemctl stop waf-lab用 reload 而不是 restart:现有连接不断,出问题时手上还有活路。
注意:systemctl stop waf-lab 要放在 reload 之后 —— 换成直连配置后就不再依赖 WAF 了,这时候停掉它才是安全的。顺序颠倒的话中间会有几秒是「公开 server 还指向 WAF,但 WAF 已经停了」→ 502。
这条命令会同时完成两件事:nginx 恢复直读静态文件、WAF 停止。等于回到加内部静态 server之前的状态。
验收清单
-
首页
200,且响应头里有当时的Date(确认不是缓存)——Date是当时时间,非缓存 -
文章页、归档页、关于页均
200——/about/200、/archive/200 -
不存在的路径返回
404(不是403)—— 且返回的是博客自定义的 404 页(348453 字节),说明error_page 404 /404.html通过 WAF 转发仍然生效 -
/_astro/资源带Cache-Control: max-age=31536000—— 实测Expires: Tue, 12 Oct 2027(一年后)+max-age=31536000 -
响应头仍含
X-Content-Type-Options: nosniff—— 且只出现 1 次(没有重复) -
curl -H "Host: lyh-blogs.top" http://127.0.0.1:8080/返回200(内部 server 通) -
ss -tlnp里 8888 和 8080 只绑在 127.0.0.1,不是0.0.0.0 -
拿攻击载荷打一下 → 全部被正确判定(audit 模式只记录): SQL 注入
100分、XSS115分、命令注入100分、query 穿越170分, 全部would_block=True;两个路径穿越载荷由 nginx 自己400拦掉(见下) -
审计日志能正常写入
/opt/waf-lab/logs/,且client字段是真实访客 IP(实测client=123.207.244.94,不是127.0.0.1) -
systemctl stop waf-lab后确认502,systemctl start waf-lab后恢复200(单点故障真实存在,但可控) -
用 IP 或任意 Host 直接访问
https://→ 仍是200(行为与切换前一致) -
备份配置本身能通过
nginx -t(回滚路径可用,不只是”文件存在”) -
一次客户端请求在
access.log里只有 1 行,且第 1 列是真实 IP—— 改动前是 2 行(127.0.0.1+ 真 IP) -
127.0.0.1不再出现在access.log:今天累计 166 行,(reload 时刻)之后 0 行 -
access-internal.log第 1 列全是真实 IP、末列peer=恒为127.0.0.1 -
从外网伪造
X-Real-IP: 9.9.9.9+X-Forwarded-For: 8.8.8.8打进来,两份日志各 0 条被污染
测试记录
在部署前先看看服务器状态
$ curl -sI https://lyh-blogs.top/ | head -3HTTP/1.1 200 OKServer: nginx/1.18.0 (Ubuntu)Date: Sun, 11 Oct 2026 16:55:49 GMTContent-Length: 402413
$ grep -rnE 'ssl|wrap_socket|SSLContext' /Users/liyuhang/代码库/waf-lab --include='*.py'(无匹配)
$ cd /Users/liyuhang/代码库/LYH-Blogs && git remote -vorigin git@github.com:LYH-creative/LYH-Blogs.git服务器连通性(2026-10-12 实测,只读命令)
$ nc -z -v -w 6 123.207.244.94 22123.207.244.94 22 (ssh) open$ nc -z -v -w 6 123.207.244.94 443123.207.244.94 443 (https) open
$ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes ubuntu@123.207.244.94 \ 'whoami; hostname; uptime | head -1; free -m | head -2; sudo -n true && echo SUDO_NOPASSWD'ubuntuVM-0-4-ubuntu 01:02:50 up 16 days, 13:29, 0 users, load average: 0.07, 0.02, 0.00 total used free shared buff/cache availableMem: 1963 352 159 3 1451 1428SUDO_NOPASSWD结论:免密登录可用、SUDO_NOPASSWD 可用(后续可全程自动化);内存 1963MB 中可用 1428MB,负载 0.07,跑一个多线程 Python WAF 余量充足。
加静态server
$ bash /tmp/setup-waf-on-server.sh== 0. 环境自检python 3.10.12== 1. 安装 WAF 代码到 /opt/waf-lab已安装文件数:15== 2. 确认规则能加载(这一步会打印规则表)已加载 30 条规则(拦截阈值 100 分)== 3. 装内部静态 server(127.0.0.1:8080)nginx: configuration file /etc/nginx/nginx.conf test is successful== 4. 装 systemd 单元(先不启动,下一步再起)== 5. 启动 WAF waf-lab: active== 6. 验证(三层链路逐层测)--- 6a. 内部静态 server 直连 --- 127.0.0.1:8080 -> 200--- 6b. 经过 WAF(WAF -> 8080)--- 127.0.0.1:8888 -> 200 带 X-Real-IP 的攻击路径 -> 400--- 6c. 监听端口(8080/8888 必须只绑 127.0.0.1)---LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("nginx",pid=...,fd=...))LISTEN 0 5 127.0.0.1:8888 0.0.0.0:* users:(("python3",pid=1273268,fd=3))--- 6d. 公开站点应保持 200 不变 --- https://lyh-blogs.top/ -> 200逐条解读:
-
8080 -> 200:内部静态 server 通,能正确读到
/var/www/lyh-blogs -
8888 -> 200:WAF 自己也能正常转发(WAF → 8080)
-
带 X-Real-IP 的攻击路径 -> 400:这条一开始看着像 WAF 在拦,其实不是,下面单独讲
-
两个端口都只绑
127.0.0.1:这是关键安全前提 —— 公网扫不到它们,也就不可能绕过 nginx 直接怼 WAF 或直接读文件 -
线上 443 仍是 200:证明这一阶段确实没有改变线上行为
关于那个 400 —— 一次差点误判的排查
按设计,--mode audit 下 WAF 会把「拦截」降级成「记录」,所以不应该返回 403;
但实测返回的是 400,这看着像哪里判错了。做了一次单变量对照:
-
对照组 A:跳过 WAF,直接打
127.0.0.1:8080,同样带--path-as-is和攻击路径 → 也是 400 -
结论:这个 400 是后端 nginx 自己返回的,WAF 只是把它的响应原样转发了回来。nginx 的
try_files $uri $uri/index.html =404面对/icons/.%2e/...这种解码后越出 root 的路径,在规范化阶段就拒绝了 -
也就是说:WAF 行为正确,且判定确实发生了 —— 只是发生在 audit 模式下, 只记录不拦截。要看它到底判了什么,得去翻审计日志
翻日志(真实输出,已脱敏处理 target 以外的内容):
client=203.0.113.9 peer=127.0.0.1:36174 GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd -> score=205 action=log would_block=True hit: 1001 uri[path] '../' hit: 1001 path[path] '../' hit: 1002 uri[raw] '.%2e' hit: 1002 path[raw] '.%2e' hit: 1010 uri[path] '/etc/passwd' hit: 1010 path[path] '/etc/passwd'读法:
-
score=205≥ 阈值100,would_block=True—— 如果不在 audit 模式,这个请求会被 403 -
三条规则同时命中(
100170 分 +100235 分 +1010100 分 = 205 分,和日志里的score=205对得上)—— 注意1010在uri/path两个片段上都命中了,日志里是两行hit,但分数只算一次 -
这正是前面讲过的「异常评分」机制:单看任何一条都不足以定性,累加起来才过线
-
action=log而不是block,是 audit 模式的降级结果,符合预期
真实 IP 修复验证
同一组探针,改代码前后对比。修复前(记的是 TCP 对端):
ip=127.0.0.1:55736 GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd ...修复后(engine.py 加了 _client_ip(),client 改记真实 IP):
== A. 带 X-Real-IP 的攻击请求 http=400== B. 不带 X-Real-IP 的正常请求 http=200
== 审计日志最后两条 client=203.0.113.9 peer=127.0.0.1:36174 GET /icons/.%%2e/.../etc/passwd -> score=205 action=log would_block=True client=127.0.0.1 peer=127.0.0.1:36188 GET / -> score=0 action=pass would_block=False-
请求里带了
X-Real-IP: 203.0.113.9→client=203.0.113.9 -
不带这个头(模拟直连场景)→
client=127.0.0.1,即退回 TCP 对端 -
peer始终是127.0.0.1:端口,因为 WAF 确实只被本机 nginx 连着
另外单独验了 _client_ip() 的边界条件(本地跑,6 个用例全过):
-
对端回环 + 有
X-Real-IP→ 取头里的值 -
对端回环 + 无
X-Real-IP→ 退回对端 -
对端回环(IPv6
::1/::ffff:127.0.0.1)→ 同样采信 -
对端非回环 + 伪造
X-Real-IP: 1.2.3.4→ 忽略,退回对端(这条最关键,防止将来有人把 WAF 直接暴露到公网时限速被绕过)
本地回归:selftest.py 35/35 通过,e2e_test.py 17/17 通过(e2e 里第 3 组「透明转发」4 项全过,说明这次改动没碰坏转发链路)。
上线 + 完整测试
测试方法:所有命令都在服务器上执行,通过公网域名 https://lyh-blogs.top发请求(而不是打回环口),因为这才是真实用户的路径。涉及攻击载荷的用curl --path-as-is,避免 curl 自己先把路径归一化了 —— 那会让测试结果失真。
测试 1:基本页面
$ for p in / /about/ /archive/ /posts/; do curl -s -o /dev/null -w "$p -> %{http_code}\n" https://lyh-blogs.top$p; done / -> 200 /about/ -> 200 /archive/ -> 200 /posts/ -> 404/posts/ 返回 404 是预期的:站点用 trailingSlash: always,主题没有单独的/posts/ 列表页,该目录下只有各篇文章的子目录。原配置注释里写明了这一点,切换 WAF 前后行为一致。
测试 2:不存在的路径
$ curl -s -o /dev/null -w "%{http_code}\n" https://lyh-blogs.top/no-such-page-xyz404$ curl -s https://lyh-blogs.top/no-such-page-xyz | wc -c348453返回的是博客自定义的 404 页(348453 字节,不是 nginx 默认的 134 字节小页面),说明内部 server 的 error_page 404 /404.html 经过 WAF 转发仍然生效。
测试 3:响应头(确认不是缓存命中的旧响应)
$ curl -sI https://lyh-blogs.top/ | head -20HTTP/1.1 200 OKServer: nginx/1.18.0 (Ubuntu)Date: Mon, 12 Oct 2026 01:35:01 GMTContent-Type: text/htmlContent-Length: 402413Connection: keep-aliveLast-Modified: Sat, 26 Sep 2026 10:14:41 GMTVary: Accept-EncodingETag: "6ab79b11-623ed"X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originAccept-Ranges: bytes要点:
-
Date是当时的时间(不是缓存的旧值) -
Content-Length: 402413与切换前完全一致 —— WAF 转发没有破坏 body -
Connection: keep-alive—— WAF 的 keep-alive 处理正确
测试 4:安全响应头有没有被加两遍
这一项是专门验证「公开 server 里刻意不写 add_header」这个决定的。
$ curl -sI https://lyh-blogs.top/ | grep -ci 'x-content-type-options'1$ curl -sI https://lyh-blogs.top/ | grep -ci 'x-frame-options'1$ curl -sI https://lyh-blogs.top/ | grep -ci 'referrer-policy'1每个都只出现 1 次。 如果当初在公开 server 上也保留 add_header,这里会得到 2 —— 客户端拿到重复响应头。判断正确。
测试 5:页脚备案号
$ curl -s https://lyh-blogs.top/ | grep -o '粤ICP备[0-9]*号'粤ICP备2026145822号测试 6:/_astro/ 静态资源缓存
$ A=$(curl -s https://lyh-blogs.top/ | grep -o '/_astro/[A-Za-z0-9._-]*\.js' | head -1)$ echo $A/_astro/page.B4KCMVVQ.js$ curl -sI "https://lyh-blogs.top$A" | grep -iE '^(HTTP|Cache-Control|Expires|Content-Type)'HTTP/1.1 200 OKContent-Type: application/javascriptExpires: Tue, 12 Oct 2027 01:35:11 GMTCache-Control: max-age=31536000expires 1y 指令写在内部 server 上,经过 WAF 转发照样到位。
测试 7:流量到底有没有经过 WAF
不能只看”页面能打开”就认为 WAF 生效了 —— 万一 proxy_pass 写错、请求其实还走老路呢。用日志增量来证明:
$ LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonl$ BEFORE=$(sudo wc -l < $LOG) # 19$ curl -s -o /dev/null https://lyh-blogs.top/marker-through-waf-test$ AFTER=$(sudo wc -l < $LOG) # 20 新增 1 条
$ sudo tail -n 3 $LOG client=123.207.244.94 GET / -> 200 client=123.207.244.94 HEAD /_astro/page.B4KCMVVQ.js -> 200 client=123.207.244.94 GET /marker-through-waf-test -> 404两个关键证据:
-
一次请求换一次日志增量,说明流量确实穿过 WAF 了
-
client=123.207.244.94—— 这是真实 IP(curl 在服务器上执行,访问自己的公网域名,源 IP 就是这台机的公网 IP)。这一条同时验证了三件事:nginx 传了X-Real-IP、WAF 的_client_ip()采信了它、审计日志记对了字段。如果_client_ip()当初没修,这里会是127.0.0.1。
测试 8:攻击载荷(audit 模式,只记录不拦截)
$ probe "/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd" "路径穿越 · %2e 编码(CVE-2021-41773 同款)" 路径穿越 · %2e 编码(CVE-2021-41773 同款) -> 400$ probe "/icons/%2e%2e/%2e%2e/%2e%2e/etc/passwd" "路径穿越 · 纯 %2e%2e" 路径穿越 · 纯 %2e%2e -> 400$ probe "/a%2eb" "编码点号(非穿越,白名单样本)" 编码点号(非穿越,白名单样本) -> 404$ probe "//double//slash" "重复斜杠(白名单样本)" 重复斜杠(白名单样本) -> 404$ probe "/etc/passwd" "直接要敏感文件" 直接要敏感文件 -> 404$ probe "/.env" "探测 .env" 探测 .env -> 404$ probe "/.git/config" "探测 .git 泄露" 探测 .git 泄露 -> 404$ probe "/wp-admin/" "扫描 WordPress 路径" 扫描 WordPress 路径 -> 404$ probe "/?id=1+union+select+1,2,3" "SQL 注入(query)" SQL 注入(query) -> 200$ probe "/?key=<script>alert(1)</script>" "XSS(query)" XSS(query) -> 200$ probe "/?cmd=cat%20/etc/passwd" "命令注入样式参数" 命令注入样式参数 -> 200$ probe "/search?q=../../../../etc/passwd" "query 里的路径穿越" query 里的路径穿越 -> 200同样是 audit 模式,所以 200 / 404 / 400 都是”放行”的结果 — WAF 没有拦任何东西,它只是在记录。判定结果要去日志里看。
测试 9:WAF 对这些载荷的判定
$ sudo tail -n 12 $LOG GET score=0 rules=- resp=404 /a%2eb pass GET score=0 rules=- resp=404 //double//slash pass GET score=100 rules=1010 resp=404 /etc/passwd log WOULD-BLOCK GET score=0 rules=- resp=404 /.env pass GET score=0 rules=- resp=404 /.git/config pass GET score=0 rules=- resp=404 /wp-admin/ pass GET score=100 rules=1020 resp=200 /?id=1+union+select+1,2,3 log WOULD-BLOCK GET score=115 rules=2001,2007 resp=200 /?key=<script>alert(1)</script> log WOULD-BLOCK GET score=100 rules=1010 resp=200 /?cmd=cat%20/etc/passwd log WOULD-BLOCK GET score=170 rules=1001,1010 resp=200 /search?q=../../../../etc/passwd log WOULD-BLOCK GET score=0 rules=- resp=200 / pass GET score=0 rules=4002 resp=200 /favicon/favicon.ico allow逐条读:
-
判定正确的 5 条:
/etc/passwd(1010 敏感文件,100 分)、SQL 注入(1020,100 分)、XSS(2001 + 2007,115 分)、命令注入样式(1010,100 分)、query 里的穿越(1001 + 1010,170 分)。全部would_block=True—— 切到 block 模式后这些都会被 403 -
白名单工作正常:
/favicon/favicon.ico命中规则 4002(静态资源扩展名白名单)→action=allow,直接放行,连限速都不判 -
两个穿越载荷没出现在这里 —— 这是本次测试最重要的发现,单独讲
发现 1(重要):%2e%2e 路径穿越请求到不了 WAF —— 公开的 nginx 自己先 400 了。
日志里搜不到那两个穿越载荷,但它们在测试 8 里明明返回了 400。做三路对照定位400 是谁发的:
$ curl --path-as-is -H 'Host: lyh-blogs.top' "http://127.0.0.1:8080/icons/.%2e/..." 8080 直连(nginx 内部 server) -> 400$ curl --path-as-is -H 'Host: lyh-blogs.top' -H 'X-Real-IP: 203.0.113.9' "http://127.0.0.1:8888/icons/.%2e/..." 8888 直连(WAF) -> 400 ← 但 WAF 记了日志:score=205$ curl --path-as-is "https://lyh-blogs.top/icons/.%2e/..." 443 公网(nginx 公开 server + WAF) -> 400 ← WAF 没记日志再去 nginx 的 access.log 里数「同一次请求产生几行」——这是最干净的判据。(注意:这里测的时候两份日志还混在一起,所以判据是”两行”。后来问题 2 修掉了日志混写,判据变成了”看内部日志有没有对应的行”,见下面「判据更新」和第三节问题 2。)
$ sudo grep -E '12/Oct/2026:09:3[56]' /var/log/nginx/access.log 123.207.244.94 - - [...] "GET /no-such-page-xyz HTTP/1.1" 404 348453 "-" "curl/7.81.0" 127.0.0.1 - - [...] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.81.0" 123.207.244.94 - - [...] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.81.0" ... 123.207.244.94 - - [...] "GET /icons/.%2e/%2e%2e/.../etc/passwd HTTP/1.1" 400 166 "-" "-" 123.207.244.94 - - [...] "GET /icons/%2e%2e/%2e%2e/%2e%2e/etc/passwd HTTP/1.1" 400 166 "-" "-" 127.0.0.1 - - [...] "GET /a%2eb HTTP/1.1" 404 348453 "-" "curl/7.81.0" 123.207.244.94 - - [...] "GET /a%2eb HTTP/1.1" 404 348453 "-" "curl/7.81.0"读法:每个正常请求都产生两行 —— 一行 127.0.0.1(WAF 转发到内部 server),一行真实 IP(客户端打到公开 server)。而那两个穿越请求只有一行,且是真实 IP、没有对应的 127.0.0.1 行。
判据更新之后:日志混写修掉之后,access.log 里一次请求只剩一行,上面这个”数两行”的判据失效了。新的判据更直接 —— 看内部日志有没有对应的行:
-
请求正常走完链路 →
access.log1 行 +access-internal.log1 行 -
请求被公开 nginx 在 URI 解析阶段 400 →
access.log1 行 +access-internal.log0 行(因为压根没走到proxy_pass)
也就是说:**access-internal.log 里没有这条记录,就说明请求没进到 WAF 后面。**比数行数更不容易看错。
结论:400 是公开的 nginx 在 URI 解析阶段返回的,请求在 location /匹配之前就被拒了,压根没有进入 proxy_pass。所以 WAF 看不到它。
这意味着什么:
-
好消息:这是纵深防御。nginx 1.18 的 URI 解析器会解码
%2e%2e→ 解析点段 → 一旦要越出根目录就直接回 400,比 WAF 还靠前 -
但不是”可以不管”:WAF 的规则 1001 / 1002(路径穿越)在这套架构下实际上是死代码。它只在「WAF 前面没有 nginx」,或者「前置服务器的 URI 解析行为与后端不一致」时才会发挥作用。这个事实要记下来,否则以后看日志会发现「穿越规则从来没命中过」而误以为规则写错了
-
也说明「WAF 装在最前面就能挡住所有攻击」是个错误预期 ——每一层能看到的流量范围不一样
补测:把 nginx 的判定边界卡准。 上面只知道”%2e%2e 会 400”,但边界到底在哪?做了一轮特征化测试:
---- 点段回溯但不逃出根目录 ---- /a/../b 404 从 /a/ 回退一级 -> /b,没出根 /a/b/../c 404 回退一级后继续,没出根 /a/..//b 404 回退 + 重复斜杠 /./../ 400 当前目录点 + 回退一级,正好到根---- 点段回溯且逃出根目录 ---- /../ 400 根之上一级 /a/../../b 400 从 /a/ 回退两级 -> 出根 /%2e%2e/ 400 编码的根之上一级 /a/%2e%2e/%2e%2e/b 400 编码 + 出根 /%2e%2e 400 无斜杠也 400(解码成 /.. 同样越根)---- 载荷放在 query 里(不参与路径解析)---- /?q=/../../etc/passwd 200 query 里的穿越 /?q=%2e%2e%2f%2e%2e%2f 200 query 里的全编码穿越---- 编码的点号但不构成点段 ---- /a%2e%2eb 404 %2e%2e 拼在文件名里,没有斜杠分隔 /%2e%2e%2f 400 编码的 ../规则很清晰:逐段解析点段,只要累积结果要越过根目录就是 400;不越界一律放行。query string 完全不参与这个判断。
注意 /%2e%2e(连斜杠都没有)也是 400 —— 说明 nginx 确实把它解码成 ..之后才判断的。这条是下面”发现 3”的关键证据。
这 12 个探测里,只有 6 个到了 WAF。 剩下 6 个——正好就是上表里全部的 6 个400——一个都没进日志。这个比例本身就是”nginx 拒掉的请求 WAF 完全不知情”的量化证明。
发现 3:nginx 转发的是原始字节,不会先帮你解码或合并。
这条很容易想反。很多人以为”前面有 nginx,WAF 看到的是被归一化过的路径”,实测不是。凡是 nginx 接受的请求,WAF 收到的 target 和客户端发出的逐字节相同:
客户端发出 / WAF 收到: '/a/../b' ← 没被规范化成 /b客户端发出 / WAF 收到: '/a//b' ← 重复斜杠没被合并客户端发出 / WAF 收到: '/a/./b' ← 当前目录点没被去掉客户端发出 / WAF 收到: '/dir%2fname' ← %2f 没被解码成 /客户端发出 / WAF 收到: '/a%20b' ← %20 没被还原成空格客户端发出 / WAF 收到: '/%69ndex.html' ← %69 没被解码成 i但 nginx 自己内部一定是解码的,两条硬证据:
-
/%69ndex.html返回 200 ——%69就是i,不解码就找不到index.html -
/%2e%2e返回 400 —— 不先解成..就无从判断”越根”
所以它的行为是:解析一遍用于自查(匹配 location、映射文件、判越界),但转发时把原始形态交给上游。
这对 WAF 是好消息:view_raw 里的 .%2e 这类特征才有意义 — WAF 看到的就是攻击者”Literally 写出来的东西”,没被前置服务器洗过。反过来说,如果哪天换成一个会改写 URI 的前置层(某些网关、某些 CDN),WAF 的 raw 视图就废了一半。“WAF 的解码深度必须 ≥ 后端”这个要求,前提是前置层没有提前把证据抹掉。(说明:nginx 内部具体用的是哪个变量属于推断,上面列的是实测行为,不是读 nginx 源码得出的结论。)
顺手捡到一个真实的攻击样本。 在 access.log 里搜 %2e 时,翻到一条不是我们发的:
169.58.159.96 - - [12/Oct/2026:01:54:23 +0800] "POST /cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh HTTP/1.1" 400 166 "-" "-"这是 CVE-2021-41773 的标准利用形态(/cgi-bin/ + 十层 .%2e + /bin/sh),来自互联网上的主动扫描。结果同样是 nginx 直接 400,没进到 WAF。这条是本次部署最有说服力的现实依据:你的站点确实在被扫。
发现 2:WAF 有两处规则覆盖缺口。
-
/.env→ 0 分放行 -
/.git/config→ 0 分放行 -
/wp-admin/→ 0 分放行(这个default.rules是刻意不覆盖的,见规则文件第 29~35 行注释:“没有 /wp-admin、没有 /api/v1”,属于设计选择)
前两个是真缺口:.env 和 .git/config 是通用的敏感文件,不属于任何特定应用,理应进 default.rules。目前能挡住它们的是 nginx 的 try_files(返回 404),不是 WAF。**建议给规则 1010 补上 .env、.git、.svn、.DS_Store、composer.lock、package-lock.json 这类路径。**注意这只需改数据(规则文件),不用改代码 —— 这正是”规则是数据”这个设计的好处。
测试 10~12:三路对照定位 400 的来源
-
8080(nginx 内部 server)→
400,且access.log有127.0.0.1那行 -
8888(WAF)→
400,WAF 记了日志:score=205 rules=1001,1002,1010 -
443(公开 nginx + WAF)→
400,WAF 没记日志
三者的差别正好锁定了 400 的产生位置:在公开 nginx 里,比 WAF 更靠前。
顺带说明:因为拒绝发生在 URI 解析阶段(location 匹配之前),换 proxy_pass 的写法(比如改成 $request_uri)也救不回来 ——那个阶段已经过去了。
测试 13~14:nginx 自己的日志
$ sudo tail -n 15 /var/log/nginx/error.log2026/10/12 01:03:10 [error] ... open() "/var/www/lyh-blogs/apple-touch-icon-precomposed.png" failed (2: No such file or directory), client: 36.141.24.185, ...2026/10/12 01:10:16 [crit] ... SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::bad key share) while SSL handshaking, client: 212.102.40.218, ...2026/10/12 09:34:46 [notice] 1388332#1388332: signal process started ← 这次 reload两点:
-
没有因为这次改动产生新的 error(最后一条是本次 reload 的 notice)
-
SSL_do_handshake() failed ... bad key share是互联网上的 TLS 扫描器在试不支持的加密套件,与本次改动无关(01:10 就有,那时 WAF 还没接管流量) -
favicon.ico的 404 error 是有人在用 IP 直连访问,属于正常噪音
测试 15~16:定位「同一次请求两行日志」
$ sudo grep -c '%2e' /var/log/nginx/access.log14$ sudo grep '%2e' /var/log/nginx/access.log 127.0.0.1 - - [12/Oct/2026:01:06:54 +0800] "GET /icons/.%2e/... HTTP/1.1" 400 166 "-" "-" ... 169.58.159.96 - - [12/Oct/2026:01:54:23 +0800] "POST /cgi-bin/.%2e/.../bin/sh HTTP/1.1" 400 166 "-" "-" 123.207.244.94 - - [12/Oct/2026:09:36:04 +0800] "GET /icons/.%2e/... HTTP/1.1" 400 166 "-" "-" 123.207.244.94 - - [12/Oct/2026:09:36:04 +0800] "GET /icons/%2e%2e/... HTTP/1.1" 400 166 "-" "-" 127.0.0.1 - - [12/Oct/2026:09:37:46 +0800] "GET /icons/.%2e/... HTTP/1.1" 400 166 "-" "-" ...166 是 nginx 默认 400 页的体积,两种来源(127.0.0.1 和公开 IP)都是 166,说明**「浏览器看到的 400 页面长得一样,但生成它的那一层不一样」** ——只看状态码是无法区分的,必须看「有没有 WAF 日志」和「日志里有几行」。
改动后的现状:上面的 127.0.0.1 行从此不会再出现在 access.log 里 —— 它们全部搬到了 access-internal.log,而且第 1 列也变成了真实访客 IP(末尾带 peer=127.0.0.1)。所以这条 400 排查的判据要更新成”看 access-internal.log 里有没有这一条”。
测试 17:IP 直连与错误 Host
$ curl -sk -H 'Host: 123.207.244.94' -o /dev/null -w '%{http_code} %{size_download}\n' https://127.0.0.1/ 200 402413$ curl -sk -H 'Host: evil.example.com' -o /dev/null -w '%{http_code} %{size_download}\n' https://127.0.0.1/ 200 402413$ curl -s -H 'Host: evil.example.com' -o /dev/null -w '%{http_code} %{size_download}\n' http://127.0.0.1/ 404 162$ curl -s -H 'Host: lyh-blogs.top' -o /dev/null -w '%{http_code}\n' http://127.0.0.1/ 301-
443+ 任意 Host → 仍是200正常出博客。行为与切换前一致(之前公开 server 是默认 server 且有root,现在是反代到内部 server,而内部 server 是 8080 的默认 server,照样能出内容) -
80+ 错误 Host →404 162。这解释了前面日志里那些 134 / 197 字节的小型 404:是扫描器用Host: <IP>打 80 端口,命中 certbot 写的return 404;。这不是本次改动引入的回归(那两行是 certbot 的既有配置) -
80+ 正确 Host →301跳 HTTPS,正常
测试 18:404 页面的身份确认
$ curl -s -o /dev/null -w '%{size_download}\n' https://lyh-blogs.top/no-such-page-xyz348453$ curl -s https://lyh-blogs.top/no-such-page-xyz | head -c 120<!DOCTYPE html><html lang="zh-CN" class="bg-(--page-bg) text-[14px] md:text-[16px]" data-wallpaper-mode="banner" data-as348453 字节 = 博客的 404.html;而 nginx 默认 404 页只有 134~166 字节。体积差了两个数量级,一眼就能区分 —— 这是个很方便的判据。
测试 19:服务健康
$ systemctl is-active waf-labactive$ systemctl show waf-lab -p ActiveEnterTimestamp --valueMon 2026-10-12 01:30:24 CST$ systemctl is-active nginxactive测试 20:备份能不能真的回滚
不能只确认”备份文件存在”,得确认它的内容是可用的。做法是临时把备份内容换上去跑一次 nginx -t,再换回来:
$ BAK=$(ls -t /etc/nginx/sites-available/lyh-blogs.bak.* | head -1)$ echo $BAK/etc/nginx/sites-available/lyh-blogs.bak.20261012-0931
$ sudo grep -c 'root /var/www' $BAK # 1 ← 备份里有 root$ sudo grep -c 'root /var/www' /etc/nginx/sites-available/lyh-blogs # 0 ← 当前没有$ sudo grep -c 'proxy_pass' /etc/nginx/sites-available/lyh-blogs # 1 ← 当前是反代
# 临时换成备份内容做语法检查$ sudo cp /tmp/rollback-test.conf /etc/nginx/sites-available/lyh-blogs$ sudo nginx -tnginx: the configuration file /etc/nginx/nginx.conf syntax is oknginx: configuration file /etc/nginx/nginx.conf test is successful
# 换回阶段 3 配置$ sudo cp /tmp/current.conf /etc/nginx/sites-available/lyh-blogs$ sudo nginx -tnginx: the configuration file /etc/nginx/nginx.conf syntax is oknginx: configuration file /etc/nginx/nginx.conf test is successful备份确实是改动前那一版(有 root /var/www、没有 proxy_pass),且能通过语法检查 —— 回滚路径可用。
测试 21:单点故障到底有多严重
这一项会真的让博客短暂 502,是为了验证「单点故障可控」而不是「不存在」。
$ sudo systemctl stop waf-lab && sleep 1$ curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://lyh-blogs.top/502$ sudo systemctl start waf-lab && sleep 2$ curl -s -o /dev/null -w '%{http_code}\n' --max-time 8 https://lyh-blogs.top/200$ systemctl is-active waf-labactive结论:单点故障是真实存在的(WAF 挂 = 博客 502),但恢复路径清晰、耗时 1~2 秒。真要缩短中断时间,可靠Restart=always + RestartSec=2 自动拉起(已经在 systemd 单元里配了),这次是手动 start 所以慢一些。
测试 22:资源占用
$ free -m total used free shared buff/cache availableMem: 1963 345 159 3 1451 1435$ ps -o rss=,cmd= -C python3 18.9 MB /usr/bin/python3WAF 进程 RSS 约 19 MB,服务器可用内存 1435 MB —— 占用可以忽略。2 核 2G 的小机器跑这个 WAF 没有任何压力。
切 block + 拦截验收
切换动作见阶段 4,这里记验收输出。每个载荷走两条路径各打一遍。
路径 A:直连 WAF(http://127.0.0.1:8888,跳过公网 nginx)
路径穿越(CVE-2021-41773 同源手法) /icons/.%2e/.%2e/%2e%2e/etc/passwd -> 403 542 B 拦 敏感配置文件探测 /.env -> 403 542 B 拦 Git 仓库泄露探测 /.git/config -> 403 542 B 拦 SQL 注入(联合查询) /?id=1%20union%20select%201,2,3-- -> 403 542 B 拦 SQL 注入(报错型) /?id=1%27%20and%20extractvalue(... -> 200 402413 B 放行 XSS 反射 /?q=<script>alert(document.cookie)... -> 403 542 B 拦 命令注入 /?ip=127.0.0.1;cat%20/etc/passwd -> 403 542 B 拦 LFI / php://filter /?file=php://filter/convert.base64... -> 403 542 B 拦 系统文件读取 /../../../../etc/passwd -> 403 542 B 拦 编辑器残留文件 /.DS_Store -> 404 348453 B 404路径 B:完整链路(https://lyh-blogs.top)
路径穿越 -> 400(公网 nginx 先 400,没到 WAF) SQL 联合查询 -> 403 敏感配置文件 -> 403 SQL 报错型 -> 200(放行) Git 仓库泄露 -> 403 XSS 反射 -> 403 命令注入 -> 403 LFI -> 403 系统文件读取 -> 400(同上,nginx 先 400) 编辑器残留 -> 404三条差异:
-
所有 403 的正文恒为 542 字节,与命中了什么规则无关(素净版 403 页面的目标 —— 连响应长度都不泄露信息)
-
报错型注入两条路径都是 200,即被完整放行 —— 这是本次测出的规则缺口,要改。 这条载荷是:/?id=1’ and extractvalue(1,concat(0x7e,database()))— 逐段拆开看1’ —— 闭合原查询里的字符串字面量。假设后端是 SELECT * FROM t WHERE id = ‘$id’,注入后变成 … WHERE id = ‘1” and …extractvalue(1, concat(0x7e, database())) —— MySQL 的 XML 函数。它要求第 2 个参数是合法 XPath 表达式;不合法时 MySQL 会抛错,并把那个非法表达式原文写进错误信息。攻击者把 concat(0x7e, database()) 塞进去 → 库名就出现在报错里 0x7e = 十六进制 ~,一个显眼的分隔符,方便在报错文本里一眼定位提取出的数据— —— SQL 注释,把原查询剩下的部分注释掉,让语句语法完整。所以它是典型的 MySQL 报错型注入,目标是读出当前数据库名。
-
路径穿越 / 系统文件读取在公网路径下是 400 而非 403:不是 WAF 失效, 而是公网 nginx 在 URI 解析阶段就先 400 了,请求没走到 WAF。 这也意味着 WAF 日志不能当作”全部攻击”的账本
正常路径对照(两条路径都打):/、/about/、/robots.txt、/_astro/page.B4KCMVVQ.js 全部 200,零误拦。
关键判据 —— 后端有没有收到攻击请求:
WAF 审计日志 515 -> 541(增量 26) 后端内部日志 334 -> 346(增量 12)后端增量 12 逐条核对 = 8 条正常路径(4 条 × 2 路径)+ 2 条直连组的”未拦”载荷(/.DS_Store 与报错型注入)+ 2 条公网组的同样两条。被 403 的攻击,在后端内部日志里一条都没有。
(WAF 审计增量 26 比总请求数 28 少 2,正是公网路径下那 2 条被 nginx 先 400 掉的。)
回滚验证(双向):
unit 切回 audit -> /.env 返回 404(后端 nginx 自己的 404,说明放行到了后端) / 返回 200 unit 再切 block -> /.env 返回 403 / 返回 200部署后实际发现的缺陷
缺陷 1:.env、.git/config 零分放行(漏报)
现象
测试 9 的原始日志里就有这两行:
GET score=0 rules=- resp=404 /.env pass GET score=0 rules=- resp=404 /.git/config pass0 分、没有任何规则命中、直接放行。 当时能挡住它们的是 nginx 的try_files(找不到文件返回 404),不是 WAF —— 也就是说,只要哪天这些文件真的存在于站点根目录下(.env、.git/ 被误传上去,这是非常常见的部署事故),WAF 会眼睁睁看着攻击者把数据库密码和整份源码读走。
原因
default.rules 里当时只有一条敏感文件规则 1010,它管的是系统文件:
1010 | ... | (?i)/(etc/(passwd|shadow|...)|proc/(self|version)|var/log/|root/\.(ssh|bash_history)|\.(ssh|aws)/)正则里的路径全是写死的系统目录(/etc、/proc、/var/log、/root/.ssh),而 .env、.git 属于Web 根目录下的源码与凭据文件,路径任意、只认文件名。两类特征不一样,所以当时一条都没命中。
另立两条新规则,理由写在下面的注释里:1010 的正则已经不短,再塞进一堆文件名会变成一条又长又难维护的规则;而且两类的分值策略不同(1010 全是 100 分的系统文件,新规则里有一半是55 分的中信号),混在一起以后想单独调分都调不了。
改动:rules/default.rules 新增 1013 / 1014
插在 1012 之后、1020(SQL 注入)之前。完整新增内容(含注释):
# 【敏感文件 · 源码与配置泄露】.env 泄露数据库密码、.git 泄露整份源码 ——# 这类文件和 /etc/passwd 一样,正常业务绝不会去请求,所以同样给 100 分。## 为什么和 1010 拆开、而不是塞进 1010 的正则里:# 1010 管的是"系统文件"(路径写死在 /etc、/proc、/var/log、/root/.ssh 下),# 这一条管的是"Web 根目录下的源码与凭据文件"(路径任意,只认文件名)。# 两类叠加会让正则变长且难维护;分开还方便以后单独调分。## 正则里的两个设计点:# 1) 每个文件名前都要求一个 `/` —— 这样 `?q=.env`(读者搜".env 是什么")# 不会命中,而 `?file=/.env`(有人在探测)会命中。误报防线就在这里。# 2) 结尾用 (?![a-z0-9]) 负向前瞻,而不是 (/|$) ——# 既能命中 `/.env.local`、`/id_rsa.pub`,又不会让 `/.environment`、# `/.gitignore`、`/id_rsafoo` 这类正常名字被误抓。# 实测 18 个攻击样本全中、20 个正常路径零误伤。1013 | 敏感文件访问 · 源码与配置泄露 | path,uri,query,query.value,body,body.value | path | block | 100 | (?i)/(\.env|\.git|\.svn|\.hg|\.bzr|\.htpasswd|\.htaccess|\.npmrc|web\.config|id_rsa|id_dsa|id_ecdsa|id_ed25519)(?![a-z0-9])
# 【敏感文件 · 编辑器残留】.DS_Store / Thumbs.db / vim 交换文件。# 和 1013 拆开、只给 55 分(中信号)的理由:# 这些文件本身不泄露凭据,泄露的是"目录里有哪些文件名",危害低一档;# 而且它们**有可能出现在正常流量里** —— macOS 用户往服务器传过东西、# 或者编辑器崩溃留下的 .swp,都可能在某个还没清理的目录下真实存在。# 给 100 分等于单条就拦,万一某个目录真有 .DS_Store 且被正常访问,# 就是一次较真的误拦。55 分单独不拦,和别的信号叠加才过线。# 注意这条不挂 query/body:这些是纯路径产物,没必要扩大检测面。1014 | 敏感文件访问 · 编辑器残留 | path,uri | path | log | 55 | (?i)(\.DS_Store|Thumbs\.db|\.swp|\.swo)(?![a-z0-9])逐点说明
-
为什么 1013 是 100 分(一票否决):
.env/.git/id_rsa这些名字正常业务永远不该出现在请求里。这条的判据跟 1010 完全一致 ——“特征完全由攻击者控制、正常流量里出现概率趋近于零”,所以沿用 100 分 -
为什么 1014 只给 55 分:
.DS_Store是可能真的存在的。往服务器scp过东西的 macOS 用户、编辑器崩溃留下的.swp,都可能在某个没清理干净的目录下真实存在,而且会被正常浏览器或工具请求。给 100 分等于单条就拦 —— 那就是一次较真的误拦。55 分单独不拦,和别的信号叠加才过线。 -
为什么 1014 的动作写
log而不是block:log动作只计分、不作为拦截依据。55 分配log= 纯观察,永不会因为这一条单独拦截;写成block的话,55 分虽然也拦不住(低于阈值 100),但语义上变成”这条规则我愿意用它去拦”,和实际意图不符。动作写log是准确的自我描述 -
为什么每个文件名前要那个
/:这是误报防线。没有它,?q=.env(有人在搜索引擎里搜 “.env 是什么”)也会命中;有了它,只有”以/开头的路径里出现 .env”才命中 -
(?![a-z0-9])这个负向前瞻为什么比(/|$)好:/\.env+(/|$)→ 能命中/.env,但/.env.local就漏了/\.env+(?![a-z0-9])→/.env、/.env.local、/.env.production全中, 同时/.environment、/.gitignore、/id_rsafoo这些正常名字不误伤- 一句话:前瞻表达的是”这里是一个完整 token 的结尾”,比”后面是斜杠或结束” 更贴近真实意图
验证(本地)
$ python3 run.py --list-rules | head -1[hint] 有 8 条 critical 级规则(单条命中即拦截):1010, 1011, 1013, 1020, 3001, 3002, 3003, 3020已加载 32 条规则(拦截阈值 100 分)命中测试(阈值 100):
请求路径 分值 命中规则 判定------------------------------------------------------------------------/.env 100 1013,1013 拦/.git/config 100 1013,1013 拦/.git/HEAD 100 1013,1013 拦/.svn/entries 100 1013,1013 拦/.htpasswd 100 1013,1013 拦/id_rsa 100 1013,1013 拦/id_ed25519 100 1013,1013 拦/web.config 100 1013,1013 拦/.env.local 100 1013,1013 拦/.npmrc 100 1013,1013 拦/.DS_Store 55 1014,1014 放行/a/Thumbs.db 55 1014,1014 放行/search?q=.env 0 - 放行/search?q=.git 0 - 放行/archive/ 0 - 放行/environment 0 - 放行/.gitignore 0 - 放行/id_rsafoo 0 - 放行三条要读懂:
-
攻击形态全中:10 个敏感文件路径全部命中 1013,全部 100 分 → 拦
-
零误伤:最后 6 行是故意构造的正常流量,
/environment(正常英文单词)、/.gitignore(正常文件)、/id_rsafoo(只是以 id_rsa 开头)全部 0 分 -
命中列为什么显示两次(
1013,1013):这条规则的目标列表里有path和uri两个目标,/.env在两个目标上都匹配到了同一个正则,所以有两条 Hit 记录。但计分按规则去重(rules.py第 251 行score += r.score,注释写着”不管命中几个片段,只加一次”),所以总分仍是 100 而不是 200。日志里有两条 Hit 是正常的,不是重复加载
验证(线上,真实流量穿过 WAF)
同步代码后在服务器上打这六个 URL,再看 WAF 的审计日志:
探针前日志行数: 134
=== 新增的条目(判定与命中的规则)===/.env score=100 hits=1013,1013 resp=404 WOULD-BLOCK/.git/config score=100 hits=1013,1013 resp=404 WOULD-BLOCK/id_rsa score=100 hits=1013,1013 resp=404 WOULD-BLOCK/.DS_Store score=55 hits=1014,1014 resp=404/search?q=.env score=0 hits=- resp=200/archive/ score=0 hits=- resp=200对照第八节测试 9 里同样是 /.env 的那行 score=0 rules=- —— 同一个请求,
改前 0 分,改后 100 分 WOULD-BLOCK。当前跑在 audit 模式所以 resp=404
(实际是 nginx 的 404,不是 403),切到 block 模式后这三条就会变成 403。
最后两行是回归确认:/search?q=.env 和 /archive/ 线上也是 0 分 —— 新规则
没有误伤搜索页和归档页。
缺陷 2:README 里那条示例规则「永远拦不住」
现象
README 的「加一条自己的规则」一节原来给了一个单行示例:
4101 | 站点规则 · 后台路径探测 | path | raw | log | 30 | ^/(admin|manage|dashboard)(/|$)实测结果:GET /admin → 30 分 / 不拦。
问题不在于这条规则写错了,而在于它长得像一条”能拦”的规则,实际上永远拦不住任何东西 ——log 动作只计分、不作为拦截依据,30 分又远低于阈值 100。
原因
两个机制叠在一起:
-
log动作 = “只计分,不作为拦截依据” -
拦截阈值 100 分,而这条只给 30 分
更糟的是它和 block 长得很像 —— 七个字段里只有”动作”和”分值”两段不同,不改的话没人会注意到。
改动:README.md「加一条自己的规则」整节重写
改成两段式:第一段只观察,第二段才拦,并且明确对比两者的差别:
**例:给管理后台路径加一道保护。** 分两步走,先观察后拦截:
第一步,先只记录(推荐的上线姿势):
4101 | 站点规则 · 后台路径探测 | path | raw | log | 30 | ^/(admin|manage|dashboard)(/|$)
跑几天,看日志里是不是全是扫描器、有没有正常业务在访问,再决定要不要升级。
第二步,确认误报可接受后改成拦截 —— 只改中间两段:
4101 | 站点规则 · 后台路径探测 | path | raw | block | 100 | ^/(admin|manage|dashboard)(/|$)
**这两行的差别必须搞清楚,否则会写出"永远拦不住"的规则**:
- `log` 动作**只计分、不作为拦截依据**,而 30 分又远低于阈值 100 —— 所以第一步那条规则**永远不会拦任何东西**,它只是把命中记进日志- 第二步 `block` + `100` 分 = 单条命中即拦- 实测对照:`/admin` 在第一步得 `30 分 / 不拦`,第二步得 `100 分 / 拦`实测对照的原始输出:
第一步 log | 30 -> GET /admin 分值=30 命中=4101 不拦第二步 block | 100 -> GET /admin 分值=100 命中=4101 拦顺带确认了代码里确实有这个提示(rules.py 第 427~430 行),
只加载这一个规则文件时启动就会打印:
[hint] 规则集中没有任何 allow(白名单)规则 —— 接入真实业务前建议补上,否则正常业务流量容易被误判[hint] 当前规则的最高分值是 30 —— 如果拦截阈值也高于它,WAF 将永远不会拦截对照加载完整的 default.rules:
[hint] 有 8 条 critical 级规则(单条命中即拦截):1010, 1011, 1013, 1020, 3001, 3002, 3003, 3020注意这里没有”最高分值是 30”那条 —— 因为完整规则集里有 100 分的规则。
这也解释了为什么这个坑容易踩:只有单独加载站点规则文件做测试时才会看到提示,混在一起加载时它一声不吭。 所以那句 [hint] 是”设计上的兜底”,但不能指望它 —— 真正的护栏是读懂 log 和 block 的语义差别。
同一节里补的另一条认知:规则引擎没有「来源 IP」这个维度
如果想按来源 IP 限制访问怎么办?
答案是现在的规则引擎做不到:
-
规则能匹配的只有”报文里的文本片段”:
method/uri/path/query/body/header.*/cookie.*/flag -
normalize.py的extract_payloads()从不产出代表 IP 的目标 -
客户端 IP 只在
engine.py里取一次,喂给限速器,不进规则引擎
这一点已经明确写进 README,并给出三条路(改 engine.py 加前置判断 /给 extract_payloads() 加一个 IP 目标 / 交给 nginx 的 allow/deny),推荐第三条 —— IP 白名单本来就是 nginx 最擅长的事,没必要在 WAF 里重造。
缺陷 3:带 BOM 的规则文件会让整个 WAF 起不来
这个是改前两个缺陷的过程中,顺手验证边界时挖出来的。
现象
Windows 上的记事本(以及一部分编辑器、一部分 Python 脚本的写入方式)保存 UTF-8 时会加上 BOM(文件开头的 \xef\xbb\xbf 三个字节)。拿这样一个规则文件喂给 WAF,直接抛错、一个字都加载不了。
复现:
# 造一个带 BOM 的规则文件:BOM + 一行注释 + 一条规则printf '\xef\xbb\xbf' > /tmp/bom.rulesprintf '# 注释行\n9001 | t | path | raw | log | 30 | ^/x$\n' >> /tmp/bom.rules
python3 -c "import sys; sys.path.insert(0, '.')from waf.rules import load_rule_fileprint(load_rule_file('/tmp/bom.rules'))"修复前的报错(实测原文):
/var/folders/.../bom.rules:1 字段数应为 7 段(用 | 分隔),实际 1 段:# 注释行注意报错行末尾那个看起来是空格、其实是隐形字符的位置 —— 那就是 BOM,这是这个 bug 最典型的特征。
原因
文件第一行实际内容是 "\ufeff# 注释行",不是 "# 注释行"。而解析逻辑(rules.py 第 328 行)判断注释用的正是:
if not stripped or stripped.startswith("#"): continue"\ufeff# 注释行" 的第一个字符是 \ufeff 而不是 #,startswith("#") 判断失败 ——于是这一行注释被当成规则送去解析,split("|", 6) 只切出 1 段,直接抛 字段数应为 7 段,实际 1 段,整个规则文件加载失败。
实测确认第一行确实带 BOM:
第一行前 12 个字符 repr: '\ufeff# 注释行'复现报错: /var/folders/.../bom.rules:1 字段数应为 7 段(用 | 分隔),实际 1 段:# 注释行改动:waf/rules.py 第 323 行(上面新增的注释是第 313~322 行)
原代码:
rules = [] with open(path, "r", encoding="utf-8") as fh:改成:
rules = [] # 用 utf-8-sig 而不是 utf-8: # utf-8-sig 会自动吃掉文件开头的 BOM(\ufeff),没有 BOM 时行为和 # utf-8 完全一样,所以它是严格的超集,不存在"换了会出新问题"。 # # 为什么必须换:Windows 上的记事本(以及一部分编辑器、部分 Python # 脚本的写入方式)保存 UTF-8 时会加上 BOM。这时文件第一行实际是 # "\ufeff# ===...",而 `stripped.startswith("#")` 判断的是 "#" 开头, # 匹配不上 —— 于是注释行被当成规则去解析,直接报 # "字段数应为 7 段,实际 1 段" 而整个规则文件加载失败。 # 报错信息里能看到一个隐形的 字符,这是这个 bug 最典型的特征。 with open(path, "r", encoding="utf-8-sig") as fh:只改了一个词:utf-8 → utf-8-sig。没有动任何解析逻辑。
为什么这是安全的替换
utf-8-sig 是 utf-8 的严格超集,行为只有一种差别:
-
文件有 BOM →
utf-8-sig把它吃掉,utf-8留着(就是上面这个 bug) -
文件没有 BOM → 两者完全一样,逐字节逐字符相同
这一点是实测确认的,不是推断 —— 拿生产的 rules/default.rules(文件头三字节是 b'# =',无 BOM)分别用两种编码读,逐条比对规则对象:
无 BOM 的生产规则文件,两种编码读出来的规则数:32 / 32逐条比对是否完全一致: True文件头 3 字节: b'# ='32 条规则的 id / name / targets / view / action / score / pattern 七个字段全部逐条相等。所以这次替换对现有规则零风险,只多买了”别人拿记事本改规则也不会炸”这个能力。
验证
$ python3 -c "..." # 加载同一个带 BOM 的文件带 BOM 的规则文件 -> 加载 1 条: ['9001']线上同样验证了一遍(因为服务器上的文件是同步过去的):
带 BOM 的规则文件 -> 加载 1 条: ['9001']缺陷 4:--quiet-headers 只关住了响应头,403 正文一直在往外吐规则细节
这个缺陷披露出来的时机很关键:前三个缺陷是在 audit 模式下测出来的,而 audit 模式永远不会真的返回 403 页面(action 被降级成 log)。所以直到切到 block、第一次真的对外返回 403,才去看了一眼正文。
现象
切到 block 后做验收,习惯性地用 curl -I 只看响应头,确认 X-WAF-* 那三个头确实没了,就认为”规则细节暴露”已经关掉。
改成看正文之后发现,整张命中表原封不动摆在页面上:
规则编号 1013 | 规则名「敏感文件访问 · 源码与配置泄露」| 片段 path | 视图 path分值 +100 | 命中内容 /.env...累计风险分 100 / 阈值 100线条实测:正文 2435 字节,里面能 grep 到 1013、敏感文件、分值、累计风险分、WAF、规则、命中内容。
原因
Config.expose_rules(engine.py 第 118 行)这个名字里并没有 “header”,语义上它该管”所有往外暴露规则细节的地方”。但实现里它只被消费了一次 ——_process() 里的 if cfg.expose_rules:,只决定要不要加X-WAF-Action / X-WAF-Score / X-WAF-Rule-Ids 三个响应头。render_block_page() 从来没读过这个开关,无论真假都走详细版那条路。
于是形成了”文档写了、注释写了、只有实现没写”的三方不一致:
-
run.py的帮助文本写着「阻断响应里不暴露规则编号」 -
README 里写着「403 页面里带
X-WAF-Rule-Ids,生产必须关掉」 -
实现只关了头,正文照吐
注释和行为不一致,而且不一致的方向是”听起来已经改好了” ——这种缺陷不会报错,只会让人放心,比崩溃更难发现。
修改前
def render_block_page(req, scan, rate_event, total_score, cfg): """生成拦截页面。""" hits = list(scan.hits) if rate_event is not None: hits.append(rate_event) # …… 后面无条件拼那张命中表,最后整页返回修改后
新增一个模块级常量(engine.py),并在函数开头提前返回:
def render_block_page(req, scan, rate_event, total_score, cfg): """生成拦截页面。**两种形态,由 cfg.expose_rules 决定。**""" if not cfg.expose_rules: return _BARE_BLOCK_PAGE.encode("utf-8")
hits = list(scan.hits) # …… 详细版原样保留素净版全文只有两句人话,共 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")。这一点在代码里留了注释
(第 300~302 行),免得下次有人”顺手优化”回去。
实测验证
改完部署后,同一个请求对比详细版与素净版的字节数:
目标 '/.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 字节,与命中了什么规则完全无关 —— 这正是它该有的样子:连长度本身都不泄露信息。线上实测同样拿到 542 字节。
再逐个 grep 敏感串,七个全部落空:
'1013' -> 无 '累计风险分' -> 无 'WAF' -> 无'敏感文件' -> 无 '规则' -> 无 '命中内容' -> 无'分值' -> 无只 curl -I 看响应头是不够的 —— 这个缺陷就是被”只看头”的习惯放过去的。
四处改动后的整体回归
本地:
$ python3 tools/selftest.py 结果:全部 35 个用例通过
$ python3 tools/e2e_test.py[ok] 生成 JSONL 日志文件 —— waf-2026-10-12.jsonl(92 条记录)[ok] 日志里记录了拦截事件 —— 拦截 32 条[ok] 拦截记录含规则命中详情 —— 示例:规则 1001 在 uri 片段上命中 结果:全部 17 项通过线上(改动已同步并重启):
$ md5sum waf/engine.py run.py waf/rules.py rules/default.rulesfc899cc08a591c1a2031d67e2912c852 waf/engine.py806a37a5d3d779a162d67e0a223f2a95 run.py629a44fea9d488a6a03924644e519bb5 waf/rules.pyb63799a505e4a8c6038e117585d61cd7 rules/default.rules
$ python3 run.py --list-rules | head -2[hint] 有 8 条 critical 级规则(单条命中即拦截):1010, 1011, 1013, 1020, 3001, 3002, 3003, 3020已加载 32 条规则(拦截阈值 100 分)
$ systemctl is-active waf-lab nginxactiveactive
$ sudo ss -ltnp | grep -E "8080|8888"LISTEN 0 511 127.0.0.1:8080 ... users:(("nginx",pid=1388334,fd=15), ...)LISTEN 0 5 127.0.0.1:8888 ... users:(("python3",pid=1405248,fd=3))四处改动都做到了”改完立刻回归 + 线上复核”,而且没有引入任何新的部署动作 ——缺陷 1 是纯数据改动(改规则文件)、缺陷 2 是纯文档改动、缺陷 3 是一行参数改动、缺陷 4 是一个提前返回 + 一个常量。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!