线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装: 一、/xmlrpc.php 回的是 HTML 登录页(P1) 我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来, 一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。 更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、 且绕开登录页限速插件,是 WP 爆破里产出最高的入口。 新增 xmlrpc.go: - 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致) - POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在) - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP) - 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除 methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让 配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进 fields(xmlrpc.0/1/…),配对失败也不丢原文。 multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串 密码是常见字典形态,只留第一组等于丢掉整份字典。 - 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。 二、进入页面的隐藏值每次请求都一样(P2) 两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能 判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token, 不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级), 页头、hidden 与 Set-Cookie 共用同一套。 顺带: - X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身 就是破绽;没配 external_url 时干脆不发这个头。 - Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0 (no-store 更像安全设备/代理加的,不像登录页)。 - Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。 - Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php, 原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。 测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id / 非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且 是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、 所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 + 回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。 四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、 .php 兜底失效。 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码), AssetFor 仍 0 分配。
515 lines
21 KiB
Go
515 lines
21 KiB
Go
// 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
|
||
}
|
||
|
||
// XML-RPC 端点单独处理:真实 WordPress 在这里回 XML,而不是登录页。
|
||
// 我们自己的 X-Pingback 头正指着这个地址,回 HTML 等于自曝。
|
||
if honeypot.IsXMLRPCPath(path) {
|
||
h.serveXMLRPC(ctx, clientIP, banKey, path)
|
||
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"),
|
||
BaseURL: conf.App.ExternalURL,
|
||
})
|
||
}
|
||
|
||
// serveXMLRPC 处理 /xmlrpc.php。
|
||
//
|
||
// 为什么单独做:system.multicall 一次请求能带上千组用户名/密码,且绕开登录页的
|
||
// 限速插件 —— 这是 WordPress 爆破里产出最高的一条通道,xxer、WPScan 的 xmlrpc
|
||
// 模式都走它。原来的实现把这个路径当普通诱饵,回一个 HTML 登录页:
|
||
// 既拿不到字典,又因为 X-Pingback 指着它而暴露「整个后台是假的」。
|
||
//
|
||
// 响应形态对齐真实 WordPress:
|
||
// - 非 POST(GET/PUT/…)→ 405 + fault「XML-RPC services are disabled on this site.」
|
||
// - POST + 凭据 → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
|
||
// - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
|
||
func (h *honeypotHandler) serveXMLRPC(ctx flamego.Context, clientIP, banKey, path string) {
|
||
req := ctx.Request().Request
|
||
w := ctx.ResponseWriter()
|
||
|
||
if req.Method != http.MethodPost {
|
||
// 纯探测:计入常规敏感路径统计(阈值同其它诱饵),不立即封。
|
||
if h.ab != nil && banKey != "" {
|
||
h.ab.Record(banKey, path, autoban.KindSensitive)
|
||
}
|
||
h.record(clientIP, banKey, req, path, honeypot.Credentials{}, false)
|
||
honeypot.WriteXMLRPCFault(w, http.StatusMethodNotAllowed, 405, honeypot.XMLRPCFaultDisabled)
|
||
return
|
||
}
|
||
|
||
// body 只能读一次,提取完就不能再给后面的通用逻辑用了。
|
||
creds := honeypot.ExtractXMLRPCCredentials(req)
|
||
|
||
// 与表单提交同一套判定:带凭据 → 蜜罐通道立即封;否则走常规阈值。
|
||
banned := false
|
||
if h.ab != nil && banKey != "" {
|
||
if creds.HadFields {
|
||
banned = h.ab.RecordHoneypot(banKey, path)
|
||
} else {
|
||
h.ab.Record(banKey, path, autoban.KindSensitive)
|
||
}
|
||
}
|
||
|
||
if !creds.HadFields && creds.Fields["xmlrpc.method"] == "system.listMethods" {
|
||
honeypot.WriteXMLRPCMethods(w)
|
||
} else {
|
||
honeypot.WriteXMLRPCFault(w, http.StatusOK, 403, honeypot.XMLRPCFaultBadLogin)
|
||
}
|
||
|
||
h.record(clientIP, banKey, req, path, creds, banned)
|
||
}
|
||
|
||
// 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])
|
||
}
|