将我的waf项目部署到我的博客网站上

17086 字
85 分钟
将我的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 119889

WAF 转发是「原样转发」(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)===
1
123.207.244.94 - - [12/Oct/2026:10:58:31 +0800] "GET /?verify-1791773911 ..." 200 402413
=== 内部 access-internal.log:命中行数(期望 1)===
1
123.207.244.94 - - [12/Oct/2026:10:58:31 +0800] "GET /?verify-1791773911 ..." 200 402413 peer=127.0.0.1

2)改成”改前 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


具体实现#

在本地准备文件#

  1. 确认本地自检全过:
Terminal window
python3 tools/selftest.py # 35/35 通过
python3 tools/e2e_test.py # 17/17 通过
  1. 准备三份配置文件(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,所以改前必须先备份

服务器准备#

Terminal window
# 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 一次装完。脚本里有一处刻意的写法值得记一下:

Terminal window
# 用 $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 还没动,线上无变化):

Terminal window
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/logs
Restart=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),限速保留、正常参与计分。

实际操作命令:

Terminal window
# 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 日志里有多少正常请求会被判定为「本应拦截」:

Terminal window
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 ——也就是说窗口内没有一个正常访客会被误拦。

确认后改一行、重启:

Terminal window
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.service
sudo systemctl daemon-reload && sudo systemctl restart waf-lab

改动只有 audit → block 这一个词。 验证方式是同时看三处:

Terminal window
$ grep -n "mode" /etc/systemd/system/waf-lab.service
26: --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

Terminal window
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 分、XSS 115 分、命令注入 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 -3
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 11 Oct 2026 16:55:49 GMT
Content-Length: 402413
$ grep -rnE 'ssl|wrap_socket|SSLContext' /Users/liyuhang/代码库/waf-lab --include='*.py'
(无匹配)
$ cd /Users/liyuhang/代码库/LYH-Blogs && git remote -v
origin git@github.com:LYH-creative/LYH-Blogs.git

服务器连通性(2026-10-12 实测,只读命令)

$ nc -z -v -w 6 123.207.244.94 22
123.207.244.94 22 (ssh) open
$ nc -z -v -w 6 123.207.244.94 443
123.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'
ubuntu
VM-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 available
Mem: 1963 352 159 3 1451 1428
SUDO_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

  • 三条规则同时命中(1001 70 分 + 1002 35 分 + 1010 100 分 = 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-xyz
404
$ curl -s https://lyh-blogs.top/no-such-page-xyz | wc -c
348453

返回的是博客自定义的 404 页(348453 字节,不是 nginx 默认的 134 字节小页面),说明内部 server 的 error_page 404 /404.html 经过 WAF 转发仍然生效。

测试 3:响应头(确认不是缓存命中的旧响应)#

$ curl -sI https://lyh-blogs.top/ | head -20
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 12 Oct 2026 01:35:01 GMT
Content-Type: text/html
Content-Length: 402413
Connection: keep-alive
Last-Modified: Sat, 26 Sep 2026 10:14:41 GMT
Vary: Accept-Encoding
ETag: "6ab79b11-623ed"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Accept-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 OK
Content-Type: application/javascript
Expires: Tue, 12 Oct 2027 01:35:11 GMT
Cache-Control: max-age=31536000

expires 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.log 1 行 + access-internal.log 1 行

  • 请求被公开 nginx 在 URI 解析阶段 400 → access.log 1 行 +access-internal.log 0 行(因为压根没走到 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.log
2026/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.log
14
$ 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-xyz
348453
$ 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-as

348453 字节 = 博客的 404.html;而 nginx 默认 404 页只有 134~166 字节。体积差了两个数量级,一眼就能区分 —— 这是个很方便的判据。

测试 19:服务健康#

$ systemctl is-active waf-lab
active
$ systemctl show waf-lab -p ActiveEnterTimestamp --value
Mon 2026-10-12 01:30:24 CST
$ systemctl is-active nginx
active

测试 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 -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
# 换回阶段 3 配置
$ sudo cp /tmp/current.conf /etc/nginx/sites-available/lyh-blogs
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: 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-lab
active

结论:单点故障是真实存在的(WAF 挂 = 博客 502),但恢复路径清晰、耗时 1~2 秒。真要缩短中断时间,可靠Restart=always + RestartSec=2 自动拉起(已经在 systemd 单元里配了),这次是手动 start 所以慢一些。

测试 22:资源占用#

$ free -m
total used free shared buff/cache available
Mem: 1963 345 159 3 1451 1435
$ ps -o rss=,cmd= -C python3
18.9 MB /usr/bin/python3

WAF 进程 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
pass

0 分、没有任何规则命中、直接放行。 当时能挡住它们的是 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,直接抛错、一个字都加载不了。

复现:

Terminal window
# 造一个带 BOM 的规则文件:BOM + 一行注释 + 一条规则
printf '\xef\xbb\xbf' > /tmp/bom.rules
printf '# 注释行\n9001 | t | path | raw | log | 30 | ^/x$\n' >> /tmp/bom.rules
python3 -c "
import sys; sys.path.insert(0, '.')
from waf.rules import load_rule_file
print(load_rule_file('/tmp/bom.rules'))
"

修复前的报错(实测原文):

/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.rules
fc899cc08a591c1a2031d67e2912c852 waf/engine.py
806a37a5d3d779a162d67e0a223f2a95 run.py
629a44fea9d488a6a03924644e519bb5 waf/rules.py
b63799a505e4a8c6038e117585d61cd7 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 nginx
active
active
$ 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 是一个提前返回 + 一个常量。


文章分享

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

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

音乐

暂未播放

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

文章目录