作者:暗夜铭少
这个 WAF 跑在我自己的服务器上,单二进制、零外部依赖。半年下来最大的收获不是拦截率,是理解了误杀比漏杀更致命。
本文不聊概念,只聊三件事:为什么这么架构、踩了哪 5 个坑、怎么填的。
一、为什么用 Go,为什么单二进制
先说选型。
为什么不用 PHP 写? PHP 版我写过一版(开源在 GitHub),单文件引入,嵌入网站同服务器。但它有个天花板:它和业务代码共享同一个 PHP 进程。WAF 检测越重,业务响应越慢;WAF 崩了,业务也跟着崩。
为什么用 Go? 三个原因:
- 独立反向代理,透明部署。WAF 跑在 8443,用户请求经过它再到后端 8088。WAF 挂了,后端还在;WAF 重启,用户无感。
- 原生并发。Go 的 goroutine 处理高并发请求,单节点 QPS 10,000+,p95 延迟 < 5ms。
- 编译成单个二进制。部署就是“丢一个文件、跑起来”。没有 Redis、没有数据库、没有消息队列。
为什么不用 Redis? 小服务器上多一个组件就多一个故障点。封禁列表、白名单、画像数据,全部序列化到本地文件,启动时 LoadFromFile 恢复。代价是内存里要维护状态,但 2 核 1.6G 够用。
架构长这样:
浏览器/攻击者 │ 80/443 ▼ nginx 入口(域名 vhost,蜘蛛 allow 段在这) │ 反向代理 127.0.0.1:8443 ▼ ShieldWAF (Go, 仅监听 127.0.0.1:8443) ← 全部检测/封禁/学习 │ http://127.0.0.1:8088 ▼ nginx 后端 vhost │ PHP-FPM ▼ 应用(WordPress)
二、5 个踩坑点
坑 1:规则型被 UnIoN SeLeCt 绕过
第一版我堆了 200 条正则,以为稳了。结果攻击者把 UNION SELECT 写成 UnIoN SeLeCt,直接绕过。
填坑方式:从规则匹配转向语义分析。核心是三步——多层解码还原、语料合并、AST 语法分析。
多层解码还原的逻辑大致是这样: // 多层解码:URL×3 + HTML 实体 + Unicode/十六进制转义 func normalize(input string) string { result := input for i := 0; i < 3; i++ { decoded := urlDecode(result) if decoded == result { break // 解不动了,停 } result = decoded } result = htmlEntityDecode(result) result = unicodeEscapeDecode(result) result = hexEscapeDecode(result) return result } 关键不是“解一次”,而是循环解码到不能再解,同时保留每一层的中间结果。攻击者双重编码、三重编码,都能还原。
然后是把归一化输入 + 原始 URI + 解码 URI 合并成一个语料池,一起送分析器: type AnalyzerInput struct { Normalized string // 归一化后的输入 RawURI string // 原始 URI DecodedURI string // 解码后的 URI } 这样无论攻击者把 Payload 藏在哪一层,最后都会被还原到同一个语料池里被看到。
坑 2:搜索引擎蜘蛛被误封
我用威胁情报库做 IP 信誉,结果有一天发现 Google 爬虫段(66.249.0.0/16)被误判成恶意 IP 封了。网站收录直接掉。
填坑方式:三层豁免。
- 网络层:nginx 的
allow段优先于include nginx_ban.conf - 应用层:
isVerifiedSpider用 UA + PTR 反向 DNS 双重验证,先于一切封禁放行 - 封禁路径:所有封禁写入路径(
autoBanIP/banSingleIP/ AI 对抗 / 威胁情报)全部经过isOfficialCrawlerSubnet过滤
覆盖了 Google、百度、360、Yandex、必应、搜狗、头条等实证段。单 IP 输入自动按 /32 判断——这一条是后来补的,因为发现威胁情报的单 IP deny 曾经绕过豁免。
建议:如果你也自研 WAF,把爬虫豁免放在所有封禁逻辑之前。否则威胁情报、CC 防护、网段连坐,任何一层都可能误封官方爬虫。
坑 3:PHP 反序列化被归 unknown,Score 63
日志里有一条:
2026-10-16 09:31:13 | IP: 45.78.x.x | POST /api/data
| Payload: payload=O:4:"User":2:{s:4:"name";s:5:"admin";s:4:"role";s:5:"admin";}
| Reason: 语义引擎: unknown | Score: 63
这是教科书级的 PHP 序列化格式,但当时我的 deserialization_analyzer.go 没认出来,只给了 63 分,归到 unknown。
填坑方式:修 deserialization 分析器对 PHP 序列化的识别。现在它能拿 100 分。
但这件事暴露的更深问题不是“有个 bug”,而是:如果我的策略是“命中即封”,那条 63 分的 Payload 可能会误封一整个网段。
所以同时上了分数分级:
- ≥80 分:永久封禁 IP + /24·/16 网段连坐
- <80 分:仅拦当前请求,不封 IP、不连坐、不进攻击画像
误杀的代价,比漏杀更大。 漏杀一次,损失一次;误杀一次,失去一个用户。
坑 4:被封 IP 还在打 Go 层
xmlrpc 撞库那几个 IP,第一次 POST 就被封了(Score 90 → 封 IP)。但封禁之后,日志里还是能看到同一个 IP 连续几十条 Reason: 永久封禁IP | Score: 100。
这说明:封禁规则生效了,但请求还是打到了 Go 应用层。
理想情况下,被封的 IP 应该在 nginx deny 或 iptables/ipset 层就被丢弃,根本不该进 Go 进程。为什么没生效?可能的原因:
- nginx deny 的 reload 有延迟
- HTTP keep-alive 连接复用,已建立的连接不受新 deny 影响
目前的处理:waf_auto_ban.go 同时写 Nginx deny + iptables/ipset 双锁,重启时重建。但 keep-alive 那个问题还没完全解决——如果你有思路,欢迎交流。
坑 5:网段封禁没有幂等去重
日志里看到同一个 /24 段一天被封了 5 次:
09:32:11 | SUBNET: 104.131.169.0/24 | 🔒 整个/24网段永久封禁
09:44:21 | SUBNET: 104.131.169.0/24 | 🔒 整个/24网段永久封禁
已经封过的段又被写了一次,nginx_ban.conf 重复写入,日志噪音大。
填坑方式:网段封禁加幂等去重——写入前先查 ban.txt 里有没有这个段,有就跳过。目前还在优化中,因为 ban.txt 的读写和内存态需要保持一致。
三、半年数据
| 指标 | 数据 |
|---|---|
| 最常被攻击的端点 | xmlrpc.php(占 POST 总量 97%) |
| 最常被伪造的 UA | Jetpack by WordPress.com |
| 单日最高封禁 IP | 43 个 |
| 误杀率(修复后) | < 1% |
| 最常触发的检测 | 语义引擎:deserialization |
数据来自 2026-10-16 的生产日志。
四、项目结构(部分)
/opt/duduziy-waf/ ├── main.go # 主入口 ├── export.go # 情报资产导出 ├── waf.conf # 运行配置 ├── internal/ # 核心模块(20 个) │ ├── waf_core.go # ServeHTTP 主流程 + 蜘蛛豁免 │ ├── type_labels.go # 应用层类型标签表(115+ 类型) │ ├── waf_cc_protection.go # CC 防护(JS 挑战 + 30 分钟 cookie) │ ├── waf_login_limiter.go # 登录撞库限流 V1.1 │ ├── waf_auto_ban.go # 自动网段封禁(Nginx deny + iptables/ipset) │ ├── waf_gang_cluster.go # DBSCAN 语义团伙聚类 │ ├── waf_file_dna.go # 文件 DNA 手术刀(inotify + 自动还原) │ ├── normalizer.go # 编码归一化引擎 │ ├── homoglyph_map.go # 750+ 同形字符映射 │ └── semantic/ # 语义分析(34 个 AST 分析器) │ ├── semantic_engine.go # 调度中心 │ ├── sql_ast_analyzer.go │ ├── xss_ast_analyzer.go │ ├── deserialization_analyzer.go │ ├── open_redirect_analyzer.go │ └── ... ├── cmd/ # 独立工具 │ ├── intel_export/ │ ├── log_intel_builder/ │ └── waf-monitor/ └── data/ # 运行数据(日志/封禁/白名单/缓存)
五、快速开始
开源版(PHP,MIT 协议) : git clone github.com/anye1991/sh… require_once 'shield-waf-master/src/WAF.php'; 商业版(Go,OpenSSF GOLD) : github.com/anye1991/shield-waf-enterprise duduziy.com/shield-waf 开源版(MIT · OpenSSF Passing)打基础,商业版(Go · OpenSSF GOLD 黄金级认证)上生产。
六、写在最后
做安全,不是比谁封得多,是比谁误杀少。
规则型 WAF 的时代没有结束,但它的天花板已经清晰可见。语义分析不是银弹,但它至少打开了一个新的方向——从匹配字符串,到理解语法。
这条路还很长。34 个分析器只是起点,不是终点。
如果你也在自研 WAF,或者对上面某个坑有更好的解法,欢迎在评论区交流。
本文基于真实生产环境日志,IP、UA 已脱敏处理。文中所述 WAF 为作者自研的盾甲 WAF(ShieldWAF)。