上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、 由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。 改为配置驱动: - 新增 [security] ban_image_path(相对路径以进程工作目录为基准); - 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字 "403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为 一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn); - 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。 安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。 把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件 原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。 顺带删掉 internal/route/assets(webp + embed),已无引用。 测试: - TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文; - TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、 Content-Length 与文件一致、body 与文件逐字节相同; - TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误 都不得装载,且**不得把文件内容当响应体吐出去**; - TestLoadBanImageTooLarge:超 2 MB 拒绝; - TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。 变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去: "[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。 实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
17 KiB
蜜罐(虚假后台登录页)
给扫描器一组假的后台登录页(WordPress / phpMyAdmin / Jenkins / Tomcat / Grafana …):它探后台路径时返回对应产品外观的假页面,它提交账号密码时把凭据记下来、并把它临时封掉。
一句话取舍:这套功能的价值是「拿到对方正在用的字典」,不是「挡住扫描」。 挡扫描靠 CDN 和 autoban;蜜罐只在「有人真的往假后台里填账号密码」时才有意义。
1. 生效范围
蜜罐挂在 f.NotFound 上 —— flamego 只有路由表完全落空时才会走到这里,所以:
/login、/register、/_/{domain}、/user/**、/api/v1/**这些真实路由永远不会被接管;- 命中后短路返回,不查业务表、不渲染业务模板、不调用
ctx.Next(); - 页面是纯静态 HTML,只含 canary 标记和假表单,不含任何真实信息。
匹配规则(internal/security/honeypot/honeypot.go):
| 类型 | 例子 |
|---|---|
| 段前缀 | /admin、/wp-admin、/.ssh、/.env、/phpmyadmin、/actuator |
| 任意位置片段 | /wp-includes/、/xmlrpc.php、/cgi-bin/、/.git/ |
| 文件后缀 | *.zip、*.sql、*.bak、*.pem、*.key、*.env |
| 自定义 | [security] honeypot_paths(逗号分隔,与内置清单取并集) |
业务路由前缀(/login、/api、/static 等)即使出现在自定义清单里也会被强制剔除,构成第二道保险。
改完路径后用 tamabox honeypot paths 看最终生效的完整清单(会按家族汇总条数)。
会伪装成哪几类后台
| 家族 | 典型路径 | 表单字段 / 响应 |
|---|---|---|
| WordPress | /wp-login.php、/wp-admin、/wp-json |
log / pwd,带 wordpress_test_cookie |
| phpMyAdmin | /phpmyadmin、/pma、/adminer.php |
pma_username / pma_password |
| Tomcat | /manager/html、/host-manager |
401 + WWW-Authenticate(Basic,凭据从 Authorization 头取) |
| Jenkins | /jenkins、/jnlpJars |
j_username / j_password,带 X-Jenkins 头 |
| Grafana | /grafana |
user / password(深色主题) |
| GitLab | /users/sign_in |
user[login] / user[password] |
| LuCI(路由器) | /cgi-bin/luci、/luci |
luci_username / luci_password |
| Joomla | /administrator |
username / passwd |
| 通用后台 | /admin、/console、/zabbix、/jira … |
username / password |
为什么要分类:扫描器和爆破脚本都是「先按路径判断这是什么后台,再用它认识的字段名填表」。所有路径都回 WordPress 页面,等于在 /phpmyadmin/index.php 上告诉对方「这里不是 PMA」,对方直接换目标 —— 我们既拿不到凭据,也留不下有效取证。
静态资源也一起伪装:/wp-admin/css/login.min.css、/wp-includes/js/*.js、/wp-admin/images/*.png 这类请求回占位 CSS/JS/真 1×1 PNG。这些请求不落库、不计数 —— 一次页面加载会打 5–10 个资源,逐个 INSERT 等于帮攻击者把写入放大十倍。插件目录下的 .php 则回 404(那是 webshell 探测,回登录页反而不像)。
/xmlrpc.php 单独按 XML-RPC 回应,不是登录页:
| 请求 | 响应 |
|---|---|
GET / 其它非 POST |
405 + XML fault「XML-RPC services are disabled on this site.」 |
POST 普通 methodCall |
200 + XML fault「Incorrect username or password.」 |
POST system.multicall |
同上,但一包里的所有凭据对都会被记下来 |
POST system.listMethods |
200 + 方法列表(枚举类工具靠它确认对面是真 WP) |
为什么值得单独做:system.multicall 一次请求能塞进上千组用户名/密码,且绕开登录页的限速插件,是 WordPress 爆破里产出最高的一条通道。我们自己的 X-Pingback 响应头又正指着这个地址 —— 回 HTML 等于自己指路给对方看破绽。
动态值(phpMyAdmin 的 token、通用后台的 csrf_token、Jenkins 的 X-Jenkins-Session、GitLab 的 _gitlab_session)每个请求都不同。恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 token,任何脚本都能判定对面是伪造的。
单页渲染约 3.7 µs / 2 次分配、页面 1.5–2.5 KB,资源请求 0 分配(BenchmarkRenderFamily / BenchmarkAssetFor 守着)。
2. 配置
[security]
enable_honeypot = true
honeypot_paths =
honeypot_threshold = 1
honeypot_ban_minutes = 1440
honeypot_retain_days = 30
honeypot_auto_clean = true
honeypot_max_rows = 200000
honeypot_cred_dir = data/honeypot-creds
| 键 | 缺省 | 说明 |
|---|---|---|
enable_honeypot |
false |
总开关。关闭后 /wp-admin 等路径恢复原先的 302 → / |
honeypot_paths |
空 | 追加诱饵路径,与内置清单取并集 |
honeypot_threshold |
1 |
提交凭据的封禁阈值(见 §3) |
honeypot_ban_minutes |
1440 |
蜜罐封禁时长;显式 0 回退 ban_duration_minutes |
honeypot_retain_days |
30 |
元数据与凭据文件的保留天数;<= 0 也回退 30 |
honeypot_auto_clean |
true |
每小时一轮后台清理;关闭后只能手动 honeypot prune |
honeypot_max_rows |
200000 |
元数据行数上限(第二道闸);显式 0 = 不限 |
honeypot_cred_dir |
data/honeypot-creds |
凭据文件目录,进程工作目录为基准 |
⚠️ 配置项一律写成「注释独占一行 + 裸值」。配置加载器开启了
IgnoreInlineComment,行尾; 注释会被并入值:honeypot_max_rows = 200000 ; 说明解析出来是"200000 ; 说明",int 解析失败而MapTo不报错 —— 该项静默回退零值,日志里只会说「配置值不对」。
改完配置必须重启(配置只在启动时读一次)。
3. 两条封禁路径(最容易误解的地方)
| 行为 | 走哪条通道 | 阈值 | 是否立即封 |
|---|---|---|---|
GET /wp-login.php 看一眼假页面 |
常规 autoban.Record |
ban_threshold_sensitive(默认 10 次 / 60 秒) |
否 |
GET /wp-admin/css/login.min.css 等资源 |
不计数、不落库 | — | 否 |
POST 带该家族的凭据字段(log/pwd、pma_*、j_*、user[login] …) |
蜜罐 autoban.RecordHoneypot |
honeypot_threshold(默认 1) |
是,立即封 |
POST /xmlrpc.php 带 methodCall(含 system.multicall) |
蜜罐 autoban.RecordHoneypot |
同上 | 是,立即封 |
GET /xmlrpc.php / POST system.listMethods |
常规通道 | 同上 | 否 |
POST 不带任何凭据字段 |
常规通道 | 同上 | 否 |
为什么 GET 不立即封:爆破脚本的标准流程是「先 GET 探表单 → 再 POST 提交」。如果 GET 就吃 403,假登录页只被看了一次,对方根本没机会填表,凭据永远拿不到 —— 而这个功能的全部价值就在那份凭据上。
蜜罐通道不受 enable_auto_ban 约束:关掉常规自动封禁(它对共享出口误伤多)不会连带让蜜罐变成只记录不封禁的旁观者。
纯探测也不会被刷爆:元数据落库按「每 IP 每分钟一条」采样,凭据则是「有就存」——那才是要保的东西。
被封之后会看到什么
状态码永远 403,body 由 [security] ban_image_path 决定:
[security]
ban_image_path = data/403.webp
| 情况 | 响应体 |
|---|---|
| 配了合法图片 | 图片本身(Content-Type 按内容嗅探,png/jpeg/gif/webp 都行) |
| 留空 | 纯文字 403 Forbidden(改造前的老行为) |
| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 403 Forbidden + 启动日志 Warn |
$ curl -si https://你的站点/anything | head -5
HTTP/2 403
content-type: image/webp
content-length: 8608
cache-control: no-store, no-cache, must-revalidate
为什么要能换成图:爆破脚本常靠读响应体里的 403 Forbidden 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是可选的——配错了不会影响封禁本身,只是回到纯文字。
几个约束,都是刻意的:
- 只认内容,不认扩展名(
http.DetectContentType)。把ban_image_path配成app.ini/.env会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。 - 上限 2 MB。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。
- 启动时读一次,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。
no-store:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。
封的是一把「键」,不是一个人
autoban 的表就是内存里的 map[键]到期时间,键由 shared.LimitKey 生成:
- IPv4 → 原样(
203.0.113.7) - IPv6 → 折叠到
/64(2408:8207:1234:5678::/64),防止在单个 /64 内轮换地址绕过阈值
所以告警邮件里可能有两个地址:
| 字段 | 含义 |
|---|---|
| 来源 IP | 精确客户端地址,取证与对账用 |
| 封禁范围 | 实际写进封禁表的键;IPv6 下是 /64 前缀,整段一起封(IPv4 下与来源 IP 相同,不重复显示) |
两个直接后果,排障时先记住:
- 封禁只在内存里 —— 重启进程即解除,邮件不会撤回;
- 封的键与请求算出来的键必须一致 —— 换地址族(IPv6 访问 / IPv4 回源)或换 /64 前缀就不会被拦。
每次建封都会打一条 WARN,键、原因、时长、到期时间都在里面:
journalctl -u tamabox -n 50 | grep "已自动封禁来源"
# ban_key=2408:8207:1234:5678::/64 reason=蜜罐提交凭据 duration=24h0m0s expires_at=...
ban_key 就是被封的键,与邮件里的「封禁范围」一致。若这里没有记录,说明当时根本没建封(邮件可能是别的通道发的);若有记录但仍能访问,就去比对请求算出来的键。
4. 凭据存在哪里
用户名/密码不进数据库,只追加到按天分文件的 JSONL:
data/honeypot-creds/creds-2026-10-21.jsonl
{"time":"2026-10-21T16:13:50+08:00","ip":"117.x.x.226","method":"POST",
"path":"/wp-login.php","username":"admin2","password":"test456",
"fields":{"log":"admin2","pwd":"test456"},"raw_body":"log=admin2&pwd=test456",
"user_agent":"curl/8.22.0","banned":true}
| 项 | 值 |
|---|---|
| 目录权限 | 0700(启动时强制 chmod) |
| 文件权限 | 0600 |
| 切分 | 按记录时间,一天一个文件 |
| 坏行 | 跳过(进程被杀留下的半条 JSON 不会让整天数据报废) |
| 清理 | 与 honeypot_retain_days 同步,只删「整天都过期」的文件 |
为什么不入库:明文账号密码一旦入库,就会跟着进备份、主从复制、以及任何有库只读权限的报表/BI 工具,事后无法彻底清除。文件可以单独挂卷、单独设权限、到期直接删。
数据库里只保留「发生过一次带凭据的提交」这个事实(honeypot_hits.had_fields),以及 IP / 路径 / UA / 是否封禁这些元数据。
ip两处存的都是精确客户端地址(IPv6 保留完整地址),不是封禁用的/64聚合键 —— 取证要能定位到具体主机。想知道「封了多大范围」,看告警邮件的「封禁范围」或日志里的ban_key。
⚠️ 这些是外部攻击者提交的真实账号密码,很可能是从别处泄露后复用的。请按敏感数据对待:不要提交进版本库(
/data/已在.gitignore)、不要参与备份、不要贴到公开群组。
5. 部署:反向代理必须配 trust_proxy
这是部署时最容易踩、后果最严重的一条。
如果站点在 nginx / Cloudflare 之后而 trust_proxy = false:
- 源站看到的所有请求都来自代理地址(常见
127.0.0.1),取证数据失去意义; - 更糟:一次蜜罐命中封的是
127.0.0.1,而所有访客在源站眼里都是这个 IP —— 等于封掉整站。
正确配置:
[security]
trust_proxy = true
trusted_proxies = 127.0.0.1
⚠️ 前提:源站不能直接被公网访问(Cloudflare Tunnel / Authenticated Origin Pull / 防火墙只放行回源网段)。
trust_proxy = true时程序会信任CF-Connecting-IP这类权威头,源站若可直连,攻击者能伪造它来绕过封禁、或反手封掉别人。
程序在「命中回环地址且 trust_proxy 未开」时会打一条一次性 WARN(24 小时冷却)提醒检查这里。
验证:触发一次蜜罐后看凭据文件里的 ip 是不是真实客户端地址。
6. 升级:清掉历史明文列
早期版本把 username / password / fields / raw_body 直接存在 honeypot_hits 上。结构体已经删掉这些字段,但 GORM 的 AutoMigrate 不会删既有列 —— 老库升级上来后,历史明文还在表里。
启动时会打一条 WARN:
honeypot_hits 上仍残留旧版明文凭据列,请执行 TamaBox honeypot purge-creds 清除
清理(不可逆,先备份):
pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
./tamabox honeypot purge-creds # 列出四列,输入 yes 才执行
只处理当前库;历史备份与从库里可能仍有明文,需要自行处理。
7. 日常运维
./tamabox honeypot hits # 最近 24h 的凭据(密码默认脱敏)
./tamabox honeypot hits --ip 1.2.3.4 --show-password
./tamabox honeypot stats --days 7 # 来源排行(IPv6 按 /64 聚合)+ 路径排行 + 凭据排行
./tamabox honeypot paths # 当前生效的诱饵路径
./tamabox honeypot size # 表行数 + 磁盘占用 + 凭据文件体积
./tamabox honeypot prune --days 30 # 手动按保留期清(元数据 + 凭据文件)
hits / paths 不连数据库(DB 挂了也能查)。
8. 排障
| 现象 | 原因 | 处理 |
|---|---|---|
| 邮件说封了,但还能正常访问 | ① 进程重启过(封禁是内存态)② 后续请求换了地址族 / 换了 /64 前缀 ③ trust_proxy 未配,封的是反代地址 |
看日志里 已自动封禁来源 的 ban_key,与 honeypot hits 里的 IP、以及本机 curl -6 ifconfig.co 比对;见 §3「封的是一把键」 |
/wp-admin 还是 302 |
enable_honeypot 未开 / 配置未重启生效 |
看启动日志;honeypot paths 确认开关状态 |
记录里 ip 是 127.0.0.1 |
trust_proxy 未配 |
见 §5;注意此时封禁会封全站 |
| 整站都返回 403 | 蜜罐把反代 IP 封了(上一条的后果) | 重启进程(封禁是内存态)或等 honeypot_ban_minutes 到期 |
| GET 假页面不封禁 | 设计如此 | 提交凭据才立即封,见 §3 |
记录里 path 带前导空白/奇怪字符 |
上游(nginx/CF)改写了 URI | 代码已清洗并打 WARN;根因建议查 proxy_pass / rewrite |
honeypot 命令不存在 |
二进制旧 / 两个构建入口未同步 | go build -o tamabox . 后重启进程 |
| 改了代码但行为没变 | 只替换了二进制,进程还在跑旧映像 | kill 旧进程后按原方式重新拉起 |
| 凭据文件没有生成 | honeypot_cred_dir 不可写 |
看启动日志里的「凭据目录不可用」告警 |
启动报 honeypot_hits 上仍残留… |
老库有历史凭据列 | honeypot purge-creds(§6) |
排障时两个容易误判的点:
- 验证二进制里有没有某个中文字符串,不能用
strings—— 它默认只输出可打印 ASCII,UTF-8 中文整段被丢弃,strings x | grep 中文恒为 0。用grep -ac "中文字符串" x。 - 替换二进制 ≠ 生效,进程必须重启;前台跑
./tamabox web若立刻返回,通常是端口被旧进程占着。 - 邮件里的「来源 IP」不是封禁键:IPv6 下前者是精确地址、后者是 /64 前缀,拿前者去查后者永远对不上。判「封没封」只看
ban_key。
9. 设计取舍(已知限制)
- GET 探测不封:见 §3。代价是扫描器可以反复拿假页面,收益是能拿到凭据。
- 假页面带 canary:只放在 HTML 注释里(
<!-- build: tb-hp-... -->),用于确认「这确实是蜜罐返回的页面」;<meta name="generator">伪装成WordPress 6.4.3,不主动暴露技术栈。 - 只挡 NotFound:如果将来给
/admin之类路径注册了真实路由,蜜罐会自动让位 —— 这是刻意设计(业务优先)。 - 告警邮件仍带明文用户名/密码:这正是它的价值(看到对方在用什么字典),但也意味着邮件是敏感物,收件人只应是你自己。
- 不主动反制:不回打、不上报第三方、不做删除/篡改,只有「记录 + 临时封禁」。
- XML-RPC 的凭据提取是启发式的:不上
encoding/xml解析(蜜罐会被高频命中),而是抓出所有<string>、剔除方法名与站点 URL 后两两配对。wp.getUsersBlogs的第一个参数是blog_id,若是数字会让配对错位一位——为此会把原始串全量写进取证文件的fields(xmlrpc.0、xmlrpc.1…),即便配对失败原文也不丢。 X-Pingback需要配external_url:没配就不发这个头(相对路径比不发更假)。