对博客进行一次渗透测试看看waf有没有起作用
测试目的
- 验证 WAF 在
block模式下是否真的拦得住常见 Web 攻击 - 验证被拦的请求是否真的没到达后端(而不是”拦了但后端也收到了”)
- 验证 403 响应不泄露规则细节
测试方法
每个载荷走两条路径各打一遍,对比差异:
- 路径 A:直连 WAF ——
http://127.0.0.1:8888,跳过公网 nginx - 路径 B:完整链路 ——
https://lyh-blogs.top,走公网 nginx
再加一组正常路径对照,确认没有误拦。
判定”拦住”不只看状态码,还要同时确认三件事:
- WAF 进程在监听(
lsof -iTCP:8888 -sTCP:LISTEN) - 返回码是 403(而不是连接错误或 502)
- 后端内部日志
/var/log/nginx/access-internal.log零新增
第 3 条是最关键的判据 —— 前面几次排查都栽在”看到 403 就以为拦住了”。
测试步骤
记录基线
$ ssh ubuntu@<server>$ LOG=/opt/waf-lab/logs/waf-$(date +%F).jsonl$ wc -l < $LOG # WAF 审计日志行数$ sudo wc -l < /var/log/nginx/access-internal.log # 后端内部日志行数基本攻击载荷(10 条)
覆盖 OWASP 常见类型,另加一条故意设计的”弱信号”对照:
- 路径穿越(CVE-2021-41773 同源手法):
/icons/.%2e/.%2e/%2e%2e/etc/passwd - 敏感配置文件探测:
/.env - Git 仓库泄露探测:
/.git/config - SQL 注入(UNION 联合查询):
/?id=1%20union%20select%201,2,3-- - SQL 注入(报错型):
/?id=1%27%20and%20extractvalue(1,concat(0x7e,database()))-- - XSS 反射:
/?q=<script>alert(document.cookie)</script> - 命令注入:
/?ip=127.0.0.1;cat%20/etc/passwd - LFI / php://filter:
/?file=php://filter/convert.base64-encode/resource=index.php - 系统文件读取:
/../../../../etc/passwd - 编辑器残留文件:
/.DS_Store
SQL 注入变体探测(11 条)
专门把 SQL 注入拆成变体,看分值分布:
- UNION 联合查询:
1 union select 1,2,3-- - 恒真条件:
1' or 1=1--/1 and 1=1 - 报错型:
1' and extractvalue(1,concat(0x7e,database()))--/1' and updatexml(...) - 时间盲注:
1' and sleep(5)-- - 读写文件:
1' and load_file('/etc/passwd')--/1' union select 1,2 into outfile '/tmp/x'-- - 元信息探测:
1' and 1=1 from information_schema.tables-- - 纯引号闭合:
1' - 堆叠查询:
1'; drop table users--
这里会出现一个问题,我都没有用mysql,那么sql注入的测试还有意义吗?
有意义,而且跟后端是什么完全无关。 WAF 的职责是在载荷到达应用之前判定,不是事后确认”还好后端没这个功能”。你那个规则集刻意不含站点特征,是要复用到别的站上去的 —— 换一个真带 PHP/Java 的站,同一批判据就是活的防线。以后想加评论功能意义就大了。
后端日志对证
测试后逐条 grep 后端内部日志,确认攻击路径有没有漏进去:
$ sudo grep -c -- "/.env" /var/log/nginx/access-internal.log$ sudo grep -n -- "/.env\|extractvalue\|union" /var/log/nginx/access-internal.log测试结果
- 攻击载荷(10 条)在直连 WAF 路径下:8 条 403 拦截,2 条放行
- SQL 注入变体(11 条):5 条拦截,6 条放行
- 正常路径(4 条)在两条路径下全部 200,零误拦
- 被拦截的请求,后端内部日志一条都没有
直连 WAF(127.0.0.1:8888)
路径穿越(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三个细节值得注意:
-
所有 403 的正文恒为 542 字节,与命中了什么规则无关 —— 这是素净版 403 页面的设计目标(
--quiet-headers生效),连响应长度都不泄露信息 -
报错型注入返回 200 且正文 402413 字节,即博客首页 —— 说明它被完整放行、 原样转发到了后端。
-
/.DS_Store返回 404,是设计如此:规则 1014 是log | 55,只记录不拦
正常路径对照(零误拦)
/ 本地WAF=200 公网=200 /about/ 本地WAF=200 公网=200 /robots.txt 本地WAF=200 公网=200 /_astro/page.B4KCMVVQ.js 本地WAF=200 公网=200后端是否收到攻击请求(关键判据)
测试前后行数对比:
WAF 审计日志 515 -> 541(增量 26) 后端内部日志 334 -> 346(增量 12)增量 12 逐条核对,没有一条是被 block 的攻击:
- 直连组的 2 条:
/.DS_Store(log|55 不拦)+ 报错型注入(90 分不过线) - 公网组的 2 条:同上
- 正常路径 8 条(4 条 × 2 路径)
而 WAF 审计增量 26 比总请求数 28 少 2,原因是:公网路径下有 2 条载荷(路径穿越、系统文件读取)在公网 nginx 就被 400 掉了,根本没到 WAF,所以 WAF 没记录。
/.env、/.git/config、union select、cat /etc/passwd 这些被拦截的载荷,
在后端内部日志里出现次数全部为 0:
/.env -> 4 次 ← 全部来自 audit 模式时期 /.git/config -> 0 次 union -> 1 次 ← 同上,audit 模式时期 cat%20/etc/passwd -> 0 次 extractvalue -> 3 次 ← 全部是"没被拦"的那几条 passwd -> 0 次发现的规则缺口
已经修复了
报错型注入 / 时间盲注:差 10 分不过线
这是本次测试最有价值的发现。三条最常用的非联合注入手法全部被放行:
报错型 extractvalue 200 90 100 1022,1024 过 报错型 updatexml 200 90 100 1022,1024 过 时间盲注 sleep 200 90 100 1022,1024 过得分构成:1022(危险函数,45 分)+ 1024(引号闭合特征,45 分)= 90 分,
阈值 100,差 10 分。
为什么会这样:这两条规则在规则文件里都被标成”弱信号”级别(1022 是 log | 45、1024 是 log | 45),设计意图是”单独不够、叠加才确定”。问题在于,一个完整的、可直接武器化的报错型注入载荷,恰好只触发这两条,凑出 90 —— 差一点点,但就是不过线。
对照:UNION 联合查询被拦,因为规则 1020 单条就是 100 分。也就是说,规则集对”联合查询”和”报错/盲注”的处置不对称:
- UNION 查询 → 单条 100 → 拦
- 报错型 / 时间盲注 → 45 + 45 = 90 → 放
extractvalue、updatexml、sleep 是 sqlmap 默认就会尝试的手法,把它们放过去意味着 WAF 只挡住了最”粗”的那一半注入。
这个缺口能走多远:实测「一直停在 90 分」的完整枚举
实测把一条完整的注入链走了一遍,看分值是能否被挡在阈值之下:
第 1 步:只取库名 200 90 100 放行 1022,1024 第 2 步:查表名(走 information_schema) 403 125 100 拦 1022,1023,1024 第 2 步变体:updatexml 查表名 403 125 100 拦 1022,1023,1024 绕开 information_schema:取版本 @@version 200 90 100 放行 1022,1024 绕开 information_schema:取当前用户 user() 200 90 100 放行 1022,1024 绕开 information_schema:暴力取表名 mysql.innodb_* 200 90 100 放行 1022,1024读法:走 information_schema 那条路会被规则 1023(元信息探测,35 分)补刀到 125 分而拦下 —— 看起来像是”虽然第一步漏了,但下一步会拦住”。
但这个”兜底”是假的。 换成 mysql.innodb_table_stats 之后(sqlmap 里有现成的手法,就是为了绕开基于 information_schema 的 WAF 规则),得分回到 90,全程不过线。于是:
-
库名、
@@version、当前用户user()、表名 —— 全都能以 90 分拿到 -
也就是说,这条缺口不是”只放过了侦察的第一步”,而是可以支撑一整套数据库信息提取而不触发拦截
这一条要记牢:靠”某一条 35 分规则兜底”是挡不住的,只要攻击者能换一个同义函数/系统表绕开那条规则,兜底就没了。分值的稳健性不能依赖”攻击者会老实走那条会被抓的路径”。
为什么这条载荷在本站其实无害
必须说清楚,避免夸大:在 lyh-blogs.top 上这条载荷是打不响的,因为后端是 nginx 托管的纯静态文件,没有 PHP、没有 CGI、更没有数据库。/?id=1' and extractvalue(...)-- 只是带着查询串请求 /,try_files 照样返回 /index.html(实测 200,402413 字节)。
但它依然是个必须修的缺口,理由有三条:
-
waf-lab 的定位是通用型 WAF ——
rules/default.rules刻意不含站点特征, 同一套规则会拿去保护真正的 PHP / Java 应用。在那样的站点上, 这就是一条活的注入 -
“这次没打中”不能成为”规则没问题”的证据。WAF 的职责是在载荷到达应用前判定, 不是在事后确认”还好后端没这个功能”
-
这条缺口同时说明了 5.1 的真实性质:不是”参数调得松了一点”, 而是分值设计上存在一个 90 分的死角 —— 凡是恰好凑到 90 的载荷组合都会漏
但”本站无害”不等于”可以按本站的情况去调规则” —— 这里有个容易想反的地方。
对纯静态站来说,SQL 注入相关规则的风险方向是反的:
-
漏报风险 ≈ 0 —— 没有可注入目标,漏了也没有后果
-
误报风险才是真的 —— 静态站上”看起来像 SQL”的正常请求反而更常见: 搜索框里的查询词、别人粘贴过来的 SQL 教程链接、标题里带引号或分号的文章 URL (
?q=drop+table+mysql+syntax就是一条真实的技术博客搜索)
所以在这个站上,SQLi 规则该盯的不是”能不能拦住”,而是
tools/fp_check.py 输出里的残留分 —— 残留分就是这条规则的误报余量。
什么时候它会立刻变成真问题:一旦同一个 WAF 后面挂上任何带数据库的服务 —— 比如计划中的 Artalk / Waline 评论后端(SQLite / MySQL / PostgreSQL 都算)—— “用户输入直连数据库”这条路径就出现了,这批判例也就从”验证 WAF” 变成”验证真实防线的第一层”。规则是复用资产, 这里的价值是提前把判据调准,而不是”现在用不上就不做”。
堆叠查询:0 分(正则边界锚点缺陷)
堆叠查询 1'; drop table users-- 200 0 100 - 过 纯引号闭合 1' 200 0 100 - 过1'; drop table users-- 得 0 分,比 5.1 更严重。
根因(已实测验证):规则 1024 的正则是
(?i)['"]\s*(or|and|union|select|from|where|having|order\s+by|group\s+by|;)\b结尾那个 \b 是词边界,要求它后面紧跟”单词字符”。而 '; drop 里分号后面是空格 —— 分号和空格都不是单词字符,中间不存在词边界,所以整个匹配失败。
实测对照(同一个正则,只改输入写法):
"1'; drop table users--" 不命中 ← 分号后跟空格,\b 失败 ★ "1'; drop table users-- " 不命中 "1';DROP TABLE users--" 命中 ← 分号后跟字母,恰好有边界 "1' ; drop table users" 不命中 "1' or 1=1--" 命中 "1';" 不命中同一个攻击,中间加不加一个空格,一个命中一个漏报 —— 这说明这个 \b写错了位置:它想表达的是”关键字到此为止,别让 order by 匹配到 order byte”,但因为分号 ; 本身不是单词字符,\b 在它后面就成了无用甚至有害的约束。
把 \b 去掉后,7 个样本全部命中。
另外:规则库里没有任何一条规则覆盖 drop table / delete / insert 这类数据破坏语句,所以即使 1024 命中了,堆叠查询也只有 45 分,仍然不过线。
恒真条件 and 1=1:55 分不过线
恒真条件 or 1=1 403 100 100 1021,1024 拦 恒真条件 and 1=1 200 55 100 1021 过' or 1=1 因为同时命中 1021(55) + 1024(45) = 100 才刚好过线,而 and 1=1 只命中 1021 一条,55 分,放行。这一条未必是缺陷(and 1=1 在正常业务里确实可能出现),但值得知道:过线与否高度依赖攻击者有没有带引号,是 45+55=100 这种”卡在阈值上”的巧合,不是稳健设计。
规则文件的 block / log 列在机制上没有作用
排查过程中验证了一件事:RuleSet.scan() 里只有
self.allow_rules = [r for r in rules if r.action == "allow"]self.detect_rules = [r for r in rules if r.action != "allow"]也就是说,规则文件第 5 列(block / log)只区分 allow 和非 allow,block 和 log 对非白名单规则完全等价 —— 真正决定拦不拦的只有第 6 列的分数。
于是出现一个容易误导人的现象:规则 1021 标着 block、1022 标着 log,但两者机制上一样,都只是”往分数池里加分”。这也是 5.1 那个缺口的来源 ——看规则文件会以为”报错型注入是 block 级别的”,实际上它 45 分、永远单独拦不住。
两条路径的差异
同一个载荷,公网 400、直连 403
路径穿越 /icons/.%2e/.%2e/%2e%2e/etc/passwd 直连 WAF -> 403(WAF 拦截) 公网 -> 400(nginx 拦截,166 字节默认错误页)
系统文件读取 /../../../../etc/passwd 直连 WAF -> 403(WAF 拦截) 公网 -> 400(nginx 拦截)结论:这不是 WAF 失效,而是上层的公网 nginx 在 URI 解析阶段就直接 400 了,请求根本没走到 WAF。
这个现象印证了一个容易搞错的预期:“装了 WAF 就所有攻击都由 WAF 判定”是错的。每一层能看到的流量范围不一样 —— nginx 自己也会拦掉一批畸形请求。好处是纵深防御,坏处是你不能拿 WAF 日志当”全部攻击”的账本。
一次调试事故:Host 头重复导致全部 400
第一次跑测试脚本时,所有请求都返回 400,包括正常路径,看起来像 WAF 崩了。
实际原因在我自己脚本里:
conn.putrequest("GET", path, skip_host=False, ...) # http.client 自动加一个 Hostconn.putheader("Host", "lyh-blogs.top") # 我又手动加了一个于是请求里带了两个 Host 头。WAF 按设计”原样转发”(一个字节都不改),
后端 nginx 对重复 Host 一律回 400。修法是 skip_host=True,Host 只由自己加。
这个事故本身也有价值:它从侧面证明了”原样转发”是真实生效的 ——WAF 没有偷偷”修好”畸形请求,而是如实转给了后端。如果 WAF 自作主张规范化,这里就会变成一个假阴性(攻击被 WAF 洗干净了,以为规则没问题)。
审计模式的一个副作用
audit 模式下,被判定为”本应拦截”的请求照样会到后端
后端内部日志里出现过 4 次 /.env 和 1 次 union select,时间戳集中在 11:12 和 11:46~11:51 —— 那是切换 block 之前、以及回滚验证时切回 audit 的窗口。
机制:audit 模式会完整跑一遍检测、算出 would_block=True,但把 action 从 block 降级成 log,然后照常转发给后端。
所以:
-
audit只适合短期观察,不适合当”安全模式”长期挂着 -
判断”某个攻击有没有被真正挡下”时,必须先确认当前是 block 还是 audit ——否则会把 audit 时期的放行记录误读成”规则失效”
让ai看看哪里可以改进的
结论
-
WAF 在
block模式下确实拦得住并拦住了一批真实攻击(含一套 PHP-CGI RCE 组合拳) -
被拦截的请求确实没有到达后端,这是逐条核对过的,不是靠状态码推断的
-
403 响应不泄露任何规则细节(正文恒定 542 字节,七个敏感串全部落空)
-
正常路径零误拦
-
规则集存在三类真实缺口(报错型/盲注差 10 分不过线、堆叠查询 0 分、
block/log列机制上无作用),已修复
整改建议与实施结果
下表是最初提的建议和最终实际做了的事的对照。
-
第 1 条:修 1024 的
\b边界 —— 已完成,但做法和最初设想不同- 最初设想:把结尾的
\b换成(?:\s|$|[;'"\)])这类”关键字边界” - 实际做法:把分号单独拆成一支、不给它套任何边界断言,
其余关键字保留
\b - 为什么改主意:
(?!\w)之类的替代品对分号那一支同样无效 ——';drop里;后面跟的正是d(单词字符),前瞻一样失败。 分号本身就是天然分隔符,它不需要”边界要求”。 而其余关键字必须以单词字符结尾,\b不能去掉,否则'orange里的or会被误判成关键字
- 最初设想:把结尾的
-
第 2 条:补堆叠查询 / 数据破坏规则 —— 已完成,拆成了两条而不是一条
1026 破坏性语句(DDL/DML)——drop/truncate/delete from/insert into/update ... set,log | 701027 堆叠查询(分号 + 语句)——;后跟语句动词,log | 70- 为什么拆两条:1026 管”语句本身”,1027 管”语句被塞进原语句”这个动作。
数字型注入点(
?id=1; drop table users)不需要引号,1024 不命中, 只靠 1026 的 70 分不过线 —— 加上 1027 才过 - 为什么只给 70 分不做一票否决:
drop table是关键词, 技术博客的搜索框里搜 “drop table mysql syntax” 完全正常。 单独 70 分放行,和 1024(45 分)叠成 115 分才拦
-
第 3 条:调整报错型/盲注的分值组合 —— 已完成,三条措施一起上
1022从log | 45改成block | 100,并把正则收窄成extractvalue|updatexml|load_file|into outfile这几个在 SQL 之外 没有意义的名字(判断标准:这个名字在 SQL 之外还有没有意义)- 把
sleep/benchmark/pg_sleep拆成新的1025(log | 60), 并要求函数名后紧跟左括号 —— 因为它是个普通英文词, 旧规则连 “I need to sleep more” 都会给 45 分 - 新增
1028 数据窃取表达式(@@version/user()/group_concat(,log | 55)—— 这条是第 3 条建议里”更稳的思路”的落地: 按语法类别覆盖,而不是继续堆函数名 - 关于误报担忧:
sleep(的处理方式是”降分 + 要求括号”, 实测 “I need to sleep more” → 0 分,“Sleep (2020) movie” → 60 分 (命中但不过线)。误报边界用例已经写进selftest.py
-
第 4 条:统一
block/log列的含义 —— 已完成,选了”说清楚”而不是”改引擎”- 规则文件头部第 15~26 行现在明确写着:这两个值在机制上完全等价,
只按
action == "allow"分组;保留两列是为了标注设计意图; 想让它真有语义差别必须改RuleSet.scan() - 为什么没改引擎:改成”必须有 ≥1 条非 log 规则命中才拦”是对整个
规则集语义的大改,blast radius 覆盖全部 36 条规则,
收益(现有的
log/block划分本来就是按分值自然形成的) 小于风险。先把文档与实际对齐,是这一步该做的事
- 规则文件头部第 15~26 行现在明确写着:这两个值在机制上完全等价,
只按
-
第 5 条:任何分值改动都要先跑两个回归 —— 已执行,而且顺序上做了改进
- 实际顺序:先把新用例写进
selftest.py,让它先失败,再改规则。 这样”改好了没有”有一个能对照的基线,而不是改完凭眼睛看 - 结果:
selftest.py从 35 用例扩到 52 用例(38/48 → 52/52),e2e_test.py从 17 项扩到 20 项(全过) - 额外做了一次对抗性误报自检:专挑”含分号 / 含 SQL 动词 / 含引号”的 正常请求 15 条,误拦 0 条
- 实际顺序:先把新用例写进
测试方法的沉淀
-
判定”拦住了”必须三方对证:进程在听 + 返回 403 + 后端日志零新增
-
两条路径都要打:直连 WAF 测规则本身,走公网测完整链路 — 上层的 nginx 会先吃掉一批畸形请求
-
按真实 IP 排序不等于按攻击者排序:要看分数和拦截次数
-
audit模式不是安全模式:它只记录、不拦,一切照旧转发
整改实施与验证
改了什么
规则层面:2 条修改 + 1 条扩表 + 4 条新增(规则总数 32 → 36)
-
1024 引号闭合特征—— 修尾锚点。原来的...|;)\b里那个\b让”引号 + 分号”这一支永远失败(;和它后面的空格都不是单词字符, 中间不存在词边界)。改成把分号单独拆成一支、不给它套边界断言 -
1022 报错注入与文件读写——log | 45→block | 100, 正则收窄为extractvalue|updatexml|load_file|into outfile。 判断标准是**“这个名字在 SQL 之外还有没有意义”** -
1023 元信息探测——log | 35→log | 55, 表名清单从”只认information_schema”扩成”按系统表的语法类别收” (补上mysql.innodb_table_stats、mysql.innodb_index_stats、performance_schema、sys.tables、sys.columns、pg_class等) -
1025 时间盲注函数(新)——sleep(/benchmark(/pg_sleep(/WAITFOR DELAY,log | 60。从 1022 拆出来的,并要求函数名后紧跟左括号 -
1026 破坏性语句(DDL/DML)(新)——drop/truncate/delete from/insert into/update ... set,log | 70 -
1027 堆叠查询(分号 + 语句)(新)——;后跟语句动词,log | 70 -
1028 数据窃取表达式(新)——@@version/user()/group_concat(,log | 55
代码层面:2 处
-
waf/rules.py的ScanResult.explain()—— 按规则去重后再列分值。 原来照着命中列表逐条打印,同一条规则命中两个片段就打印两行分数(
1022=45; 1022=45; 1024=45; 1024=45),看着像 180 分而实际只有 90 —— 排查分值问题时会被这行字带偏 -
waf/engine.py的render_block_page()—— 同一个问题在详细阻断页上也存在。现在只在首次出现时写+N,重复行写”已计”,并在页面说明里点明。实测:报错型注入那一页显示+100 / 已计 / +45 / 已计 / +55 / 已计,加起来正好等于”累计风险分 200”
用例层面:两份回归都扩容
-
tools/selftest.py:35 → 52 个用例。新增两组:- 7 条缺口攻击载荷(就是第五章实测被放行的那几条,回填成回归用例)
- 10 条误报边界用例(用攻击规则里的关键词,但没有注入上下文)
-
tools/e2e_test.py:17 → 20 项。新增报错型注入、时间盲注、堆叠查询
改前基线(先把用例写进去,让它失败)
结果:38/48 通过,10 个未通过
[!!] log 搜索「I need to sleep more」(sleep 是普通英文词) 45分 规则=1022[!!] log 影片名「Sleep (2020)」 45分 规则=1022[!!] log 搜索「information_schema 是什么」 35分 规则=1023[!!] log SQL 注入 · 报错型(extractvalue 读库名) 90分 规则=1022,1024[!!] log SQL 注入 · 报错型(updatexml 读版本) 90分 规则=1022,1024[!!] log SQL 注入 · 时间盲注(sleep 函数) 90分 规则=1022,1024[!!] pass SQL 注入 · 堆叠查询(引号 + drop table) 0分 规则=-[!!] pass SQL 注入 · 堆叠查询(数字型注入点,不带引号) 0分 规则=-[!!] pass SQL 注入 · 堆叠查询(delete from) 0分 规则=-[!!] log SQL 注入 · 绕开 information_schema 换系统表 45分 规则=1024注意前三条和最后一条 —— 它们期望就是”不该过线”,当时的状态虽然是 45/35 分(没到 100),但用例仍然标红,因为改完之后这三条的分值会变(45 → 0 / 60 / 55),期望值也要跟着改。把”误报边界”和”攻击载荷”放在同一张表里跑,就是为了防止”为了抓一个载荷把正常搜索打死”。
改后:注入链九步对照
用修复后的本地规则逐行对照:
步骤 旧分 旧判 新分 新判 新命中规则------------------------------------------------------------------------------第 1 步:只取库名(extractvalue) 90 放行 200 拦 1022,1024,1028 ← 翻转第 1 步变体:只取库名(updatexml) 90 放行 200 拦 1022,1024,1028 ← 翻转第 1 步变体:时间盲注 sleep 90 放行 105 拦 1024,1025 ← 翻转第 2 步:查表名(走 information_schema) 125 拦 200 拦 1022,1023,1024绕开 information_schema:换 innodb 统计表 90 放行 155 拦 1023,1024,1028 ← 翻转绕开 extractvalue:只取 @@version 90 放行 100 拦 1024,1028 ← 翻转绕开 extractvalue:只取 user() 90 放行 100 拦 1024,1028 ← 翻转堆叠查询删表 0 放行 185 拦 1024,1026,1027 ← 翻转堆叠查询删数据(数字型注入点) 0 放行 140 拦 1026,1027 ← 翻转------------------------------------------------------------------------------共 8 条判定发生翻转(放行 → 拦)最关键的一行是”绕开 information_schema:换 innodb 统计表”:上一轮它证明 1023 只是个”假后盾”(换张表就从 125 掉回 90),现在它自己也过线了 —— 后盾是真的了。
分值和组合的算术
这一轮踩的坑本质上是分值组合没算过。教训固化成了两条纪律,
已写进 rules/default.rules 的文件头(第 28~57 行):
-
中信号(36~69 这一档)只取 45 / 55 / 70 这几档固定值,不随手写 50
45 + 55 = 100两条中信号叠加刚好过线(刻意配出来的)45 + 45 = 90叠加也不过线 ← 这次的坑就出在这里70 + 45 = 115过线
-
能不能给 100 分一票否决,看”这个特征在正常语境里还有没有意义”
extractvalue(/load_file(—— 只有注入会这么写 → 语法结构 → 可以给 100drop table—— 技术博客搜索框里很常见 → 关键词 → 只能给 70, 靠”关键词 + 注入上下文”两条一起过线
误报自检(对抗性)
改完分值后专门测了一批”含分号 / 含 SQL 动词 / 含引号”的正常请求。
这个自检已经固化成常驻工具 tools/fp_check.py(可直接重跑复现下表):
用例 分数 判定分号分隔的 id 列表(/?ids=1;2;3) 0 pass分号分隔的排序参数 / 字段列表 0 pass版本区间里的分号(?min=1.2;3&max=4) 0 passCSS 片段(color:red;display:block) 0 passJSON 里的分号 / git 命令里的分号 / UA 里的分号 0 pass印刷体引号 + 分号(He said "stop; go") 0 pass「I need to sleep more」 0 pass「a benchmark of web servers」 0 pass正常中文 / 英文搜索 / 含撇号的人名 0 pass「choose and (select the best option)」 0 pass代码片段 printf(";") 45 log「what is @@version in mysql」 55 log「information_schema 是什么」 55 log影片名「Sleep (2020) movie」 60 log「drop table mysql syntax」 70 log「delete from syntax in sql」 70 log「update the set of rules」 70 log「SQL tutorial drop table create table ...」 70 log----------------------------------------------------------误拦了 0 条怎么看这张表:分数 和 判定 要分两栏看 ——命中弱信号拿到分是允许的,只要判定不是 block。那些残留的分数就是这条规则的误报余量:drop table mysql syntax 拿 70 分,离阈值还差 30 分,以后想给 1026 加分,先看这 30 分够不够用。
另有两个附带收益:
-
I need to sleep more从原来的 45 分降到 0 分 —— 这是 1025 要求”函数名后紧跟左括号”带来的直接改善(旧规则把它当成了危险函数) -
?q=how to delete a row、怎么把表删掉都是 0 分,说明 1026 的delete\s+from写法没有滥伤到普通英文
回归结果
tools/selftest.py → 结果:全部 52 个用例通过 (改前 38/48)tools/e2e_test.py → 结果:全部 20 项通过 (改前 17/17)tools/fp_check.py → 结果:误拦了 0 条 (23 条正常请求)端到端那一层新增的三条攻击都拿到了 403,且响应里没有回显后端的标记 — 说明是拦在转发之前,不是回源之后才发现的。
三个脚本缺一不可,因为它们验的是不同的东西:
-
selftest.py—— 检测逻辑对不对(内存里跑,不起服务) -
fp_check.py—— 整体误报面有没有变大(内存里跑) -
e2e_test.py—— 检测出来之后有没有真的拦在转发之前(真起进程走 socket)
fp_check.py 和 selftest.py 里那组”误报边界用例”是互补的,不是重复:后者贴着具体规则写(给 1026 加关键词就补一条 ?q=drop table mysql syntax),守的是单条规则的线;前者管的是一整类输入的整体误报面。
残留缺口(诚实的部分)
没有全部堵死,有一条明确留着:
-
不带引号的布尔盲注仍然不过线。 例如
?id=1 and (select @@version)只拿 1028 的 55 分,放行。 这是 1025/1028 的分数上限决定的:@@version是关键词而不是语法结构 (技术文章里会写它),所以只能给 55 分,必须和 1024(引号闭合,45)叠加才过线。 而”不带引号”意味着 1024 不命中。-
想堵它需要一条覆盖
and (select ...)这种布尔子查询语法的新规则。 没有直接加,原因是:它对正常英文的误报面(and (select the best option)) 需要先实测,而不是拍脑袋定分。 -
需要说明的是:这一类缺口是关键词型 WAF 的天然下限。 真要彻底覆盖布尔盲注,靠的是请求层面的异常评分(本项目里的限速器 就是这类”行为维度”信号的雏形),而不是继续往正则里堆词。
-
-
block/log两列机制上仍然等价。 这次只做到了”把文档与实际对齐”,没有改引擎语义。
11.8 部署步骤(待执行)
# 1. 本地先跑三个检查(改完必须跑,任何一次改动之后都一样)python3 tools/selftest.py # 期望:全部 52 个用例通过python3 tools/e2e_test.py # 期望:全部 20 项通过python3 tools/fp_check.py # 期望:误拦了 0 条
# 2. 传规则文件(这次只有规则变了;代码那两处属于显示层,可选)scp rules/default.rules ubuntu@123.207.244.94:/tmp/default.rules
# 3. 服务器上替换 + 校验规则条数,再重启ssh ubuntu@123.207.244.94sudo cp /opt/waf-lab/rules/default.rules /opt/waf-lab/rules/default.rules.bak.$(date +%Y%m%d-%H%M)sudo cp /tmp/default.rules /opt/waf-lab/rules/default.rulescd /opt/waf-lab && python3 run.py --list-rules | head -3 # 期望:36 条规则sudo systemctl restart waf-lab
# 4. 三方对证:进程在听 + 新规则生效 + 正常路径没被误伤sudo systemctl is-active waf-labcurl -s -o /dev/null -w '%{http_code}\n' \ "http://127.0.0.1:8888/?id=1%27%20and%20extractvalue(1,concat(0x7e,(select%20database())))--%20"# 期望 403(改之前是 200)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附录 A:复现脚本
全部纯标准库,python3 直接跑(跑在服务器上以访问 127.0.0.1:8888)。
脚本已归档在 tools/ 下:
-
tools/live_attack_demo.py—— 基本攻击演示(两条路径 + 正常对照) -
tools/live_sqli_probe.py—— SQL 注入变体探测(自动关联审计日志取分) -
tools/live_sqli_chain_probe.py—— 报错型注入的完整链路探测,验证”能否全程停在阈值之下” -
tools/verify_rule_1024.py—— 验证规则 1024 的\b边界问题 -
tools/fp_check.py—— 误报自检,23 条”看起来像攻击的正常请求”,本地跑,判据只有一条:误拦必须是 0
# 在服务器上跑(需要能访问 127.0.0.1:8888 和 /opt/waf-lab/logs)$ python3 tools/live_attack_demo.py$ python3 tools/live_sqli_probe.py$ python3 tools/live_sqli_chain_probe.py
# 本地跑,验证正则边界问题$ python3 tools/verify_rule_1024.py改完规则的三件套(都在本地跑):
$ python3 tools/selftest.py # 检测逻辑:52 个用例必须全过$ python3 tools/e2e_test.py # 整条链路:20 项必须全过$ python3 tools/fp_check.py # 误报面:必须 0 条误拦后端日志对证:
$ sudo grep -n -- "/.env\|extractvalue\|union" /var/log/nginx/access-internal.log附录 B:关键排错记录
-
所有请求都 400(含正常路径)
- 真实原因:测试脚本重发了
Host头(putrequest(skip_host=False)自动加一个 + 手动又加一个),nginx 拒绝重复 Host - 判据:后端内部日志里有这些请求 = 请求确实到了后端,说明不是 WAF 的问题
- 真实原因:测试脚本重发了
-
前端 403 但后端也有记录
- 真实原因:当时 WAF 跑在
audit模式,只记录不拦、照样转发 - 判据:看审计记录里的
action和would_block字段
- 真实原因:当时 WAF 跑在
-
同一个载荷公网 400、直连 WAF 403
- 真实原因:公网 nginx 在 URI 解析阶段就先 400 了,请求没走到 WAF
- 判据:WAF 审计日志里根本没有这条请求
-
后端日志里出现了
/.env- 真实原因:那是
audit模式时期留下的(11:46~11:51 的回滚验证窗口) - 判据:按时间戳对照当时的
--mode设置
- 真实原因:那是
附录 C:真实攻击事件日志包(evidence/)
那两起真实扫描,原始日志已经单独归档在 evidence/ 目录。和本文档的区别是:这里(拦截测试记录.md)是结论和判读,evidence/ 是原始行,可以逐条核对。
evidence/README.md—— 索引:怎么取的、时间线、49 条逐条解码、 两次扫描的逐位比对、IP 归属、以及那两条 400 的容器复现过程evidence/attack-2026-10-12-169.58.159.96.access.log—— 事件 A 原始 49 行evidence/attack-2026-10-12-103.46.186.148.public.access.log—— 事件 B 公网 49 行evidence/attack-2026-10-12-103.46.186.148.internal.access.log—— 事件 B 回源 42 行evidence/attack-2026-10-12-103.46.186.148.waf.jsonl—— 事件 B 的 WAF 审计 47 条
取日志时注意两件事(都写进了 evidence/README.md):
- 按天轮转:10-12 全天的记录在
/var/log/nginx/access.log.1,不在access.log。 找历史事件先确认时间,再选文件 - IP 匹配要锚定行首(
grep -E '^1\.2\.3\.4 ')。裸子串会误匹配 referer、UA
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!