286 lines
17 KiB
Markdown
286 lines
17 KiB
Markdown
# 蜜罐(虚假后台登录页)
|
||
|
||
给扫描器一组假的后台登录页(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. 配置
|
||
|
||
```ini
|
||
[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 是一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),不再是那行 13 字节的明文:
|
||
|
||
```
|
||
$ curl -si https://你的站点/anything | head -4
|
||
HTTP/2 403
|
||
content-type: image/webp
|
||
content-length: 8608
|
||
cache-control: no-store, no-cache, must-revalidate
|
||
```
|
||
|
||
这样做有两个目的:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。
|
||
|
||
图片用 `go:embed` 编进二进制(`internal/route/assets/403.webp`),不占任何可公开访问的 URL。想换成自己的图:覆盖同目录文件后重新编译、重启即可。注意体积——被封的来源会反复重试,8.6 KB 是刻意压过的(原图别直接塞几百 KB 的)。
|
||
|
||
### 封的是一把「键」,不是一个人
|
||
|
||
`autoban` 的表就是内存里的 `map[键]到期时间`,键由 `shared.LimitKey` 生成:
|
||
|
||
- IPv4 → 原样(`203.0.113.7`)
|
||
- IPv6 → 折叠到 `/64`(`2408:8207:1234:5678::/64`),防止在单个 /64 内轮换地址绕过阈值
|
||
|
||
所以告警邮件里**可能有两个地址**:
|
||
|
||
| 字段 | 含义 |
|
||
|---|---|
|
||
| 来源 IP | 精确客户端地址,取证与对账用 |
|
||
| 封禁范围 | 实际写进封禁表的键;IPv6 下是 /64 前缀,整段一起封(IPv4 下与来源 IP 相同,不重复显示) |
|
||
|
||
两个直接后果,排障时先记住:
|
||
|
||
1. **封禁只在内存里** —— 重启进程即解除,邮件不会撤回;
|
||
2. **封的键与请求算出来的键必须一致** —— 换地址族(IPv6 访问 / IPv4 回源)或换 /64 前缀就不会被拦。
|
||
|
||
每次建封都会打一条 WARN,键、原因、时长、到期时间都在里面:
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
```json
|
||
{"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 —— **等于封掉整站**。
|
||
|
||
正确配置:
|
||
|
||
```ini
|
||
[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 清除
|
||
```
|
||
|
||
清理(**不可逆**,先备份):
|
||
|
||
```bash
|
||
pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
||
./tamabox honeypot purge-creds # 列出四列,输入 yes 才执行
|
||
```
|
||
|
||
只处理当前库;**历史备份与从库里可能仍有明文**,需要自行处理。
|
||
|
||
---
|
||
|
||
## 7. 日常运维
|
||
|
||
```bash
|
||
./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`**:没配就不发这个头(相对路径比不发更假)。
|