测试waf的测试命令与脚本手册

8873 字
44 分钟
测试waf的测试命令与脚本手册

如何快速走一遍流程#

Terminal window
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#

先查看规则表#

Terminal window
cd /waf-lab
python3 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 自带的后端):

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

终端 2 —— 起 WAF:

Terminal window
cd /Users/liyuhang/代码库/waf-lab
python3 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):

Terminal window
cd /CVE-2021-41773
python3 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 —— 只打印规则然后退出

服务器上跑#

Terminal window
sudo systemctl start waf-lab # 启动
sudo systemctl restart waf-lab # 重启(改完东西用这个)
sudo systemctl status waf-lab # 看状态
sudo systemctl is-active waf-lab # 只要一行结果:active / inactive

改文件之后一定要重启,而且顺序不能反:

Terminal window
# 正确:先放文件,再重启
sudo cp /tmp/default.rules /opt/waf-lab/rules/default.rules
sudo systemctl restart waf-lab
# 错误:先重启,再放文件 —— 新进程读的还是旧规则

确认它真的在监听#

Terminal window
lsof -iTCP:8888 -sTCP:LISTEN

逐段拆解:

  • lsof —— list open files,列出「哪些进程打开了哪些文件」。在 Linux 上网络端口也是文件

  • -iTCP:8888 —— 只看 TCP 协议、端口 8888 的

  • -sTCP:LISTEN —— 只听状态是 LISTEN(正在等待连接)的

  • 连读:告诉我谁正在 8888 端口等着接客

有输出 = 在听;没输出就是没在听,这时候后面所有的「403」都无从谈起。

为什么单拎出来说:进程崩了、端口被占、systemd 拉不起来,现象都是「脚本拿不到 403」。先确认这一条,能省掉一半的排查时间。

关停#

Terminal window
# 本地:前台跑的,Ctrl-C 即可
# 本地:后台跑的,按端口杀(不要用 pkill -f "run.py ...",会误杀自己的 shell)
lsof -tiTCP:8888 -sTCP:LISTEN | xargs -r kill
# 服务器
sudo systemctl stop waf-lab

lsof -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 —— 检测逻辑(本机)#

Terminal window
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 —— 整条链路(本机)#

Terminal window
python3 tools/e2e_test.py

它真的起两个进程:

Terminal window
# 等价于脚本内部做的事(不用手敲,写出来是为了看懂它在干嘛)
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,出问题先看这个文件。

它回答三个问题:

  1. 正常请求能不能被正确转发?(不误伤)
  2. 攻击请求能不能被拦下?(不回源,后端根本收不到)
  3. 放行的请求,后端收到的是不是原样?(有没有被 WAF 偷偷规范化)

第 3 条是重点。攻击用例里同时断言两件事:

  • HTTP 状态码是 403
  • 响应里没有后端特有的标记

前者证明「判了」,后者证明「拦在转发之前」。这两件事真的会分开—— audit 模式下就是判了但照样转发。

五组分别测什么(对着运行输出的分组标题看):

  1. 正常流量:应当放行并正确转发
  2. 攻击流量:应当在 WAF 层被拦下(后端收不到)
  3. 透明转发:放行的请求必须原样到达后端,字节都不能改
  4. 连接复用:同一个连接连发两个请求
  5. 审计日志落盘

它实际抓到过什么:第 3 组(透明转发)是这一层最有价值的部分。

它连着跑四组「非穿越但很像穿越」的路径 —

  • 编码过的点号 /a%2eb(不是穿越,不能动)
  • 重复斜杠 //double//slash
  • 大小写混合 /CaseSensitive/PATH
  • 百分号编码的斜杠 /dir%2fname

每一组都同时断言「发出的是什么」和「后端收到的必须一模一样」。这类问题在 selftest 里是永远抓不到的:检测链根本不知道转发长什么样。

它抓不到什么:规则的边界(20 条精选用例覆盖不到正则的每个角),以及误报面(它没有「像攻击的正常请求」那类语料)。

环境细节:它要求 logs/ 目录先存在,否则会报FileNotFoundError(它要把 WAF 子进程的输出重定向进去)。在新环境里第一次跑之前先 mkdir -p logs。

预期结尾:

结果:全部 20 项通过

tools/fp_check.py — 误报面(本机)#

Terminal window
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 —— 「什么都答应」的后端(本机)#

Terminal window
python3 tools/echo_backend.py 18080

它不用 http.server,而是纯 socket 手写。原因很实在:

http.server 自己就会改请求。 实测(Python 3.9 / 3.13 都一样):

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

原因是 BaseHTTPRequestHandler 用 urlsplit 解析请求行,而urlsplit('//double//slash') 会把开头的 // 当成「网络位置分隔符」。

这就很要命:如果拿它当验证工具,WAF 明明忠实转发了,你却会看到一个「被改过」的路径,然后去 WAF 代码里找一个根本不存在的 bug。

所以这个脚本收到的请求行原样打印,一个字节都不动。它存在的唯一意义就是「说实话」。

执行顺序#

Terminal window
# 1. 先把新用例写进 selftest.py,跑一次 —— 让它失败
python3 tools/selftest.py
# 2. 改规则
# 3. 三个一起跑,都要过
python3 tools/selftest.py
python3 tools/e2e_test.py
python3 tools/fp_check.py

回归工具不负责什么(边界)#

  1. 不负责「能不能拦住新的攻击手法」

回归用例是写进去的。规则集只对想到的手法有效 — 零日在这层是测不出来的。三个工具全绿,只说明「已知的这批载荷都被正确处理了」,不是「这 WAF 没漏洞了」。

这一条的实践含义:每次看到一个新的绕过手法,第一件事是把它写进 selftest.py 并让它变红,而不是直接去改规则。

  1. 不负责真实流量的形态

fp_check.py 的 23 条是人工精选的。真实流量永远更丰富 — 所以推上线之后还有一轮,本地工具替代不了:

Terminal window
# 用这个站点自己的语料打探针(本地/服务器都行)
# 判据只有一条:判定不能是 block
  1. 不负责「有没有覆盖到的分支」

这条最隐蔽。举一个真实反例:

路径穿越那条载荷,selftest 里过了、e2e 里也过了 — 两个工具全绿。但直到切到 block 模式、真的去看阻断页正文,才发现页面里把规则编号、分值、命中片段全打印出来了,等于告诉攻击者规则表长什么样。

为什么两个工具都没抓到?因为「阻断页里写了什么」这件事,根本没有任何一条用例在管。

测试用例覆盖不到的分支,就等于没测过。 全绿不等于没问题,只等于「我测过的那些没问题」。

所以每次上线之后,除了四方对证,还要顺手做一件低技术含量但有效的事:用肉眼把每一种响应都看一遍 —— 放行的、拦截的、白名单的、404 的、畸形请求的。工具会漏掉「没人想到要断言」的东西,眼睛不会。


四个「打真实入口」的脚本#

tools/live_attack_demo.py —— 基础攻击演示(服务器上跑)#

Terminal window
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 注入变体探测(服务器上跑)#

Terminal window
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 — 完整注入链探测(服务器上跑)#

Terminal window
python3 tools/live_sqli_chain_probe.py

上一个脚本测的是「单个载荷拿多少分」,这个测的是「一整套信息提取走下来,会不会在中途被拦」。

按真实攻击的顺序走:取库名 → 查表名 → 换手法绕开 → 取版本 → 取当前用户 → 暴力取表名。

它验证出来的是**「假兜底」:走 information_schema 会被规则 1023 补刀到 125 分拦下,看起来像「虽然第一步漏了,下一步会拦住」。但换成 mysql.innodb_table_stats(sqlmap 里现成的绕法)后得分回到 90,全程不过线**。

tools/verify_rule_1024.py — 正则边界的对照演示(本机)#

Terminal window
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 的误报)。

一般化的教训:给某一支加「边界要求」之前,先问一句「这个字符本身是不是边界」。是的话,这个断言不但没用,还会让它永远失败。


手工命令逐条打,纯净手搓#

基线#

Terminal window
LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonl
wc -l < $LOG
sudo wc -l < /var/log/nginx/access-internal.log
  • $(date +%F) —— 命令替换,展开成 2026-10-13 这种日期

  • wc -l < 文件 —— 数行数。< 是输入重定向,这样写不会把文件名也打印出来,输出就是干干净净一个数字,方便一会儿做减法

  • 连读:把今天 WAF 审计日志和内部回源日志的行数各记一个数

一条 curl 打过去#

Terminal window
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

再看一条正常的:

Terminal window
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 里的 ..#

Terminal window
# 打路径穿越时,如果载荷里是**字面**的 ..
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,阻断页不暴露任何细节。本地调试时关掉它:

Terminal window
python3 run.py --backend 127.0.0.1:8080 --listen 127.0.0.1:8888
# 不加 --quiet-headers

然后看头:

Terminal window
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),按天分文件:

Terminal window
# 今天所有被拦的请求
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 / allow
  • score / 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. 1开头信息响应
  • 100 Continue:继续上传请求体
  • 101 Switching Protocols:协议升级(切换 WebSocket)
  1. 2开头请求成功
  • 200 OK:请求正常,返回内容。渗透中高危:访问需要鉴权的接口返回 200,大概率鉴权绕过(CWE-288)
  • 201 Created:创建资源成功(POST 上传、新建账号)
  • 204 No Content:成功,但无返回包体(DELETE 常用)
  • 206 Partial Content:返回部分内容(断点下载)
  1. 3开头重定向
  • 301 Moved Permanently:永久重定向(资源永久迁移)
  • 302 Found:临时重定向(登录跳转最常见)
  • 303 See Other:用 GET 访问新地址
  • 304 Not Modified:资源未改动,直接用浏览器缓存
  • 307 Temporary Redirect:临时重定向,保留原请求方法(渗透测试 HTTP 方法漏洞 CWE-650 会用到)
  1. 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:请求太多,限流(防爆破)
  1. 5开头服务器端错误
  • 500 Internal Server Error:服务器内部代码报错(注入、代码异常经常爆出 500)
  • 502 Bad Gateway:网关错误,后端服务挂了(反向代理拿不到后端响应)
  • 503 Service Unavailable:服务不可用、维护、过载
  • 504 Gateway Timeout:网关超时,后端响应太久(SSRF 经常出现 504)
  • 501 Not Implemented:服务器不支持该请求方法

四方对证#

Terminal window
# 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 分,但不该过线。加上它,是为了确认「新规则没有把正常搜索打死」。


看日志#

Terminal window
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。

读压缩的:

Terminal window
zcat /var/log/nginx/access.log.2.gz | head
  • zcat = 解压 + 输出到标准输出(等价于 gunzip -c)

匹配 IP 要锚定行首#

Terminal window
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#

Terminal window
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 归属查询#

Terminal window
dig +short -x 169.58.159.96 # 反向 DNS
whois -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 —— 看服务启动横幅
Terminal window
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)。混成一句话,结论会整个错掉。

配置文件写入时间:

Terminal window
ls -la --time-style=full-iso /etc/nginx/sites-available/lyh-blogs
  • --time-style=full-iso —— 时间显示到纳秒,改没改过一眼就看出来

容易踩的坑#

2>/dev/null 和 2>&1#

Terminal window
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 ... 永远不会执行。

正确做法是按端口杀:

Terminal window
lsof -tiTCP:8888 -sTCP:LISTEN | xargs -r kill

awk 里按字段取日志列#

nginx combined 格式的列是:

$1=IP $2 $3 $4=[时间] $5 $6 $7=请求 $8 $9=状态码 $10=字节数

所以:

Terminal window
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_request 53、case 90、NORMAL_CASES 99、ATTACK_CASES 157、 run_case 242、main 255、_clip 335
  • tools/e2e_test.py(302 行,20 项)—— check 49、wait_port 58、raw_request 71、NORMAL_CASES 104、 ATTACK_CASES 112、PASSTHROUGH_CASES 137、main 145
  • tools/fp_check.py(154 行,23 条)—— make_request 98、main 110、_clip 149
  • tools/echo_backend.py(148 行)—— EchoHandler 39、EchoServer 125、main 134

打真实入口

  • tools/live_attack_demo.py(108 行)—— req_raw 43、section 64、main 71
  • tools/live_sqli_probe.py(81 行)—— CASES 22、probe 37、records_for_ua 52
  • tools/live_sqli_chain_probe.py(72 行)—— UA 21、CASES 23、probe 39
  • tools/verify_rule_1024.py(154 行)—— 只读规则文件,纯本地

入口

  • run.py(203 行)—— 参数定义在 75 行往后

把本文的命令按顺序串起来,就是一次完整的测试流程(在服务器上执行):

Terminal window
# ---- 0. 记录基线 ----
LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonl
wc -l < $LOG
sudo 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 542
curl -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/" # 200
curl -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.py
python3 tools/live_sqli_probe.py
# ---- 6. 验收:内部日志零新增 ----
sudo wc -l < /var/log/nginx/access-internal.log # 和步骤 0 的数字比

本地跑的三个回归工具(在项目根目录):

Terminal window
python3 tools/selftest.py # 52/52
python3 tools/e2e_test.py # 20/20
python3 tools/fp_check.py # 误拦 0 条
python3 tools/verify_rule_1024.py # 边界对照演示

文章分享

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

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

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录