一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。
二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。
三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
可稳定触发)。统一改 rune 截断并按列宽逐项对齐。
四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。
五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。
验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
123 lines
6.5 KiB
Go
123 lines
6.5 KiB
Go
package honeypot
|
||
|
||
import (
|
||
"html"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
// LoginPageVars 是假登录页的渲染参数。
|
||
type LoginPageVars struct {
|
||
// Action 是表单提交地址(当前诱饵路径本身)。
|
||
Action string
|
||
// Title / Heading 是页面标题与表单标题。
|
||
Title string
|
||
Heading string
|
||
// Notice 是提交失败后的提示语。
|
||
Notice string
|
||
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
|
||
Redir string
|
||
}
|
||
|
||
// LoginPageTitle 是假登录页的统一标题(与真实 WordPress 登录页一致)。
|
||
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
|
||
// 已经出现漂移。
|
||
const LoginPageTitle = "Log In ‹ WordPress"
|
||
|
||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||
//
|
||
// 页面刻意包含:
|
||
// - 隐藏 canary 标记(HTML 注释),便于日后溯源;
|
||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||
// 我们就能拿到最干净的用户名与密码对;
|
||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||
//
|
||
// ⚠️ canary 只放在 HTML 注释里,不放进 <meta name="generator">。
|
||
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
|
||
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
|
||
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
|
||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||
action := html.EscapeString(vars.Action)
|
||
title := vars.Title
|
||
if title == "" {
|
||
title = LoginPageTitle
|
||
}
|
||
heading := vars.Heading
|
||
if heading == "" {
|
||
heading = "WordPress"
|
||
}
|
||
notice := ""
|
||
if vars.Notice != "" {
|
||
notice = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
|
||
}
|
||
redir := html.EscapeString(vars.Redir)
|
||
|
||
var b strings.Builder
|
||
b.Grow(4096)
|
||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||
// canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
|
||
b.WriteString(`<!-- build: ` + CanaryToken + ` -->` + "\n")
|
||
b.WriteString(`<html lang="en-US">` + "\n")
|
||
b.WriteString(`<head>` + "\n")
|
||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||
// generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
|
||
b.WriteString(`<meta name="generator" content="WordPress 6.4.3" />` + "\n")
|
||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||
b.WriteString(`</head>` + "\n")
|
||
b.WriteString(`<body class="login">` + "\n")
|
||
b.WriteString(`<div id="login">` + "\n")
|
||
b.WriteString(`<h1><a href="` + action + `" title="` + html.EscapeString(heading) + `">` + html.EscapeString(heading) + `</a></h1>` + "\n")
|
||
b.WriteString(notice + "\n")
|
||
b.WriteString(`<form name="loginform" id="loginform" action="` + action + `" method="post" enctype="application/x-www-form-urlencoded">` + "\n")
|
||
b.WriteString(`<p><label for="user_login">Username<br /><input type="text" name="log" id="user_login" class="input" value="" size="20" autocapitalize="off" autocomplete="username" /></label></p>` + "\n")
|
||
b.WriteString(`<p><label for="user_pass">Password<br /><input type="password" name="pwd" id="user_pass" class="input" value="" size="20" autocomplete="current-password" /></label></p>` + "\n")
|
||
b.WriteString(`<p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" /> Remember Me</p>` + "\n")
|
||
b.WriteString(`<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" /></p>` + "\n")
|
||
b.WriteString(`<input type="hidden" name="redirect_to" value="` + redir + `" />` + "\n")
|
||
b.WriteString(`<input type="hidden" name="testcookie" value="1" />` + "\n")
|
||
b.WriteString(`</form>` + "\n")
|
||
b.WriteString(`<p id="nav"><a href="` + action + `?action=lostpassword">Lost your password?</a> | <a href="` + action + `?action=register">Register</a></p>` + "\n")
|
||
b.WriteString(`</div>` + "\n")
|
||
b.WriteString(`<div id="footer"><p>` + html.EscapeString(NotFoundFooter) + `</p></div>` + "\n")
|
||
b.WriteString(`</body>` + "\n")
|
||
b.WriteString(`</html>` + "\n")
|
||
return []byte(b.String())
|
||
}
|
||
|
||
const honeypotCSS = `html{background:#f0f0f1}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;color:#3c434a}
|
||
#login{border:1px solid #dcdcde;background:#fff;width:320px;margin:5em auto;padding:26px 24px 46px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||
#login h1{text-align:center;margin:0 0 1em;font-size:24px;font-weight:400}
|
||
#login h1 a{color:#444;text-decoration:none}
|
||
#login form{margin-top:16px}
|
||
#login label{display:block;font-size:14px}
|
||
#login input[type=text],#login input[type=password]{width:100%;padding:5px 8px;border:1px solid #ddd;border-radius:3px;box-sizing:border-box;margin-top:4px}
|
||
#login .button{width:100%;padding:7px 12px;background:#2271b1;border:1px solid #2271b1;border-radius:3px;color:#fff;cursor:pointer;font-size:13px}
|
||
#login .submit{margin:16px 0 0}
|
||
#login_error{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:12px 0}
|
||
#nav,.forgetmenot{font-size:13px;color:#50575e}
|
||
#footer{text-align:center;color:#646970;font-size:12px;margin:24px auto;max-width:320px}
|
||
`
|
||
|
||
// NotFoundFooter 是 404 假页脚,用于给扫描器「后台存在但资源不存在」的错觉。
|
||
const NotFoundFooter = "The requested resource was not found on this server."
|
||
|
||
// LoginFailureNotice 是提交凭据后的统一提示(不区分「用户不存在」与「密码错误」,
|
||
// 避免因响应差异让爆破脚本调整策略;同时这也是虚假后台「存在」的证据)。
|
||
const LoginFailureNotice = "ERROR: Invalid username or password."
|
||
|
||
// WriteLoginPage 将假登录页写入响应。
|
||
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
|
||
body := RenderLoginPage(vars)
|
||
h := w.Header()
|
||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
// 诱饵页面绝不进入任何共享/边缘缓存,也不被中间层改写。
|
||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(body)
|
||
}
|