用 Go 写了一个单二进制 WAF,聊聊架构取舍和 5 个踩坑点

0 阅读6分钟

作者:暗夜铭少

这个 WAF 跑在我自己的服务器上,单二进制、零外部依赖。半年下来最大的收获不是拦截率,是理解了误杀比漏杀更致命。

本文不聊概念,只聊三件事:为什么这么架构、踩了哪 5 个坑、怎么填的。

一、为什么用 Go,为什么单二进制

先说选型。

为什么不用 PHP 写? PHP 版我写过一版(开源在 GitHub),单文件引入,嵌入网站同服务器。但它有个天花板:它和业务代码共享同一个 PHP 进程。WAF 检测越重,业务响应越慢;WAF 崩了,业务也跟着崩。

为什么用 Go? 三个原因:

  1. 独立反向代理,透明部署。WAF 跑在 8443,用户请求经过它再到后端 8088。WAF 挂了,后端还在;WAF 重启,用户无感。
  2. 原生并发。Go 的 goroutine 处理高并发请求,单节点 QPS 10,000+,p95 延迟 < 5ms。
  3. 编译成单个二进制。部署就是“丢一个文件、跑起来”。没有 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%)
最常被伪造的 UAJetpack by WordPress.com
单日最高封禁 IP43 个
误杀率(修复后)< 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)。