Files
tamabox/internal/route/honeypot.go
T
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  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、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00

460 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"unicode"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
)
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
//
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
//
// 行为:
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
// - 提交了凭据 → 立即封禁(RecordHoneypot,不受 enable_auto_ban 约束)+ 告警邮件;
// - 纯路径探测 → 只计入常规敏感路径阈值,不立即封禁。必须留出「咬钩」的机会:
// 爆破脚本先 GET 探表单再 POST 提交,GET 就把人封了等于永远拿不到凭据;
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
//
// 蜜罐取证的去重/采样参数。
const (
// alertDedupTTL 同一个 IP 的告警冷却时间。旧实现是「永久只发一次」,
// 直接让 map 随源 IP 数量无界增长;24 小时既能压住噪音,也允许
// 同一 IP 在长期攻击中再次提醒。
alertDedupTTL = 24 * time.Hour
// probeSampleWindow 纯路径探测(无凭据)的落库采样窗口:
// 同一 IP 在窗口内最多落一行,保留「谁扫了哪些路径」的分布,
// 但单个 IP 的洪泛不会变成等量 INSERT。
probeSampleWindow = time.Minute
// maxDedupEntries 去重表容量上限,超出后先清过期项、仍超则按容量硬裁。
maxDedupEntries = 20000
)
// deduper 是一个带过期时间的 best-effort 去重器。
//
// 用「带 TTL 的 map」而不是永不过期的 map[string]bool:
// 蜜罐的键完全由外部流量决定(源 IP),后者等于把内存交给攻击者控制。
type deduper struct {
mu sync.Mutex
seen map[string]time.Time
}
func newDeduper() *deduper {
return &deduper{seen: make(map[string]time.Time)}
}
// first 报告 key 是否在窗口内首次出现;首次会登记,过期时间为 now+ttl。
// ttl <= 0 表示永不过期(仍受容量上限约束)。
func (d *deduper) first(key string, ttl time.Duration) bool {
if key == "" {
return true
}
now := time.Now()
d.mu.Lock()
defer d.mu.Unlock()
if until, ok := d.seen[key]; ok {
if ttl <= 0 || until.After(now) {
return false
}
}
d.seen[key] = now.Add(ttl)
if len(d.seen) > maxDedupEntries {
d.pruneLocked(now)
}
return true
}
// pruneLocked 先清过期项;仍超上限时按容量硬裁到一半。
// 硬裁是 best-effort:宁可偶尔漏一次去重,也不能让内存无界增长。
func (d *deduper) pruneLocked(now time.Time) {
for k, until := range d.seen {
if !until.IsZero() && !until.After(now) {
delete(d.seen, k)
}
}
limit := maxDedupEntries / 2
for k := range d.seen {
if len(d.seen) <= limit {
break
}
delete(d.seen, k)
}
}
type honeypotHandler struct {
hp *honeypot.Honeypot
ab *autoban.Banner
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
store db.HoneypotStore
// cstore 是凭据取证文件的存储,允许为 nil(目录不可写时降级)。
// 用户名/密码只走这里,绝不写进 store(数据库)。
cstore honeypot.CredStore
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
sendAlert func(mail.HoneypotAlert) error
// dedup 同时承担「告警每 IP 冷却」与「探测落库采样」。
dedup *deduper
}
// newHoneypotHandler 构造蜜罐处理器。
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore, cstore honeypot.CredStore) *honeypotHandler {
return &honeypotHandler{
hp: hp,
ab: ab,
store: store,
cstore: cstore,
dedup: newDeduper(),
}
}
// Serve 处理一次蜜罐命中。
func (h *honeypotHandler) Serve(ctx flamego.Context) {
req := ctx.Request().Request
// 两个不同的东西,必须分开用:
//
// clientIP —— 精确客户端地址(IPv6 保留完整地址),取证与告警展示用;
// banKey —— 聚合键(shared.LimitKey,IPv6 折成 /64),封禁与采样去重用。
//
// 曾经两者混用(都取 banKey),后果是取证只留下 /64 前缀:运维收到
// 「你的 IP 已被封禁」时发现邮件里那个地址根本不是自己的,排查方向直接被带偏;
// 而且精确地址一丢,拿去查其他日志或做 abuse 投诉就再也对不上了。
clientIP := shared.ResolveClientIP(req)
banKey := shared.LimitKey(clientIP)
// 反代/网关可能把 URI 带上首尾空白:实测 Cloudflare → nginx → 源站 链路上
// 出现过 ` /wp-login.php`。Match 内部会 normalize(含 TrimSpace),所以能命中,
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
// 甚至让爆破脚本把表单提交到错误路径。这里统一清洗一次。
path := cleanPath(req.URL.Path)
// 静态资源短路(必须放在最前面)。
//
// 真实后台登录页会加载一堆 CSS/JS/图标;这些请求如果也走后面的落库逻辑,
// 一次页面加载就能放大出十几条 INSERT,等于帮攻击者把写入成本转嫁给我们。
// 这里只回占位内容:不解析 IP、不判定封禁、不读 body、不记录 ——
// 已封禁的来源在中间件里就被挡掉了,轮不到这里。
if body, ctype, status, isAsset := honeypot.AssetFor(path); isAsset {
w := ctx.ResponseWriter()
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.WriteHeader(status)
if len(body) > 0 {
_, _ = w.Write(body)
}
return
}
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
fam := honeypot.FamilyOf(path)
// 诊断:清洗过说明上游注入了空白/零宽字符。用 %q 打出来 ——
// 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。
if raw := req.URL.Path; raw != path {
logrus.WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
"raw": fmt.Sprintf("%q", raw),
"used": fmt.Sprintf("%q", path),
}).Warn("蜜罐路径含上游注入的空白/零宽字符,已清洗后使用")
}
// 来源 IP 是回环地址 + 未开 trust_proxy,是「站点在反代后面但没配信任」的
// 典型信号。此时所有访客在源站眼里都是同一个 IP:
// 取证数据失去意义,且一次封禁会连带封掉整站(包括普通访客)。
// 只告警不改变行为 —— 本地直连调试时回环 IP 是正常的。
if clientIP != "" && !conf.Security.TrustProxy && isLoopbackIP(clientIP) {
if h.dedup.first("warn:loopback", 24*time.Hour) {
logrus.Warn("蜜罐记录的来源 IP 是回环地址(127.0.0.1 / ::1):站点很可能在反向代理后面," +
"[security] trust_proxy 未开启。此时所有访客共享同一个 IP —— 取证失去意义," +
"且一次封禁会连带封掉整站。请配置 trust_proxy = true 与 trusted_proxies。")
}
}
// body 只能读一次:先提取凭据(POST 时读 body),再决定封禁与响应。
creds := h.hp.ExtractCredentials(req)
// 封禁分两条路,这是蜜罐能不能「钓到东西」的关键:
//
// - 提交了凭据(POST 带 log/pwd)→ 走蜜罐独立通道 RecordHoneypot,
// 不受 enable_auto_ban 约束,阈值默认 1,当场封禁。这才是证据最强的行为。
// - 纯路径探测(GET 看一眼假登录页)→ 走常规 Record(阈值默认 10 次 /
// 60 秒)。绝不能一次就封:爆破脚本的标准流程是「先 GET 探测表单再 POST
// 提交」,GET 就把人封了等于假登录页只被看了一次,对方根本没机会填表,
// 凭据无从谈起 —— 而这个功能的全部价值就在那份凭据上。
//
// 纯探测不封也不会被刷爆:落库有「每 IP 每分钟一条」的采样(见 record)。
banned := false
if h.ab != nil && banKey != "" {
if creds.HadFields {
banned = h.ab.RecordHoneypot(banKey, path)
} else {
h.ab.Record(banKey, path, autoban.KindSensitive)
}
}
switch req.Method {
case http.MethodGet, http.MethodHead:
h.writeLogin(ctx, fam, path, false)
case http.MethodPost, http.MethodPut, http.MethodPatch:
// 提交后仍返回该家族的登录页,带自己的失败文案(Tomcat 家族则是 401)。
h.writeLogin(ctx, fam, path, true)
default:
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
}
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
h.record(clientIP, banKey, req, path, creds, banned)
}
// cleanPath 去掉路径首尾的空白与零宽字符,并保证前导斜杠。
//
// 为什么不能只用 strings.TrimSpace:它只认 Unicode White_Space,
// 而 U+200B(零宽空格)/ U+200C / U+200D / U+FEFF(BOM)都属于「看不见但不算空白」
// 的字符 —— 反代或某些 WAF 改写 URI 时可能注入它们,TrimSpace 会原样放过,
// 甚至因为首字符不是 '/' 而额外补一个斜杠,拼出 "/\u200b/wp-login.php" 这种怪路径。
//
// 刻意只处理首尾:中间的零宽字符可能是攻击 payload 的一部分,取证要保留原样。
// 也刻意不改大小写:URL 路径大小写敏感。
func cleanPath(p string) string {
p = strings.TrimFunc(p, func(r rune) bool {
switch r {
case '\u200b', '\u200c', '\u200d', '\ufeff':
return true
}
return unicode.IsSpace(r)
})
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
// isLoopbackIP 判断归一化后的限流键是否指向本机回环地址。
//
// 不用 net.ParseIP:shared.LimitKey 会把 IPv6 改写成 "xxxx::/64" 这种带前缀长度的
// 形式,ParseIP 解析不了;这里按语义做前缀判断即可。
func isLoopbackIP(ip string) bool {
host := ip
if i := strings.Index(host, "/"); i >= 0 {
host = host[:i]
}
if host == "::" || host == "::1" {
return true
}
return strings.HasPrefix(host, "127.")
}
// writeLogin 是假登录响应的唯一出口。
//
// 按家族渲染(WordPress / phpMyAdmin / Jenkins / Tomcat …),families 见
// honeypot.FamilyOf:扫描器会按路径先判断「这是什么后台」,再拿它认识的
// 字段名去填;所有路径都回 WordPress 页面等于告诉对方「这里没有你要找的东西」。
//
// failed 表示这是提交后的响应,需要显示该家族自己的失败文案
// (不区分「用户不存在」与「密码错误」,避免给爆破脚本用户名枚举信号)。
//
// 之前这里手抄过一遍响应头,已经漂移(标题一处字面量 `‹`、一处 `\u2039`);
// 现在响应头也归 page.go 统一设置,改一处即可。
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
Action: path,
Failed: failed,
Redir: ctx.Request().URL.Query().Get("redirect_to"),
})
}
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
//
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
// 采样理由:honeypot_hits 的写入完全由外部流量驱动,一个诱饵 404 就 INSERT
// 一行,等于把「零成本探测」放大成数据库写压力;而 20 万行的数量闸每小时
// 才跑一轮,挡不住短时洪泛。提交了凭据的请求才是高价值证据(必须一条不漏),
// 纯路径探测按来源 + 窗口采样即可 —— 路径分布仍然保留。
func (h *honeypotHandler) record(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
// 采样按聚合键:同一 /64 内轮换地址的探测属于同一个来源,不必各存一条。
if !creds.HadFields && !h.dedup.first("probe:"+banKey, probeSampleWindow) {
return
}
query := ""
if req.URL != nil {
query = req.URL.RawQuery
}
// 凭据先落文件:这是不可再生的证据,优先级高于元数据入库。
// 写失败只告警 —— 响应已经发出去了,取证失败不能反过来影响请求。
if creds.HadFields {
h.saveCredential(clientIP, req, path, query, creds, banned)
}
provider, proxied := ipinfo.Lookup(clientIP)
// 元数据入库。这里刻意只有「发生过一次带凭据的提交」这个事实(HadFields),
// 不含任何凭据内容 —— 见 db.HoneypotHit.HadFields 的注释。
// 所有字符串列都按 rune 截到列宽以内。少截一处就会在 PostgreSQL 上
// 以 `value too long for type character varying(N)` 整条插入失败 ——
// 取证记录静默丢失,而攻击者只要发一个超长 URL 就能稳定触发。
hit := &db.HoneypotHit{
IP: truncate(clientIP, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
HadFields: creds.HadFields,
UserAgent: truncate(req.UserAgent(), 500),
Referer: truncate(req.Referer(), 500),
ContentType: truncate(req.Header.Get("Content-Type"), 250),
Provider: provider,
Proxied: proxied,
Banned: banned,
CreatedAt: time.Now(),
}
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
if creds.HadFields {
h.alert(clientIP, banKey, req, path, creds, provider, proxied, banned)
}
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
if creds.HadFields {
logrus.WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
"path": path,
"username": creds.Username,
"banned": banned,
}).Warn("honeypot: credential submission captured")
}
if h.store == nil {
return
}
// 用独立超时上下文,避免请求被取消时取证写不进去。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := h.store.Create(ctx, hit); err != nil {
logrus.WithError(err).WithField("ip", clientIP).WithField("path", path).
Warn("honeypot: failed to persist hit")
}
}
// saveCredential 把一次凭据提交追加到取证文件。
//
// 为什么不入库:见 honeypot.CredRecord 的注释 —— 明文账号密码一旦入库,
// 就会跟着进备份、主从复制和任何能连库的报表工具,事后无法彻底清除。
// 这里也不做「脱敏后入库」的折中:半吊子的脱敏既不能用于取证,
// 也不能消除「凭据以某种形式留在库里」这个事实。
//
// 文件写入失败只告警不重试:蜜罐是尽力而为的取证,不能因为磁盘满
// 反过来影响已经发出的响应;而且重试会放大攻击者的写入能力。
// clientIP 必须是精确地址(不是 shared.LimitKey 折叠后的 /64 键):
// 这份文件是取证物,IPv6 只留前缀等于把「哪台主机」永久丢掉了。
func (h *honeypotHandler) saveCredential(clientIP string, req *http.Request, path, query string, creds honeypot.Credentials, banned bool) {
if h.cstore == nil {
logrus.WithField("ip", clientIP).
Warn("蜜罐捕获到凭据,但凭据目录不可用,本条凭据未被保存")
return
}
rec := honeypot.CredRecord{
Time: time.Now(),
IP: truncate(clientIP, 64),
Method: truncate(req.Method, 16),
Path: truncate(path, 500),
Query: truncate(query, 1000),
Username: truncate(creds.Username, 512),
Password: truncate(creds.Password, 512),
Fields: creds.Fields,
RawBody: creds.RawBody,
UserAgent: truncate(req.UserAgent(), 500),
Banned: banned,
}
if err := h.cstore.Append(rec); err != nil {
logrus.WithError(err).WithField("ip", clientIP).Warn("蜜罐凭据写入文件失败")
}
}
// alert 发送一次告警邮件(同一来源在冷却期内只发一封)。
//
// clientIP 进正文(人要看的是「谁来了」,必须是精确地址);
// banKey 用作去重键,同时也让邮件能说清「实际被封的是哪个键」——
// IPv6 下两者不同(键是 /64 前缀),邮件只给其中一个都会让人误判。
func (h *honeypotHandler) alert(clientIP, banKey string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
if h.sendAlert == nil || clientIP == "" {
return
}
dedupKey := banKey
if dedupKey == "" {
dedupKey = clientIP
}
// 同一来源在冷却期内只发一封:爆破脚本会在封禁前反复 POST,
// 不去重会把邮箱打爆。按聚合键去重 —— 同一 /64 内换地址仍是同一个人。
if !h.dedup.first("alert:"+dedupKey, alertDedupTTL) {
return
}
// 邮件里报告的时长必须与 autoban 实际使用的蜜罐时长一致,
// 否则运维按邮件判断「什么时候解封」会错。
durationMin := conf.Security.HoneypotBanMinutes
if durationMin <= 0 {
durationMin = conf.Security.BanDurationMinutes
}
if durationMin <= 0 {
durationMin = 15
}
go func() {
if err := h.sendAlert(mail.HoneypotAlert{
IP: clientIP,
BanKey: banKey,
Provider: provider,
Proxied: proxied,
Path: path,
Username: creds.Username,
Password: creds.Password,
Banned: banned,
DurationMin: durationMin,
}); err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"ip": clientIP,
"ban_key": banKey,
}).Warn("honeypot: failed to send alert mail")
}
}()
}
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
func truncate(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}