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 → 页面缺特征字段。两项均按预期变红。
This commit is contained in:
tamakyi
2026-10-23 12:38:35 +08:00
parent edde075406
commit 811578dcf1
9 changed files with 1067 additions and 108 deletions
+155 -80
View File
@@ -10,113 +10,188 @@ import (
type LoginPageVars struct {
// Action 是表单提交地址(当前诱饵路径本身)。
Action string
// Title / Heading 是页面标题与表单标题。
// Title / Heading 覆盖家族默认的标题与品牌名(留空即用家族设定)。
Title string
Heading string
// Notice 是提交失败后的提示语。
// Notice 是自定义提示语;留空时由 Failed 决定是否显示家族的失败文案。
Notice string
// Failed 表示这是一次「提交后」的响应,需要显示登录失败提示。
Failed bool
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
Redir string
}
// LoginPageTitle 是假登录页的统一标题(与真实 WordPress 登录页一致)。
// LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
// 已经出现漂移。
const LoginPageTitle = "Log In ‹ WordPress"
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
// LoginFailureNotice 是 WordPress 提交凭据后的提示
// (不区分「用户不存在」与「密码错误」,避免响应差异让爆破脚本调整策略)。
const LoginFailureNotice = "ERROR: The password you entered for the username is incorrect."
// baseLoginCSS 是全部家族共用的骨架样式,各家族只叠加自己的配色。
const baseLoginCSS = `*{box-sizing:border-box}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;background:#f0f0f1;color:#3c434a}` +
`.card{width:340px;margin:8vh auto;background:#fff;border:1px solid #dcdcde;border-radius:4px;padding:26px 24px 30px}` +
`h1{margin:0 0 6px;font-size:22px;font-weight:400;text-align:center}` +
`.tag{margin:0 0 16px;text-align:center;font-size:11px;color:#787c82;letter-spacing:.3px}` +
`label{display:block;margin-top:12px;font-size:13px}` +
`input[type=text],input[type=password]{display:block;width:100%;margin-top:5px;padding:7px 9px;border:1px solid #c3c4c7;border-radius:3px;font-size:14px}` +
`button{display:block;width:100%;margin-top:18px;padding:9px;border:0;border-radius:3px;color:#fff;font-size:14px;cursor:pointer}` +
`.err{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:0 0 16px;font-size:13px;line-height:1.5}` +
`.foot{margin:22px auto 0;text-align:center;font-size:12px;color:#787c82;line-height:1.7}`
// fieldIDReplacer 把 user[login] 这类字段名转成合法的 HTML id。
// 提到包级:每次渲染新建一个 Replacer 要分配十几次,而蜜罐是被高频命中的。
var fieldIDReplacer = strings.NewReplacer("[", "_", "]", "")
// RenderFamily 按家族渲染假登录页。
//
// 页面刻意包含:
// - 隐藏 canary 标记(HTML 注释),便于日后溯源;
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
// 我们就能拿到最干净的用户名与密码对;
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
// 资源占用刻意压到最低:全部是编译期常量 + 一次 strings.Builder 拼接,
// 没有 html/template 解析、没有文件读取、没有外部请求。一个页面约 1.5–2.5 KB。
//
// ⚠️ canary 只放在 HTML 注释里,不放进 <meta name="generator">。
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
func RenderLoginPage(vars LoginPageVars) []byte {
// 写的时候刻意不用 `"a" + b + "c"`:每个 + 都是一次临时分配。
// 连续 WriteString 走 append,配合一次 Grow,整页只有 2–3 次分配
// (见 family_test.go 的 BenchmarkRenderFamily)。
//
// canary 只放 HTML 注释里,不放进 <meta name="generator">:
// generator 是扫描器的必查项,必须伪装成真实产品,否则等于自曝蜜罐。
func RenderFamily(f Family, vars LoginPageVars) []byte {
s := skinOf(f)
action := html.EscapeString(vars.Action)
title := vars.Title
if title == "" {
title = LoginPageTitle
title = s.title
}
heading := vars.Heading
if heading == "" {
heading = "WordPress"
brand := vars.Heading
if brand == "" {
brand = s.brand
}
notice := ""
if vars.Notice != "" {
notice = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
notice := vars.Notice
if notice == "" && vars.Failed {
notice = s.failure
}
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")
b.Grow(3072 + len(s.css))
b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
b.WriteString("<meta charset=\"utf-8\">\n")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n")
b.WriteString("<meta name=\"robots\" content=\"noindex,nofollow\">\n")
if s.gen != "" {
b.WriteString("<meta name=\"generator\" content=\"")
b.WriteString(html.EscapeString(s.gen))
b.WriteString("\">\n")
}
b.WriteString("<title>")
b.WriteString(html.EscapeString(title))
b.WriteString("</title>\n<style>")
b.WriteString(baseLoginCSS)
b.WriteString(s.css)
b.WriteString("</style>\n</head>\n<body class=\"")
b.WriteString(s.class)
b.WriteString("\">\n<!-- build: ")
b.WriteString(CanaryToken)
b.WriteString(" -->\n<div class=\"card\">\n<h1>")
b.WriteString(html.EscapeString(brand))
b.WriteString("</h1>\n")
if s.tagline != "" {
b.WriteString("<p class=\"tag\">")
b.WriteString(s.tagline)
b.WriteString("</p>\n")
}
if notice != "" {
b.WriteString("<div class=\"err\">")
b.WriteString(html.EscapeString(notice))
b.WriteString("</div>\n")
}
b.WriteString("<form method=\"post\" action=\"")
b.WriteString(action)
b.WriteString("\" name=\"")
b.WriteString(s.form)
b.WriteString("\" id=\"")
b.WriteString(s.form)
b.WriteString("\">\n")
for _, fld := range s.fields {
typ, ac := "text", "username"
if fld.secret {
typ, ac = "password", "current-password"
}
id := "id_" + fieldIDReplacer.Replace(fld.name)
b.WriteString("<label for=\"")
b.WriteString(id)
b.WriteString("\">")
b.WriteString(fld.label)
b.WriteString("<input type=\"")
b.WriteString(typ)
b.WriteString("\" name=\"")
b.WriteString(fld.name)
b.WriteString("\" id=\"")
b.WriteString(id)
b.WriteString("\" value=\"\" autocomplete=\"")
b.WriteString(ac)
b.WriteString("\"></label>\n")
}
for _, h := range s.hidden {
v := h[1]
if v == "$redirect" {
v = vars.Redir
}
b.WriteString("<input type=\"hidden\" name=\"")
b.WriteString(h[0])
b.WriteString("\" value=\"")
b.WriteString(html.EscapeString(v))
b.WriteString("\">\n")
}
if s.submit != "" {
b.WriteString("<button type=\"submit\">")
b.WriteString(s.submit)
b.WriteString("</button>\n")
}
b.WriteString("</form>\n")
if s.footer != "" {
b.WriteString("<p class=\"foot\">")
b.WriteString(s.footer)
b.WriteString("</p>\n")
}
b.WriteString("</div>\n</body>\n</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}
`
// RenderLoginPage 保留旧签名(WordPress 家族),供既有调用方与测试使用。
func RenderLoginPage(vars LoginPageVars) []byte {
return RenderFamily(FamilyWordPress, vars)
}
// 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)
// WriteFamilyPage 写出某个家族的假登录页,并带上该家族的伪装响应头。
//
// Tomcat Manager 未认证时真实返回 401 + WWW-Authenticate,所以这里也照做 ——
// 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。
func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) {
s := skinOf(f)
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)
for _, kv := range s.headers {
h.Set(kv[0], kv[1])
}
for _, c := range s.cookies {
h.Add("Set-Cookie", c)
}
status := http.StatusOK
if s.basicRealm != "" {
h.Set("WWW-Authenticate", `Basic realm="`+s.basicRealm+`"`)
status = http.StatusUnauthorized
}
w.WriteHeader(status)
_, _ = w.Write(RenderFamily(f, vars))
}
// WriteLoginPage 保留旧签名(WordPress 家族)。
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
WriteFamilyPage(w, FamilyWordPress, vars)
}