Files
tamabox/internal/security/honeypot/page.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

198 lines
7.1 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.
package honeypot
import (
"html"
"net/http"
"strings"
)
// LoginPageVars 是假登录页的渲染参数。
type LoginPageVars struct {
// Action 是表单提交地址(当前诱饵路径本身)。
Action string
// Title / Heading 覆盖家族默认的标题与品牌名(留空即用家族设定)。
Title string
Heading string
// Notice 是自定义提示语;留空时由 Failed 决定是否显示家族的失败文案。
Notice string
// Failed 表示这是一次「提交后」的响应,需要显示登录失败提示。
Failed bool
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
Redir string
}
// LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
// 已经出现漂移。
const LoginPageTitle = "Log In ‹ WordPress"
// 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 按家族渲染假登录页。
//
// 资源占用刻意压到最低:全部是编译期常量 + 一次 strings.Builder 拼接,
// 没有 html/template 解析、没有文件读取、没有外部请求。一个页面约 1.5–2.5 KB。
//
// 写的时候刻意不用 `"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 = s.title
}
brand := vars.Heading
if brand == "" {
brand = s.brand
}
notice := vars.Notice
if notice == "" && vars.Failed {
notice = s.failure
}
var b strings.Builder
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())
}
// RenderLoginPage 保留旧签名(WordPress 家族),供既有调用方与测试使用。
func RenderLoginPage(vars LoginPageVars) []byte {
return RenderFamily(FamilyWordPress, 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")
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)
}