测试waf的测试命令与脚本手册
如何快速走一遍流程
python3 --version # 有没有 Python(3.9+ 即可)python3 run.py --list-rules # 规则加载对不对python3 tools/selftest.py # 检测逻辑:52 用例python3 tools/e2e_test.py # 整条链路:20 项python3 tools/fp_check.py # 误报面:必须 0 条误拦python3 tools/verify_rule_1024.py # 规则 1024 的边界对照演示改过规则之后,中间三条必须一起跑
其余命令按场景分节:跑起来见第三节,打真实入口见第五节,手工打见第六节,验收见第七节,真实攻击日志见第八节。
我用的工具
-
本机(Mac):三个回归工具 + 规则边界演示。不碰网络
-
服务器(
ubuntu@123.207.244.94):打真实入口、看审计日志、运维 -
靶场容器(Docker 里的 CVE-2021-41773):当后端用,验证「有 WAF / 无 WAF」的差别
三个端口
-
127.0.0.1:8888—— WAF 自己监听的地址。线上只监听回环,所以只有服务器上能直连它,从外网连不上 -
127.0.0.1:8080—— 内部 nginx,静态文件真正的出口 -
18080/18888——e2e_test.py临时用的后端口和 WAF 口。它故意选了另外两个端口,不会撞上你正在跑的 8888
线上路径是:
客户端 → 公网 nginx(443) → WAF(127.0.0.1:8888) → 内部 nginx(127.0.0.1:8080) → 静态文件看测试结果
排查问题时的关键——同一个请求,三个地方各记一份:
/var/log/nginx/access.log—— 公网 server 记的,一次客户端请求一行/opt/waf-lab/logs/waf-YYYY-MM-DD.jsonl—— WAF 审计,带分数和命中规则/var/log/nginx/access-internal.log—— 内部 server 记的回源
「请求有没有真的进到静态文件那层」,看第三份。
启动waf
先查看规则表
cd /waf-labpython3 run.py --list-rules预期输出(节选):
已加载 36 条规则(拦截阈值 100 分)------------------------------------------------------------------------ 1010 敏感文件访问 · 系统文件 path block 100 目标=path,uri,... 1020 SQL 注入 · UNION 查询 sql block 100 目标=query,... 4002 白名单 · 静态资源 raw allow 0 目标=path------------------------------------------------------------------------先看规则有没有问题
本地跑一个完整实例
需要两个终端:
终端 1 —— 起后端(用靶场容器,或者直接用 e2e 自带的后端):
cd /CVE-2021-41773docker compose up -d终端 2 —— 起 WAF:
cd /Users/liyuhang/代码库/waf-labpython3 run.py --backend 127.0.0.1:8080 --listen 127.0.0.1:8888逐段拆解:
-
--backend 127.0.0.1:8080—— 我的后端(真正干活的网站)在哪。这是 WAF 唯一需要知道的关于后端的信息 —— 它不关心后端是什么程序、什么语言写的 -
--listen 127.0.0.1:8888—— WAF 自己在哪个端口等请求 -
连读:启动一个 WAF,自己守在 8888,把放行的请求转给 8080
然后打(终端 3):
cd /CVE-2021-41773python3 poc/exploit.py --target http://127.0.0.1:8888把 --target 从平时的 8080 改成 8888,就是这一点差别 — 攻击脚本本身一个字都没改。终端 2 里会刷出一串红色的 [拦截]。
run.py 的全部参数
网络
-
--listen 地址:端口—— WAF 监听在哪,默认127.0.0.1:8888 -
--backend 地址:端口—— 后端在哪,默认127.0.0.1:8080 -
--timeout 秒—— 与后端通信的超时,默认 15
检测
-
--rules 路径—— 规则文件或目录,可以重复指定。站点专属规则就是这么追加的:--rules rules/default.rules --rules rules/site-blog.rules -
--threshold 分—— 累计风险分到多少才拦,默认 100 -
--mode block|audit——block命中即拦;audit只记录不拦,默认block
速率限制
-
--rate-limit N—— 单 IP 在窗口内允许的请求数,默认 120;0关闭 -
--rate-window 秒—— 窗口长度,默认 10 -
--rate-score N—— 每超出一次加多少分,默认 3
输出
-
--log-dir 目录—— 审计日志写哪,默认./logs -
--quiet—— 不往终端打印每个请求的结果(日志照写) -
--quiet-headers—— 阻断响应的页头和正文都不暴露任何规则细节。线上要开:连「是哪条规则拦的」都不告诉攻击者,也不让响应长度泄露信息 -
--no-color—— 关掉终端彩色 -
--list-rules—— 只打印规则然后退出
服务器上跑
sudo systemctl start waf-lab # 启动sudo systemctl restart waf-lab # 重启(改完东西用这个)sudo systemctl status waf-lab # 看状态sudo systemctl is-active waf-lab # 只要一行结果:active / inactive改文件之后一定要重启,而且顺序不能反:
# 正确:先放文件,再重启sudo cp /tmp/default.rules /opt/waf-lab/rules/default.rulessudo systemctl restart waf-lab
# 错误:先重启,再放文件 —— 新进程读的还是旧规则确认它真的在监听
lsof -iTCP:8888 -sTCP:LISTEN逐段拆解:
-
lsof—— list open files,列出「哪些进程打开了哪些文件」。在 Linux 上网络端口也是文件 -
-iTCP:8888—— 只看 TCP 协议、端口 8888 的 -
-sTCP:LISTEN—— 只听状态是 LISTEN(正在等待连接)的 -
连读:告诉我谁正在 8888 端口等着接客
有输出 = 在听;没输出就是没在听,这时候后面所有的「403」都无从谈起。
为什么单拎出来说:进程崩了、端口被占、systemd 拉不起来,现象都是「脚本拿不到 403」。先确认这一条,能省掉一半的排查时间。
关停
# 本地:前台跑的,Ctrl-C 即可# 本地:后台跑的,按端口杀(不要用 pkill -f "run.py ...",会误杀自己的 shell)lsof -tiTCP:8888 -sTCP:LISTEN | xargs -r kill
# 服务器sudo systemctl stop waf-lablsof -tiTCP:8888 里的 -t 是 「terse」:只输出 PID,不带表头,所以可以直接喂给 kill。xargs -r 的 -r 是「如果前面没输出,就别执行后面的命令」——不加的话,端口没人听时 kill 会报一个没参数的错。
三个回归工具
回归测试 = 改完之后,用什么证明没把别的地方碰坏。它不负责发现新问题,负责的是证明旧结论还有效。所以它的价值不在「跑过了」,而在「能变红」—— 一条永远绿的用例等于没有。
WAF 为什么特别需要它
普通程序改一个函数,影响面大致是「谁调用了它」。WAF 完全不是这样 —— 它的规则是叠加计分的:
-
判定结果不看单条规则,看总分有没有到阈值(默认 100 分)
-
所以改一条规则的分值,受影响的是它和其他所有规则组合出来的分数
-
具体例子:
1026(破坏性语句)从 70 改成 45,1026 + 1024就从 115(拦)掉到 90(放行)——一条本来能拦的堆叠注入直接漏掉,而且日志上看不出任何异常 -
反过来,
1024(引号闭合,45 分)单独看「怎么也拦不住」,但它是1022+1024、1025+1024、1026+1024三个组合的必配搭档,动它一个,三条链路同时受影响
所以「我只改了 1026」这个说法站不住 —— 影响面是整张组合表。组合数是规则数的平方量级,人脑算不过来,只能靠机器一遍遍重跑。
-
selftest.py—— 覆盖 解析 → 归一化 → 规则判定(检测链),共 52 条 -
fp_check.py—— 覆盖 同一条检测链,但语料换成「像攻击的正常请求」,共 23 条 -
e2e_test.py—— 覆盖 全部六段(多出转发 / 响应 / 日志),共 20 项
两个工具覆盖同一段不奇怪:走的是一条链,问的问题完全不同 — 一个问「该拦的拦住了吗」,一个问「不该拦的放过去了吗」。
缺一个会出现的盲区(每条都是实际踩过的):
-
只跑
selftest→ 规则全对,但转发时把路径改坏了,WAF 照样是废的 -
只跑
e2e→ 它的用例太少,覆盖不到规则的边界(边界问题都在 selftest 里抓) -
都不跑
fp_check→ 很容易「为了抓一个载荷,把别人的搜索框打死」
各个工具负责的与抓不到的
selftest.py
-
抓得到:某条规则漏判 / 误判、正则边界写错、分值叠加算错
-
抓不到:转发顺序、请求有没有被偷偷改动、日志有没有落盘 — 因为它根本没有「转发」这一步
e2e_test.py
-
抓得到:「假拦截」(先把请求转给后端、再把响应改成 403)、畸形请求被偷偷规范化、连接复用出问题、日志没落盘
-
抓不到:规则的边界(精选用例太少)、误报面
fp_check.py
-
抓得到:某条规则伤到了正常流量
-
抓不到:漏报(它只喂正常请求,根本不看攻击载荷);也抓不到「真实流量的形态」—— 23 条是人工精选的
注意 fp_check 那一条:**它不能证明「拦得住」,只能证明「没多拦」。**所以它永远不能单独跑,必须和 selftest 配对 — 一个管下限(别漏),一个管上限(别误伤)。
tools/selftest.py —— 检测逻辑(本机)
python3 tools/selftest.py-
不起任何服务、不占端口,直接在内存里构造请求喂给检测链
-
一共 52 条,但表里只有 51 条 —— 容易数错,先说清楚构成:
NORMAL_CASES(23 条)—— 正常流量,不能拦ATTACK_CASES(28 条)—— 攻击载荷,必须拦- 第 52 条是限流器动态用例,不在上面两张表里,写在
main()里: 「12 次请求触发 7 次限流,首次触发时 6 次/5 上限,+10 分」。 它验的是ratelimit.py,不是规则表 —— 所以 grep 那两张表永远只能数出 51
-
期望值不是「命中」或「不命中」两种,是四种,看运行输出的动作列:
pass(13 条)—— 一次都没命中allow(3 条)—— 命中白名单规则(4001 健康检查 / 4002 静态资源)log(9 条)—— 命中了,但分数没过线,放行block(26 条)—— 达阈值,拦截
log这一档是最容易被误读的:它不是失败,是设计如此。 关键词类规则单独出现时只能给中低分(drop table mysql syntax拿 70), 必须和其他信号叠加才过线。所以正常组里 23 条中有 7 条期望是log—— 它们就是「误报边界用例」:用攻击规则自己的关键词、但不带注入上下文。pass和log的区别本身就是要守住的一条性质: 「一次都没命中」和「命中了但没过线」是两种不同的安全状态, 以后给规则加分时,这 7 条会不会从log变成block,就是判据。
预期结尾:
结果:全部 52 个用例通过它实际抓到过什么(举一个真的例子):规则 1024 的正则尾锚点写错,1'; drop table users--(分号后跟空格)拿 0 分完全放行,而 1';DROP TABLE(分号后跟字母)却能命中 —— 一边漏一边不漏,光看规则文件根本看不出来。是先在 selftest.py 里写下这条用例、跑出红色,才把这个边界问题钉死的。这件事只有在这一层做得了:它不起服务,所以可以几秒钟跑完 52 条,把规则的每个角都摸一遍。
它验不到什么(很重要):它只调检测函数,验不到转发顺序——「拦下来了」有可能是「先转发到后端、再把响应改成 403」,对静态站没差别,对真实应用就是天壤之别。这件事归 e2e 管。
tools/e2e_test.py —— 整条链路(本机)
python3 tools/e2e_test.py它真的起两个进程:
# 等价于脚本内部做的事(不用手敲,写出来是为了看懂它在干嘛)python3 tools/echo_backend.py 18080 # 1) 回显后端python3 run.py --backend 127.0.0.1:18080 \ --listen 127.0.0.1:18888 \ --log-dir logs # 2) WAF然后用真实 socket 发真实 HTTP 请求,测完自动把两个进程关掉。WAF 进程的输出会存到 logs/e2e-waf-output.txt,出问题先看这个文件。
它回答三个问题:
- 正常请求能不能被正确转发?(不误伤)
- 攻击请求能不能被拦下?(不回源,后端根本收不到)
- 放行的请求,后端收到的是不是原样?(有没有被 WAF 偷偷规范化)
第 3 条是重点。攻击用例里同时断言两件事:
- HTTP 状态码是 403
- 响应里没有后端特有的标记
前者证明「判了」,后者证明「拦在转发之前」。这两件事真的会分开——
audit 模式下就是判了但照样转发。
五组分别测什么(对着运行输出的分组标题看):
- 正常流量:应当放行并正确转发
- 攻击流量:应当在 WAF 层被拦下(后端收不到)
- 透明转发:放行的请求必须原样到达后端,字节都不能改
- 连接复用:同一个连接连发两个请求
- 审计日志落盘
它实际抓到过什么:第 3 组(透明转发)是这一层最有价值的部分。
它连着跑四组「非穿越但很像穿越」的路径 —
- 编码过的点号
/a%2eb(不是穿越,不能动) - 重复斜杠
//double//slash - 大小写混合
/CaseSensitive/PATH - 百分号编码的斜杠
/dir%2fname
每一组都同时断言「发出的是什么」和「后端收到的必须一模一样」。这类问题在 selftest 里是永远抓不到的:检测链根本不知道转发长什么样。
它抓不到什么:规则的边界(20 条精选用例覆盖不到正则的每个角),以及误报面(它没有「像攻击的正常请求」那类语料)。
环境细节:它要求 logs/ 目录先存在,否则会报FileNotFoundError(它要把 WAF 子进程的输出重定向进去)。在新环境里第一次跑之前先 mkdir -p logs。
预期结尾:
结果:全部 20 项通过tools/fp_check.py — 误报面(本机)
python3 tools/fp_check.py-
挑的是一整类「看起来像攻击、其实是正常请求」的输入:含分号的 URL、CSS 片段、代码片段、搜 SQL 关键词的人、带引号的人名……
-
判据只有一条:误拦必须是 0 条
预期结尾:
结果:误拦了 0 条输出里 分数 和 判定 要分两栏看:命中弱信号拿到分数是允许的,只要判定不是 block 就算通过。
残留的分数本身有信息量 —— 它是这条规则的「误报余量」。比如 drop table mysql syntax 拿 70 分,离阈值还差 30 分,这 30 分就是以后给它加分时的活动空间。想改分值,先来这里看余量。
它和 selftest 里的「误报边界用例」是互补不是重复:
-
selftest 那组贴着具体规则写(给 1026 加关键词,就补一条对应边界)
-
这个管整体误报面有没有变大
它抓不到什么(这条最容易被误解):
-
它抓不到漏报。 它只喂正常请求,根本不看攻击载荷 — 23 条全绿只能证明「没多拦」,不能证明「拦得住」。所以它永远不能单独跑,必须和
selftest配对:一个管下限(别漏),一个管上限(别误伤) -
它抓不到真实流量的形态。 23 条是人工精选的,挑的是「我能想到的正常请求」。
测出2 条误拦:
DELETE FROM 'users' WHERE id=1→ 115 分被拦drop table 'temp';→ 115 分被拦
两条都来自 1026=70 + 1024=45 = 115。而真实攻击1'; drop table users-- 也是 115 —— 分值和误报完全相同,光靠调分数区分不开,只能靠模式差异(攻击是引号分号在前,教程是破坏性语句在前)。这类发现只有「用真实语境打」才会浮出来,fp_check.py 现有那 23 条里没有。
(最后是把「够不够得到」查清楚了才决定不改:查公网日志,带查询串的请求全部是攻击载荷、没有一条真实搜索,因为本站搜索是 Pagefind纯浏览器端、查询不发服务器。够不到就不改,但必须写进文档 ——等接上评论后端(有服务端搜索)时它就会变成真问题。)
tools/echo_backend.py —— 「什么都答应」的后端(本机)
python3 tools/echo_backend.py 18080它不用 http.server,而是纯 socket 手写。原因很实在:
http.server 自己就会改请求。 实测(Python 3.9 / 3.13 都一样):
客户端发出 GET //double//slash HTTP/1.1http.server 看到 /double//slash <- 少了一个斜杠原因是 BaseHTTPRequestHandler 用 urlsplit 解析请求行,而urlsplit('//double//slash') 会把开头的 // 当成「网络位置分隔符」。
这就很要命:如果拿它当验证工具,WAF 明明忠实转发了,你却会看到一个「被改过」的路径,然后去 WAF 代码里找一个根本不存在的 bug。
所以这个脚本收到的请求行原样打印,一个字节都不动。它存在的唯一意义就是「说实话」。
执行顺序
# 1. 先把新用例写进 selftest.py,跑一次 —— 让它失败python3 tools/selftest.py
# 2. 改规则
# 3. 三个一起跑,都要过python3 tools/selftest.pypython3 tools/e2e_test.pypython3 tools/fp_check.py回归工具不负责什么(边界)
- 不负责「能不能拦住新的攻击手法」
回归用例是写进去的。规则集只对想到的手法有效 — 零日在这层是测不出来的。三个工具全绿,只说明「已知的这批载荷都被正确处理了」,不是「这 WAF 没漏洞了」。
这一条的实践含义:每次看到一个新的绕过手法,第一件事是把它写进 selftest.py 并让它变红,而不是直接去改规则。
- 不负责真实流量的形态
fp_check.py 的 23 条是人工精选的。真实流量永远更丰富 — 所以推上线之后还有一轮,本地工具替代不了:
# 用这个站点自己的语料打探针(本地/服务器都行)# 判据只有一条:判定不能是 block- 不负责「有没有覆盖到的分支」
这条最隐蔽。举一个真实反例:
路径穿越那条载荷,selftest 里过了、e2e 里也过了 — 两个工具全绿。但直到切到 block 模式、真的去看阻断页正文,才发现页面里把规则编号、分值、命中片段全打印出来了,等于告诉攻击者规则表长什么样。
为什么两个工具都没抓到?因为「阻断页里写了什么」这件事,根本没有任何一条用例在管。
测试用例覆盖不到的分支,就等于没测过。 全绿不等于没问题,只等于「我测过的那些没问题」。
所以每次上线之后,除了四方对证,还要顺手做一件低技术含量但有效的事:用肉眼把每一种响应都看一遍 —— 放行的、拦截的、白名单的、404 的、畸形请求的。工具会漏掉「没人想到要断言」的东西,眼睛不会。
四个「打真实入口」的脚本
tools/live_attack_demo.py —— 基础攻击演示(服务器上跑)
python3 tools/live_attack_demo.py它把同一批载荷走两条路径各打一遍,直接对比:
-
路径 A:
http://127.0.0.1:8888—— 直连 WAF,跳过公网 nginx -
路径 B:
https://lyh-blogs.top—— 完整链路,走公网 nginx
tools/live_sqli_probe.py —— SQL 注入变体探测(服务器上跑)
python3 tools/live_sqli_probe.py把 SQL 注入拆成 11 个变体(UNION、恒真、报错型、时间盲注、读写文件、元信息探测、堆叠查询……),逐个看它拿到多少分。
它不止打请求,还会去读 /opt/waf-lab/logs/ 下的审计日志,把每个载荷的分数和命中规则关联出来 — 不然你只知道 403/200,不知道为什么。
- UNION 查询 → 100 分拦
- 报错型
extractvalue/updatexml、时间盲注sleep→ 都只有 90 分(1022=45 + 1024=45)→ 放行 - 堆叠查询
1'; drop table users--→ 0 分(规则 1024 的\b锚点问题)
tools/live_sqli_chain_probe.py — 完整注入链探测(服务器上跑)
python3 tools/live_sqli_chain_probe.py上一个脚本测的是「单个载荷拿多少分」,这个测的是「一整套信息提取走下来,会不会在中途被拦」。
按真实攻击的顺序走:取库名 → 查表名 → 换手法绕开 → 取版本 → 取当前用户 → 暴力取表名。
它验证出来的是**「假兜底」:走 information_schema 会被规则 1023 补刀到 125 分拦下,看起来像「虽然第一步漏了,下一步会拦住」。但换成 mysql.innodb_table_stats(sqlmap 里现成的绕法)后得分回到 90,全程不过线**。
tools/verify_rule_1024.py — 正则边界的对照演示(本机)
python3 tools/verify_rule_1024.py只读规则文件,不起服务、不发请求,纯本地。
它演示三件事:
-
第一部分:规则文件里当前的 1024 正则,对 7 条样本全部命中
-
第二部分:把修复前的正则硬编码进来,同一批样本 —— 漏掉 4 条。其中
1'; drop table users--和1';漏掉,而1';DROP TABLE却命中,说明「同一类载荷,分号后面跟空格还是字母决定了抓不抓得到」 -
第三部分:演示一个看似顺手的错误修法 —— 直接把
\b删掉,会让'orange'/'android'/'selectivity'这三个普通英文词全部命中
所以正确的修法是「把分号拆成独立的一支、不给它套边界断言」,而不是把 \b 删掉(关键词那一支留着 \b,正是为了防止 'orange → or 的误报)。
一般化的教训:给某一支加「边界要求」之前,先问一句「这个字符本身是不是边界」。是的话,这个断言不但没用,还会让它永远失败。
手工命令逐条打,纯净手搓
基线
LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonlwc -l < $LOGsudo wc -l < /var/log/nginx/access-internal.log-
$(date +%F)—— 命令替换,展开成2026-10-13这种日期 -
wc -l < 文件—— 数行数。<是输入重定向,这样写不会把文件名也打印出来,输出就是干干净净一个数字,方便一会儿做减法 -
连读:把今天 WAF 审计日志和内部回源日志的行数各记一个数
一条 curl 打过去
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ "http://127.0.0.1:8888/?id=1%20union%20select%201,2,3--"逐段拆解:
curl—— 命令行 HTTP 客户端-s—— silent,不显示进度条-o /dev/null—— 把响应正文丢进黑洞,不要刷屏-w '%{http_code} %{size_download}\n'——-w是「响应结束后打印这些变量」。%{http_code}是状态码,%{size_download}是实际收到的字节数\在行尾 —— 续行,把一条长命令拆成两行写- 连读:打一条 UNION 注入,只告诉我状态码和响应体多少字节
- 预期:
403 542
再看一条正常的:
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "http://127.0.0.1:8888/"预期:200 402413(402413 是 10-12 那天 index.html 的大小,重建后变成了 403743 —— 引用这个数字要带日期)
curl 会自己改 URL 里的 ..
# 打路径穿越时,如果载荷里是**字面**的 ..curl -s -o /dev/null -w '%{http_code}\n' \ "http://127.0.0.1:8888/../../../../etc/passwd"实测:服务器收到的是 GET /etc/passwd — curl 在发出前就把 ../../../../ 归一化掉了,你精心构造的载荷根本没发出去。
实测对照:
- 字面
..,不加--path-as-is→ 收到GET /etc/passwd - 字面
..,加--path-as-is→ 收到GET /../../../../etc/passwd - 编码
.%2e,加不加都一样 → 收到GET /icons/.%2e/.%2e/%2e%2e/etc/passwd(对 curl 来说%2e不是.,它不认识,所以不会动)
所以结论:
- 载荷里是字面
..时必须加--path-as-is - 载荷是
.%2e这种编码形式时加不加都一样 - 稳妥做法是永远加上它 —— 加了不会错,不加可能悄悄改了你的载荷
--path-as-is 直译是「路径保持原样」,就是让 curl 别做归一化。
看响应头(判断「是谁拦的」)
线上默认开 --quiet-headers,阻断页不暴露任何细节。本地调试时关掉它:
python3 run.py --backend 127.0.0.1:8080 --listen 127.0.0.1:8888# 不加 --quiet-headers然后看头:
curl -s -D - -o /dev/null "http://127.0.0.1:8888/?id=1%20union%20select%201,2,3--"-D -—— 把响应头打印到标准输出(-表示 stdout)- 会看到
X-WAF-Action: blocked、X-WAF-Score: 100/100、X-WAF-Rule-Ids: 1020
线上一定是关着的:连「是哪条规则拦的」都不该告诉攻击者,也不该让响应长度泄露信息。
字节数判据:光看大小就知道请求走到哪了
这是本项目最好用的一个判据。同样打到 8888,响应的正文大小直接告诉你结果:
542—— WAF 阻断页 → 被拦了。所有 403 的正文恒为 542 字节, 与命中哪条规则无关(--quiet-headers的设计目标之一)402413(10-12 那天)—— 博客首页index.html→ 放行,且路由命中348453—— 博客自己的 404 页 → 放行,但路径不存在166—— nginx 自带的默认 400 页 → 连 nginx 都没过, 请求没进到任何 handler
这四个数字我都实测核对过。判读日志时不用去翻规则命中详情, 光看字节数就能分流:
542 → WAF 拦的402413 → 到后端了,正常返回首页348453 → 到后端了,但路径不存在(404)166 → 根本没进链路(nginx 自己拒了)注意 402413 是特定日期的首页大小,重建博客后会变。
真正稳定的判据是「542 恒定」。
读审计日志
WAF 的审计日志是 JSONL(一行一个 JSON),按天分文件:
# 今天所有被拦的请求sudo grep '"action": "block"' /opt/waf-lab/logs/waf-$(date +%F).jsonl
# 统计哪条规则命中最多(需要 jq)sudo cat /opt/waf-lab/logs/waf-$(date +%F).jsonl \ | jq -r '.hits[]?.rule' | sort | uniq -c | sort -rn逐段拆解第二条:
-
jq -r '.hits[]?.rule'—— 从每条 JSON 里取出hits数组,[]?的?表示「这个字段不存在或不是数组时别报错,跳过就行」,然后取每项的rule字段。-r是输出原始字符串(不加引号) -
sort | uniq -c—— 排序后统计连续重复的行数 -
sort -rn—— 再按数量倒序,-n是「按数字大小排」(不然10会排在9前面) -
连读:把所有命中过的规则号拉成一行一个,数出各自出现多少次,从多到少列出来
查「这条请求到底被判成什么」要看的字段:
action——block/log/pass/allowscore/threshold—— 拿到多少分 / 线在哪hits[].rule—— 命中了哪几条规则,各自多少分hits[].matched—— 具体匹配到哪个片段(排查误报时最有用)would_block—— 见下
would_block 只在 audit 模式下有意义:audit 会把「本该拦」降级成「只记录」,同时把 would_block 记成 true;拦截模式下它恒为 false。
所以看到 "action": "block" 配 "would_block": false 的正确读法是「真的拦了」,而不是「配置不一致」。这个字段曾经把排查带偏过。
验收
不可以只看状态码
- 前端 403,后端也收到了 ——
audit模式下就是这样:判了,但照样转发 - 前端 403,其实是别的东西拦的 —— 比如公网 nginx 在 URI 解析阶段就先 400/403 了, 请求根本没走到 WAF
- 连接错误被脚本显示成 403 —— 有些 PoC 把「连不上」和「403」显示成一样
Tips — 各个状态码
- 1开头信息响应
- 100 Continue:继续上传请求体
- 101 Switching Protocols:协议升级(切换 WebSocket)
- 2开头请求成功
- 200 OK:请求正常,返回内容。渗透中高危:访问需要鉴权的接口返回 200,大概率鉴权绕过(CWE-288)
- 201 Created:创建资源成功(POST 上传、新建账号)
- 204 No Content:成功,但无返回包体(DELETE 常用)
- 206 Partial Content:返回部分内容(断点下载)
- 3开头重定向
- 301 Moved Permanently:永久重定向(资源永久迁移)
- 302 Found:临时重定向(登录跳转最常见)
- 303 See Other:用 GET 访问新地址
- 304 Not Modified:资源未改动,直接用浏览器缓存
- 307 Temporary Redirect:临时重定向,保留原请求方法(渗透测试 HTTP 方法漏洞 CWE-650 会用到)
- 4开头客户端 / 权限类错误
- 400 Bad Request:请求格式错误、参数非法
- 401 Unauthorized:未认证,需要登录(没带 cookie/token)
- 403 Forbidden:已登录,但是没有权限(登录成功,不让访问 admin 后台)
- 404 Not Found:资源不存在,页面找不到
- 405 Method Not Allowed:请求方法不允许(比如接口只允许 POST,你用 GET;和 CWE-650 非标准方法未鉴权配套)
- 408 Request Timeout:请求超时
- 429 Too Many Requests:请求太多,限流(防爆破)
- 5开头服务器端错误
- 500 Internal Server Error:服务器内部代码报错(注入、代码异常经常爆出 500)
- 502 Bad Gateway:网关错误,后端服务挂了(反向代理拿不到后端响应)
- 503 Service Unavailable:服务不可用、维护、过载
- 504 Gateway Timeout:网关超时,后端响应太久(SSRF 经常出现 504)
- 501 Not Implemented:服务器不支持该请求方法
四方对证
# 1) 进程在听lsof -iTCP:8888 -sTCP:LISTEN
# 2) 攻击载荷拿到 403(不是连接错误)curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ "http://127.0.0.1:8888/?id=1%27%20and%20extractvalue(1,concat(0x7e,(select%20database())))--%20"
# 3) 正常路径仍然是 200,没被误伤curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8888/"curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8888/?q=drop+table+mysql+syntax"
# 4) 内部回源日志零新增(最关键的一条)sudo wc -l < /var/log/nginx/access-internal.log # 和 6.1 记的基线比第 4 条是唯一不会骗人的判据。前三条都可能因为各种原因「看起来对」,只有「请求没进到后端」这件事,必须由后端的日志来证明。
第 3 条里的 ?q=drop+table+mysql+syntax 是故意挑的边界样本 — 它命中规则 1026 拿到 70 分,但不该过线。加上它,是为了确认「新规则没有把正常搜索打死」。
看日志
ls -la /var/log/nginx/access.log—— 今天的access.log.1—— 前一天的(在每天 23:59 轮转出去)access.log.2.gz~.N.gz—— 更早的,已压缩,要用zcat
找历史事件先看时间,再选文件。 想看 10-12 全天的记录,
它在 access.log.1 里,不在 access.log。
读压缩的:
zcat /var/log/nginx/access.log.2.gz | headzcat= 解压 + 输出到标准输出(等价于gunzip -c)
匹配 IP 要锚定行首
grep -E '^169\.58\.159\.96 ' /var/log/nginx/access.log.1逐段拆解:
grep—— 按模式筛选行-E—— 用扩展正则(这里主要是为了用\.转义点号)^—— 行首锚定。日志第一列就是 IP,所以从行首匹配\.—— 转义的点,代表字面的.;不转义的话.会匹配任意字符- 末尾那个空格很关键:保证匹配的是完整的 IP 列,不会把
169.58.159.961也算进来 - 连读:找出行首就是这个 IP 的行
为什么不写裸子串:grep "169.58.159.96" 会误匹配到referer、User-Agent,甚至别的 IP 里恰好包含这串数字的情况。一个 49 条的结果,可能因为误匹配变成 60 条。
三层条数对账
同一个 IP,在三个地方各有一份记录,条数往往不同,但差额必须能解释:
公网 N →(被公网 nginx 在请求行阶段 400 掉的 a 条,没到 WAF) → WAF 审计 N-a →(被判定拦截、不回源的 b 条) → 内部回源 N-a-b实测过一次:49 → 47 → 42(a=2、b=5)。
**这几个数字能对上,本身就是「WAF 确实卡在链路上、并且确实掐断了请求」的旁证。**对不上就说明你的模型有问题,得去查是哪里漏了一环。
查 WAF 审计里的某个 IP
sudo grep -E '"client": "103\.46\.186\.148"' \ /opt/waf-lab/logs/waf-2026-10-12.jsonl > /tmp/that-ip.jsonl日志里的两个 IP 字段(判读时别混):
client—— 真实访客 IP(先取X-Real-IP,取不到才回退 TCP 对端)peer—— TCP 对端。因为 WAF 只监听回环,它恒为127.0.0.1:<随机端口>
IP 归属查询
dig +short -x 169.58.159.96 # 反向 DNSwhois -h whois.ripe.net 169.58.159.96 # 欧洲地址whois 103.46.186.148 # 亚太地址走 APNIC(默认即可)逐段拆解第一条:
dig—— DNS 查询工具+short—— 只输出答案本身,不带 DNS 报文那一大坨-x IP—— 反向查询,把 IP 翻成域名(PTR 记录)- 连读:这个 IP 反解出来是什么域名
whois 有个坑:直接 whois 169.58.159.96 可能只返回上游的大块记录(NetName: RIPE-ERX-169-32-0-0-1),拿不到具体归属。必须指名问对应大区的服务器(欧洲 → whois.ripe.net)。
实测的两个例子:
169.58.159.96→ 反查到vmi3629269.contaboserver.net,RIPE 显示Contabo GmbH/ 德国 —— 一眼就是一台租来的 VPS,不是家宽、更不是「某台设备」103.46.186.148→ 印尼PT Air Lintas Komunikasi,且没有 PTR
能查到什么 / 查不到什么:
- 能:出口 IP 归属、反向 DNS、行为特征(UA / 请求序列 / 时间间隔 / 目标路径集合)
- 查不到:具体是谁;「什么设备」这个问法在公网侧不成立 — 你看到的只有网络出口,拿不到型号、系统、物理位置
「某个 IP 为什么不在某份日志里」
必须查时间线,不能猜。 要分清这两件事:
- 写进链路:前置 nginx 配置里加上
proxy_pass—— 看配置文件的 mtime - 切到拦截模式:
--mode block—— 看服务启动横幅
journalctl -u waf-lab --since "2026-10-12" --no-pager | grep -E "Started|Stopped"逐段拆解:
journalctl—— 读 systemd 日志-u waf-lab—— 只看这个单元的--since "2026-10-12"—— 从这个时间起--no-pager—— 别用 less 分页(不然脚本里会卡住)| grep -E "Started|Stopped"—— 只看起停那几行- 连读:这个服务在 10-12 那天启动/停止过几次、各是什么时候
实测这两件事能差近两小时(09:39 接进链路,11:30 才切 block)。混成一句话,结论会整个错掉。
配置文件写入时间:
ls -la --time-style=full-iso /etc/nginx/sites-available/lyh-blogs--time-style=full-iso—— 时间显示到纳秒,改没改过一眼就看出来
容易踩的坑
2>/dev/null 和 2>&1
cmd 2>/dev/null # 把「错误输出」丢掉cmd > /dev/null 2>&1 # 把「标准输出」和「错误输出」都丢掉1= 标准输出(stdout),2= 标准错误(stderr)2>/dev/null—— 只丢错误> /dev/null 2>&1——2>&1的意思是「让 2 指向 1 现在指向的地方」。 此时1已经指向/dev/null了,所以2也一起进去- 顺序不能反:
2>&1 > /dev/null会先把2指向当前的1(也就是终端), 再把1指到/dev/null—— 结果是错误信息照样打印,只有正常输出被丢了
pkill -f "run.py --backend 127.0.0.1:18080" 会杀掉自己
-f 是「匹配完整命令行」,而执行这条 pkill 的 shell 自己的命令行里就包含这串文字,所以它会把自己也匹配上并杀掉 —后面接的 && grep ... 永远不会执行。
正确做法是按端口杀:
lsof -tiTCP:8888 -sTCP:LISTEN | xargs -r killawk 里按字段取日志列
nginx combined 格式的列是:
$1=IP $2 $3 $4=[时间] $5 $6 $7=请求 $8 $9=状态码 $10=字节数所以:
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn # 状态码分布awk '{print $10}' /var/log/nginx/access.log.1 | sort -n | uniq -c | sort -rn | head # 响应体大小分布第二条实测输出过:
336 162 245 348453 <- 博客自己的 404 页 129 90702 91 402413 <- 博客首页 index.html光看这张分布表就能认出哪些是 SEO/异常页、哪些是主力页面。
注意 $4 是 [12/Oct/2026:01:54:23(带方括号),要在 shell 里按小时筛,得写 awk '$4 ~ /12\/Oct\/2026:01:/' —— 方括号是正则元字符。
脚本与命令副录
回归三件套 + 后端
tools/selftest.py(340 行,52 用例)——make_request53、case90、NORMAL_CASES99、ATTACK_CASES157、run_case242、main255、_clip335tools/e2e_test.py(302 行,20 项)——check49、wait_port58、raw_request71、NORMAL_CASES104、ATTACK_CASES112、PASSTHROUGH_CASES137、main145tools/fp_check.py(154 行,23 条)——make_request98、main110、_clip149tools/echo_backend.py(148 行)——EchoHandler39、EchoServer125、main134
打真实入口
tools/live_attack_demo.py(108 行)——req_raw43、section64、main71tools/live_sqli_probe.py(81 行)——CASES22、probe37、records_for_ua52tools/live_sqli_chain_probe.py(72 行)——UA21、CASES23、probe39tools/verify_rule_1024.py(154 行)—— 只读规则文件,纯本地
入口
run.py(203 行)—— 参数定义在 75 行往后
把本文的命令按顺序串起来,就是一次完整的测试流程(在服务器上执行):
# ---- 0. 记录基线 ----LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonlwc -l < $LOGsudo wc -l < /var/log/nginx/access-internal.log
# ---- 1. 确认在听 ----lsof -iTCP:8888 -sTCP:LISTEN
# ---- 2. 确认规则加载 ----cd /opt/waf-lab && python3 run.py --list-rules | head -3 # 期望 36 条
# ---- 3. 手工打一条,看字节数 ----curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ "http://127.0.0.1:8888/?id=1%20union%20select%201,2,3--" # 期望 403 542curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \ "http://127.0.0.1:8888/../../../../etc/passwd" --path-as-is # 期望 403 542
# ---- 4. 正常路径对照 ----curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8888/" # 200curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8888/?q=drop+table+mysql+syntax" # 200
# ---- 5. 批量脚本 ----python3 tools/live_attack_demo.pypython3 tools/live_sqli_probe.py
# ---- 6. 验收:内部日志零新增 ----sudo wc -l < /var/log/nginx/access-internal.log # 和步骤 0 的数字比本地跑的三个回归工具(在项目根目录):
python3 tools/selftest.py # 52/52python3 tools/e2e_test.py # 20/20python3 tools/fp_check.py # 误拦 0 条python3 tools/verify_rule_1024.py # 边界对照演示文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!