diff --git a/dev/preview-honeypot/main.go b/dev/preview-honeypot/main.go
new file mode 100644
index 0000000..816d33b
--- /dev/null
+++ b/dev/preview-honeypot/main.go
@@ -0,0 +1,77 @@
+// Command preview-honeypot 把全部伪装家族渲染成静态 HTML,供人工核对外观。
+//
+// 用法:go run ./dev/preview-honeypot
+// 产物:outputs/honeypot-preview/index.html(9 个 iframe 并排)
+//
+// 改动 internal/security/honeypot/family.go 的皮肤后重新跑一次,
+// 比对着源码想象 CSS 可靠得多 —— 上一版就是靠它发现 Tomcat 按钮没背景色。
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tamakyi/TamaBox/internal/security/honeypot"
+)
+
+type item struct {
+ fam honeypot.Family
+ action string
+ note string
+}
+
+func main() {
+ dir := "outputs/honeypot-preview"
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ panic(err)
+ }
+ items := []item{
+ {honeypot.FamilyWordPress, "/wp-login.php", "/wp-login.php · /wp-admin"},
+ {honeypot.FamilyPhpMyAdmin, "/phpmyadmin/index.php", "/phpmyadmin · /pma"},
+ {honeypot.FamilyTomcat, "/manager/html", "/manager/html · 401 Basic"},
+ {honeypot.FamilyJenkins, "/jenkins/login", "/jenkins"},
+ {honeypot.FamilyGrafana, "/grafana/login", "/grafana"},
+ {honeypot.FamilyGitLab, "/users/sign_in", "/users/sign_in"},
+ {honeypot.FamilyLuCI, "/cgi-bin/luci", "/cgi-bin/luci"},
+ {honeypot.FamilyJoomla, "/administrator/index.php", "/administrator"},
+ {honeypot.FamilyGeneric, "/admin", "/admin · /console"},
+ }
+
+ var cards strings.Builder
+ for _, it := range items {
+ name := it.fam.String()
+ body := honeypot.RenderFamily(it.fam, honeypot.LoginPageVars{
+ Action: it.action,
+ Redir: "/wp-admin/",
+ Failed: false,
+ })
+ if err := os.WriteFile(filepath.Join(dir, name+".html"), body, 0o644); err != nil {
+ panic(err)
+ }
+ fmt.Fprintf(&cards, `%s%s`,
+ name, it.note, name, name)
+ }
+
+ page := `
+蜜罐伪装家族预览
+
+蜜罐:9 个伪装家族
+每个 iframe 是 /wp-admin 这类诱饵路径的真实响应(同一份 RenderFamily 输出,未做任何美化)。Tomcat 家族在真实响应里是 401 + Basic Auth,这里有页面是为了看内容。
+` + cards.String() + `
`
+
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(page), 0o644); err != nil {
+ panic(err)
+ }
+ fmt.Println("预览已生成:", filepath.Join(dir, "index.html"))
+}
diff --git a/internal/cmd/honeypot.go b/internal/cmd/honeypot.go
index ae31337..270a2f6 100644
--- a/internal/cmd/honeypot.go
+++ b/internal/cmd/honeypot.go
@@ -398,6 +398,20 @@ func runHoneypotPaths(ctx *cli.Context) error {
fmt.Println(strings.Repeat("-", 72))
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
len(active), len(builtin), len(extra))
+
+ // 按家族汇总:运维真正关心的是「哪几类后台在伪装」,而不是 100 条路径。
+ // 家族决定返回哪种登录页(WordPress / phpMyAdmin / Jenkins / Tomcat …)。
+ counts := make(map[honeypot.Family]int, len(honeypot.Families()))
+ for _, p := range active {
+ counts[honeypot.FamilyOf(p)]++
+ }
+ parts := make([]string, 0, len(counts))
+ for _, fam := range honeypot.Families() {
+ if n := counts[fam]; n > 0 {
+ parts = append(parts, fmt.Sprintf("%s %d", fam, n))
+ }
+ }
+ fmt.Println("伪装家族:" + strings.Join(parts, " / "))
for _, p := range active {
fmt.Println(" " + p)
}
diff --git a/internal/route/honeypot.go b/internal/route/honeypot.go
index 6be73df..25f30c3 100644
--- a/internal/route/honeypot.go
+++ b/internal/route/honeypot.go
@@ -146,6 +146,28 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
// 但落库与假表单的 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 {
@@ -193,9 +215,10 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
switch req.Method {
case http.MethodGet, http.MethodHead:
- h.serveLoginPage(ctx, path)
+ h.writeLogin(ctx, fam, path, false)
case http.MethodPost, http.MethodPut, http.MethodPatch:
- h.serveLoginFailure(ctx, path)
+ // 提交后仍返回该家族的登录页,带自己的失败文案(Tomcat 家族则是 401)。
+ h.writeLogin(ctx, fam, path, true)
default:
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
ctx.ResponseWriter().WriteHeader(http.StatusOK)
@@ -246,31 +269,22 @@ func isLoopbackIP(ip string) bool {
return strings.HasPrefix(host, "127.")
}
-// serveLoginPage 返回假登录页。
-func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
- h.writeLogin(ctx, path, "")
-}
-
-// serveLoginFailure 返回「登录失败」的假响应。
+// writeLogin 是假登录响应的唯一出口。
//
-// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
-// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
-func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) {
- h.writeLogin(ctx, path, honeypot.LoginFailureNotice)
-}
-
-// writeLogin 是两种响应形态的唯一出口。
+// 按家族渲染(WordPress / phpMyAdmin / Jenkins / Tomcat …),families 见
+// honeypot.FamilyOf:扫描器会按路径先判断「这是什么后台」,再拿它认识的
+// 字段名去填;所有路径都回 WordPress 页面等于告诉对方「这里没有你要找的东西」。
//
-// 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type /
-// Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、
-// 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。
-func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
- honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
- Action: path,
- Title: honeypot.LoginPageTitle,
- Heading: "WordPress",
- Notice: notice,
- Redir: ctx.Request().URL.Query().Get("redirect_to"),
+// 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"),
})
}
diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go
index fcf6032..e6e4b44 100644
--- a/internal/route/honeypot_test.go
+++ b/internal/route/honeypot_test.go
@@ -507,3 +507,140 @@ func (c *captureStore) LegacyCredentialColumns(context.Context) ([]string, error
func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, error) {
return nil, nil
}
+
+// TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。
+//
+// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
+// 所有路径都回 WordPress 页面等于告诉对方「这里没有 PMA/Jenkins」。
+func TestHoneypotServesFamilySpecificPage(t *testing.T) {
+ ts := newHoneypotTestServerFull(t, true, false, nil)
+
+ cases := []struct {
+ path string
+ want string
+ }{
+ {"/wp-login.php", `name="log"`},
+ {"/phpmyadmin/index.php", `name="pma_username"`},
+ {"/jenkins/login", `name="j_username"`},
+ {"/cgi-bin/luci", `name="luci_username"`},
+ {"/users/sign_in", `name="user[login]"`},
+ {"/administrator/index.php", `name="passwd"`},
+ }
+ for _, c := range cases {
+ resp, err := http.Get(ts.URL + c.path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", c.path, err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s status = %d, want 200", c.path, resp.StatusCode)
+ }
+ if !strings.Contains(string(body), c.want) {
+ t.Errorf("GET %s 页面缺少特征字段 %s", c.path, c.want)
+ }
+ }
+
+ // Tomcat Manager 真实未认证行为是 401 + Basic,给 HTML 表单反而不像。
+ tomcat, err := http.Get(ts.URL + "/manager/html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tomcat.Body.Close()
+ if tomcat.StatusCode != http.StatusUnauthorized {
+ t.Errorf("GET /manager/html status = %d, want 401", tomcat.StatusCode)
+ }
+ if !strings.Contains(tomcat.Header.Get("WWW-Authenticate"), "Tomcat") {
+ t.Errorf("WWW-Authenticate = %q, want 含 Tomcat", tomcat.Header.Get("WWW-Authenticate"))
+ }
+}
+
+// TestHoneypotCapturesFamilyFields 验证各家族的字段名都能被提取成凭据。
+//
+// 页面换了字段名但提取逻辑没跟上,等于做了个好看的空壳。
+func TestHoneypotCapturesFamilyFields(t *testing.T) {
+ cases := []struct {
+ path, body, wantUser, wantPass string
+ }{
+ {"/phpmyadmin/index.php", "pma_username=root&pma_password=toor123", "root", "toor123"},
+ {"/jenkins/login", "j_username=admin&j_password=jenkins1", "admin", "jenkins1"},
+ {"/cgi-bin/luci", "luci_username=root&luci_password=openwrt", "root", "openwrt"},
+ {"/users/sign_in", "user%5Blogin%5D=gitlab&user%5Bpassword%5D=glpass", "gitlab", "glpass"},
+ }
+ for _, c := range cases {
+ cs, err := honeypot.NewFileCredStore(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := newHoneypotTestServerFull(t, true, false, cs)
+
+ resp, err := http.Post(ts.URL+c.path, "application/x-www-form-urlencoded", strings.NewReader(c.body))
+ if err != nil {
+ t.Fatalf("POST %s: %v", c.path, err)
+ }
+ _, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(recs) != 1 {
+ t.Fatalf("%s: 凭据记录数 = %d, want 1", c.path, len(recs))
+ }
+ if recs[0].Username != c.wantUser || recs[0].Password != c.wantPass {
+ t.Errorf("%s: 提取到 %q/%q, want %q/%q",
+ c.path, recs[0].Username, recs[0].Password, c.wantUser, c.wantPass)
+ }
+ }
+}
+
+// TestHoneypotAssetRequestsAreNotRecorded 验证静态资源请求只回占位内容、不落库。
+//
+// 真实后台页面会加载 5–10 个 CSS/JS/图标;如果每个都 INSERT,一次页面加载就能
+// 放大出十几条记录,而 honeypot_hits 的写入完全由外部流量驱动 ——
+// 等于把攻击者的写入成本转嫁给我们。
+func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
+ store := &captureStore{}
+ f := flamego.New()
+ hp := honeypot.New(honeypot.Options{Enabled: true})
+ ab := autoban.New(autoban.Options{
+ Enabled: true, HoneypotEnabled: true,
+ ThresholdSensitive: 10, Window: time.Minute, Duration: time.Hour,
+ })
+ hph := newHoneypotHandler(hp, ab, store, nil)
+ f.Use(autobanMiddleware(ab, hp))
+ f.NotFound(func(ctx flamego.Context) {
+ if hp.Match(ctx.Request().URL.Path) {
+ hph.Serve(ctx)
+ return
+ }
+ ctx.Redirect("/")
+ })
+ ts := httptest.NewServer(f)
+ defer ts.Close()
+
+ for _, p := range []string{
+ "/wp-admin/css/login.min.css",
+ "/wp-includes/js/wp-embed.min.js",
+ "/wp-admin/images/wordpress-logo.png",
+ } {
+ resp, err := http.Get(ts.URL + p)
+ if err != nil {
+ t.Fatalf("GET %s: %v", p, err)
+ }
+ _, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s status = %d, want 200", p, resp.StatusCode)
+ continue
+ }
+ if ct := resp.Header.Get("Content-Type"); ct == "" || strings.HasPrefix(ct, "text/html") {
+ t.Errorf("GET %s Content-Type = %q, want 非 HTML 的静态类型", p, ct)
+ }
+ }
+
+ if len(store.hits) != 0 {
+ t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
+ }
+}
diff --git a/internal/security/honeypot/asset.go b/internal/security/honeypot/asset.go
new file mode 100644
index 0000000..6dfb447
--- /dev/null
+++ b/internal/security/honeypot/asset.go
@@ -0,0 +1,95 @@
+package honeypot
+
+import (
+ "encoding/base64"
+ "net/http"
+ "strings"
+)
+
+// 静态资源伪装。
+//
+// 为什么必须做:真实后台登录页一定会加载 CSS/JS/图标。如果这些请求返回 302 或
+// 登录页 HTML,有经验的扫描器立刻能判定「页面是假的」,后面的爆破就不会发生。
+//
+// 成本控制:内容全是包级常量(首次调用时解码一次),请求时零分配、零 IO。
+// 这类请求**不落库、不计数** —— 一个页面 5–10 个资源请求,如果每个都 INSERT,
+// 等于帮攻击者把写入放大了十倍(见 route 层 Serve 的短路)。
+
+// assetPNG 是 1x1 透明 PNG(67 字节)。用 base64 常量而不是手写字节数组:
+// 手写十六进制一位写错就是一个坏图,扫描器加载失败会暴露。
+var assetPNG = mustBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
+
+var (
+ assetCSS = []byte("/*! This file is auto-generated. */\n" +
+ ".wp-core-ui,.pma-header{box-sizing:border-box}\n" +
+ "body.login #login{width:320px;margin:0 auto}\n")
+ assetJS = []byte("/*! generated */\n" +
+ "var wp=window.wp||{};wp.hooks={addFilter:function(){return 0}};\n")
+ assetSVG = []byte(``)
+ assetMap = []byte(`{"version":3,"sources":[],"mappings":""}`)
+ assetEmpty = []byte{}
+)
+
+func mustBase64(s string) []byte {
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ // 常量写错属于编译期错误,这里 panic 是正确的:静默降级会让蜜罐返回坏图。
+ panic("honeypot: invalid embedded png: " + err.Error())
+ }
+ return b
+}
+
+// assetPrefixes 只有这些前缀下的请求才会被当作「页面资源」。
+//
+// 刻意不含 /static、/assets、/js、/css:这些是站点的真实静态资源前缀
+// (route 层的 forbiddenPrefixes 会先剔除它们),纳入进来没有意义还可能误伤。
+var assetPrefixes = []string{
+ "/wp-admin/css/", "/wp-admin/js/", "/wp-admin/images/", "/wp-admin/fonts/",
+ "/wp-includes/css/", "/wp-includes/js/", "/wp-includes/images/",
+ "/wp-content/plugins/", "/wp-content/themes/", "/wp-content/uploads/",
+ "/phpmyadmin/themes/", "/phpmyadmin/js/", "/phpmyadmin/img/",
+ "/jenkins/static/", "/jenkins/css/", "/jenkins/scripts/", "/jenkins/images/",
+ "/grafana/public/build/", "/grafana/public/img/",
+ "/cgi-bin/luci/static/", "/luci-static/",
+ "/administrator/templates/", "/administrator/media/",
+}
+
+// AssetFor 判断请求是否为静态资源,并返回占位内容。
+//
+// 返回值:
+// - ok=false:不是资源请求,调用方按登录页处理;
+// - ok=true:直接写出 body(status 为 200 或 404),不记录、不计数。
+//
+// 已知后缀以外的文件(例如 /wp-content/plugins/x.php)返回 404:
+// 这类请求本身是 webshell 探测,回一个「找不到」比回登录页更符合真实站点。
+func AssetFor(p string) (body []byte, ctype string, status int, ok bool) {
+ isAsset := false
+ for _, pre := range assetPrefixes {
+ if strings.HasPrefix(p, pre) {
+ isAsset = true
+ break
+ }
+ }
+ if !isAsset {
+ return nil, "", 0, false
+ }
+
+ lower := strings.ToLower(p)
+ switch {
+ case strings.HasSuffix(lower, ".css"):
+ return assetCSS, "text/css; charset=utf-8", http.StatusOK, true
+ case strings.HasSuffix(lower, ".js"):
+ return assetJS, "application/javascript; charset=utf-8", http.StatusOK, true
+ case strings.HasSuffix(lower, ".png"), strings.HasSuffix(lower, ".gif"), strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"), strings.HasSuffix(lower, ".ico"):
+ return assetPNG, "image/png", http.StatusOK, true
+ case strings.HasSuffix(lower, ".svg"):
+ return assetSVG, "image/svg+xml", http.StatusOK, true
+ case strings.HasSuffix(lower, ".map"), strings.HasSuffix(lower, ".json"):
+ return assetMap, "application/json; charset=utf-8", http.StatusOK, true
+ case strings.HasSuffix(lower, ".woff"), strings.HasSuffix(lower, ".woff2"), strings.HasSuffix(lower, ".ttf"), strings.HasSuffix(lower, ".eot"):
+ // 字体不需要真内容:浏览器放弃渲染只会退化成默认字体,肉眼与扫描器都看不出来。
+ return assetEmpty, "font/woff2", http.StatusOK, true
+ default:
+ return assetEmpty, "text/html; charset=utf-8", http.StatusNotFound, true
+ }
+}
diff --git a/internal/security/honeypot/family.go b/internal/security/honeypot/family.go
new file mode 100644
index 0000000..ffe05c7
--- /dev/null
+++ b/internal/security/honeypot/family.go
@@ -0,0 +1,347 @@
+package honeypot
+
+import "strings"
+
+// Family 是诱饵页面模拟的产品类别。
+//
+// 为什么要分类:扫描器与爆破脚本几乎都是「先按路径判断这是什么后台,
+// 再用它认识的字段名去填表」。所有路径都回同一个 WordPress 页面,等于在
+// /phpmyadmin/index.php 上告诉对方「这里不是 PMA」—— 对方直接换目标,
+// 我们既拿不到凭据,也留不下有价值的取证。
+//
+// 刻意只做「登录页」这一层:不需要真实的业务逻辑,只需要让对方向表单提交。
+type Family int
+
+const (
+ // FamilyGeneric 是兜底:/admin、/console 这类通用后台。
+ FamilyGeneric Family = iota
+ FamilyWordPress
+ FamilyPhpMyAdmin
+ FamilyTomcat
+ FamilyJenkins
+ FamilyGrafana
+ FamilyGitLab
+ FamilyLuCI
+ FamilyJoomla
+)
+
+func (f Family) String() string {
+ switch f {
+ case FamilyWordPress:
+ return "wordpress"
+ case FamilyPhpMyAdmin:
+ return "phpmyadmin"
+ case FamilyTomcat:
+ return "tomcat"
+ case FamilyJenkins:
+ return "jenkins"
+ case FamilyGrafana:
+ return "grafana"
+ case FamilyGitLab:
+ return "gitlab"
+ case FamilyLuCI:
+ return "luci"
+ case FamilyJoomla:
+ return "joomla"
+ default:
+ return "generic"
+ }
+}
+
+// skinField 是登录表单里的一个输入框。
+type skinField struct {
+ name string
+ label string
+ secret bool
+}
+
+// loginSkin 是一个家族的「伪装设定」:外观、字段名、失败文案、响应头。
+//
+// 全部是编译期常量,渲染时只做一次字符串拼接 —— 没有模板解析、没有文件 IO。
+type loginSkin struct {
+ family Family
+ routes []string // 段前缀,命中即归入该家族(顺序敏感,先精确后泛化)
+ title string
+ brand string
+ tagline string // 品牌下方的版本行,扫描器常拿它判断产品
+ submit string
+ class string
+ form string // 表单 name/id
+ gen string // ,空则不输出
+ css string // 家族配色(叠加在 baseLoginCSS 之后)
+ fields []skinField
+ hidden [][2]string // 值写成 "$redirect" 表示回填原始查询串
+ failure string
+ footer string
+ headers [][2]string
+ cookies []string
+
+ // basicRealm 非空时改用 HTTP 401 + Basic Auth(Tomcat Manager 的真实行为)。
+ // 凭据从 Authorization 头取,ExtractCredentials 已支持。
+ basicRealm string
+}
+
+// skins 按顺序匹配:越具体的路径要排在越前面。
+//
+// 所有 routes 都必须与业务路由零交集(route 层的 forbiddenPrefixes 是第二道保险)。
+var skins = []loginSkin{
+ {
+ family: FamilyWordPress,
+ routes: []string{
+ "/wp-login.php", "/wp-login", "/wp-admin", "/wp-content", "/wp-includes",
+ "/wp-json", "/xmlrpc.php", "/xmlrpc", "/wp-cron.php", "/wp-trackback.php",
+ "/wp-comments-post.php", "/wp-config", "/wp-mail.php", "/wp-signup.php",
+ "/wp-activate.php", "/wp-links-opml.php", "/wp-blog-header.php",
+ "/wp-load.php", "/wp-settings.php",
+ },
+ title: LoginPageTitle,
+ brand: "WordPress",
+ tagline: "",
+ submit: "Log In",
+ class: "login",
+ form: "loginform",
+ gen: "WordPress 6.4.3",
+ css: `h1 a{color:#3c434a;text-decoration:none}button{background:#2271b1}`,
+ fields: []skinField{
+ {name: "log", label: "Username"},
+ {name: "pwd", label: "Password", secret: true},
+ },
+ hidden: [][2]string{
+ {"redirect_to", "$redirect"},
+ {"testcookie", "1"},
+ },
+ failure: `ERROR: The password you entered for the username is incorrect.`,
+ // 真实 wp-login.php 底部没有 404 文案,只有「← 返回站点」;
+ // 之前挂 NotFoundFooter 反而在一张登录页上写了「资源不存在」,很扎眼。
+ footer: "",
+ headers: [][2]string{
+ {"X-Powered-By", "PHP/7.4.33"},
+ {"X-Pingback", "/xmlrpc.php"},
+ },
+ cookies: []string{"wordpress_test_cookie=WP%20Cookie%20check; path=/"},
+ },
+ {
+ family: FamilyPhpMyAdmin,
+ routes: []string{"/phpmyadmin", "/pma", "/myadmin", "/dbadmin", "/sqladmin", "/mysqladmin", "/adminer.php", "/adminer"},
+ title: "phpMyAdmin",
+ brand: "phpMyAdmin",
+ tagline: "Welcome to phpMyAdmin 5.2.1",
+ submit: "Log in",
+ class: "pma",
+ form: "login_form",
+ gen: "",
+ css: `.card{width:360px}h1{color:#235a81;font-weight:700}.tag{color:#235a81;font-size:12px}button{background:#235a81}`,
+ fields: []skinField{
+ {name: "pma_username", label: "Username:"},
+ {name: "pma_password", label: "Password:", secret: true},
+ },
+ hidden: [][2]string{
+ {"server", "1"},
+ {"target", "index.php"},
+ {"token", "e1a4c0b7d2f58g93"},
+ },
+ failure: `#1045 - Cannot log in to the MySQL server`,
+ footer: "Language: English · Theme: pmahomme",
+ headers: [][2]string{
+ {"X-Powered-By", "PHP/7.4.33"},
+ {"Set-Cookie", "pma_lang=en; path=/; HttpOnly"},
+ },
+ },
+ {
+ family: FamilyTomcat,
+ routes: []string{"/manager/html", "/manager", "/host-manager", "/examples", "/jmx-console", "/web-console", "/invoker"},
+ title: "401 Unauthorized",
+ brand: "Tomcat Manager Application",
+ tagline: "Apache Tomcat/9.0.83",
+ submit: "",
+ class: "tomcat",
+ form: "login",
+ css: `h1{font-size:19px;font-weight:700;text-align:left}.tag{text-align:left}button{background:#5b6b73}button:hover{background:#4a575e}`,
+ footer: "401 Unauthorized · this request requires HTTP authentication",
+ basicRealm: "Tomcat Manager Application",
+ headers: [][2]string{
+ {"X-Powered-By", "Servlet/4.0 JSP/2.3"},
+ },
+ },
+ {
+ family: FamilyJenkins,
+ routes: []string{"/jenkins", "/jnlpJars", "/jenkins/", "/script", "/administrativeMonitor"},
+ title: "Jenkins",
+ brand: "Jenkins",
+ tagline: "Welcome to Jenkins 2.401.3",
+ submit: "Sign in",
+ class: "jenkins",
+ form: "login",
+ gen: "",
+ css: `.card{width:420px}h1{text-align:left;font-weight:700;color:#333}.tag{text-align:left}button{background:#4477aa}`,
+ fields: []skinField{
+ {name: "j_username", label: "Username"},
+ {name: "j_password", label: "Password", secret: true},
+ },
+ hidden: [][2]string{{"from", "/"}, {"Submit", "Sign in"}},
+ failure: "Invalid username or password",
+ footer: "Jenkins ver. 2.401.3",
+ headers: [][2]string{
+ {"X-Jenkins", "2.401.3"},
+ {"X-Jenkins-Session", "8b1f2c4d"},
+ },
+ },
+ {
+ family: FamilyGrafana,
+ routes: []string{"/grafana"},
+ title: "Grafana",
+ brand: "Grafana",
+ tagline: "Welcome to Grafana 10.2.3",
+ submit: "Log in",
+ class: "grafana",
+ form: "loginForm",
+ gen: "",
+ css: `body{background:#111217;color:#d8d9da}.card{background:#181b1f;border-color:#2c3235}h1{color:#f4f5f5}.tag{color:#9da3a8}input[type=text],input[type=password]{background:#0b0c0e;color:#d8d9da;border-color:#3d444b}button{background:#3d71d9}.foot{color:#8e9297}`,
+ fields: []skinField{
+ {name: "user", label: "Email or username"},
+ {name: "password", label: "Password", secret: true},
+ },
+ hidden: [][2]string{{"userlang", "en-US"}},
+ failure: "Invalid username or password",
+ footer: "Grafana Labs · v10.2.3",
+ headers: [][2]string{
+ {"X-Grafana-Org-Id", "1"},
+ },
+ },
+ {
+ family: FamilyGitLab,
+ routes: []string{"/users/sign_in", "/users/password", "/users/sign_up"},
+ title: "Sign in · GitLab",
+ brand: "GitLab",
+ tagline: "Open source DevSecOps platform",
+ submit: "Sign in",
+ class: "gitlab",
+ form: "new_user",
+ gen: "",
+ css: `.card{width:380px;border-color:#e5e5e5}h1{font-weight:700;color:#1f1e24}.tag{text-align:left}button{background:#1f75cb}`,
+ fields: []skinField{
+ {name: "user[login]", label: "Username or email"},
+ {name: "user[password]", label: "Password", secret: true},
+ },
+ hidden: [][2]string{{"user[remember_me]", "0"}},
+ failure: "Invalid Login or password.",
+ footer: "GitLab Community Edition 16.8.1",
+ headers: [][2]string{
+ {"X-Gitlab-Meta", `{"correlation_id":"a1b2c3"}`},
+ },
+ },
+ {
+ family: FamilyLuCI,
+ routes: []string{"/cgi-bin/luci", "/luci", "/luci-static"},
+ title: "LuCI - Lua Configuration Interface",
+ brand: "LuCI",
+ tagline: "OpenWrt 23.05 · Lua Configuration Interface",
+ submit: "Login",
+ class: "luci",
+ form: "login",
+ gen: "",
+ css: `.card{width:360px;border-top:3px solid #4a9c3f}h1{color:#4a9c3f;font-size:19px}button{background:#4a9c3f}`,
+ fields: []skinField{
+ {name: "luci_username", label: "Username"},
+ {name: "luci_password", label: "Password", secret: true},
+ },
+ failure: "Invalid username and/or password! Please try again.",
+ footer: "Powered by LuCI openwrt-23.05 branch",
+ headers: [][2]string{
+ {"X-Powered-By", "PHP/7.4.33"},
+ },
+ },
+ {
+ family: FamilyJoomla,
+ routes: []string{"/administrator"},
+ title: "Joomla! Administration Login",
+ brand: "Joomla!",
+ tagline: "Administration Login · Joomla! 4.4.2",
+ submit: "Log in",
+ class: "joomla",
+ form: "form-login",
+ gen: "Joomla! - Open Source Content Management",
+ css: `.card{border-top:3px solid #1a3867}.card h1{color:#1a3867}button{background:#1a3867}`,
+ fields: []skinField{
+ {name: "username", label: "Username"},
+ {name: "passwd", label: "Password", secret: true},
+ },
+ hidden: [][2]string{
+ {"option", "com_login"},
+ {"task", "login"},
+ {"return", "aW5kZXgucGhw"},
+ },
+ failure: "Username and password do not match or you do not have an account yet.",
+ footer: "Joomla! 4.4.2 · Copyright (C) Open Source Matters",
+ headers: [][2]string{
+ {"X-Powered-By", "PHP/7.4.33"},
+ },
+ },
+ {
+ family: FamilyGeneric,
+ routes: []string{"/admin", "/console", "/webadmin", "/login.php", "/admin.php", "/admin/login", "/admin/index.php", "/cpanel", "/whm", "/plesk", "/webmail", "/roundcube", "/zabbix", "/nagios", "/monitoring", "/jira", "/confluence", "/gitlab", "/nextcloud", "/owncloud"},
+ title: "Administration",
+ brand: "Administration Console",
+ tagline: "Sign in to continue",
+ submit: "Sign in",
+ class: "generic",
+ form: "loginForm",
+ gen: "",
+ css: `.card{width:360px}h1{font-weight:600;font-size:19px}button{background:#3c434a}`,
+ fields: []skinField{
+ {name: "username", label: "Username"},
+ {name: "password", label: "Password", secret: true},
+ },
+ hidden: [][2]string{{"csrf_token", "a7f3c1e9b2d4"}},
+ failure: "Invalid username or password.",
+ footer: "Restricted area. Authorized personnel only.",
+ headers: [][2]string{
+ {"X-Powered-By", "PHP/7.4.33"},
+ },
+ },
+}
+
+// FamilyOf 判断路径属于哪个家族(p 需已 normalize:小写、斜杠已折叠)。
+//
+// 段前缀语义:/wp-admin 匹配 /wp-admin 与 /wp-admin/setup-config.php,
+// 但不匹配 /wp-administrator。'.' 也算边界,兼容 /adminer.php 这类写法。
+func FamilyOf(p string) Family {
+ for i := range skins {
+ for _, r := range skins[i].routes {
+ if hasSegPrefix(p, r) {
+ return skins[i].family
+ }
+ }
+ }
+ return FamilyGeneric
+}
+
+func hasSegPrefix(p, prefix string) bool {
+ if p == prefix {
+ return true
+ }
+ if !strings.HasPrefix(p, prefix) {
+ return false
+ }
+ next := p[len(prefix)]
+ return next == '/' || next == '.'
+}
+
+// skinOf 返回家族对应的伪装设定。
+func skinOf(f Family) *loginSkin {
+ for i := range skins {
+ if skins[i].family == f {
+ return &skins[i]
+ }
+ }
+ return &skins[len(skins)-1] // FamilyGeneric 永远在最后
+}
+
+// Families 返回全部家族(供 CLI / 测试枚举)。
+func Families() []Family {
+ out := make([]Family, 0, len(skins))
+ for i := range skins {
+ out = append(out, skins[i].family)
+ }
+ return out
+}
diff --git a/internal/security/honeypot/family_test.go b/internal/security/honeypot/family_test.go
new file mode 100644
index 0000000..719e4dd
--- /dev/null
+++ b/internal/security/honeypot/family_test.go
@@ -0,0 +1,181 @@
+package honeypot
+
+import (
+ "bytes"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestFamilyOf(t *testing.T) {
+ cases := []struct {
+ path string
+ want Family
+ }{
+ {"/wp-login.php", FamilyWordPress},
+ {"/wp-admin/setup-config.php", FamilyWordPress},
+ {"/wp-json/wp/v2/users", FamilyWordPress},
+ {"/phpmyadmin/index.php", FamilyPhpMyAdmin},
+ {"/pma", FamilyPhpMyAdmin},
+ {"/adminer.php", FamilyPhpMyAdmin},
+ {"/manager/html", FamilyTomcat},
+ {"/host-manager/html", FamilyTomcat},
+ {"/jenkins/login", FamilyJenkins},
+ {"/grafana/login", FamilyGrafana},
+ {"/users/sign_in", FamilyGitLab},
+ {"/cgi-bin/luci", FamilyLuCI},
+ {"/administrator/index.php", FamilyJoomla},
+ {"/admin", FamilyGeneric},
+ {"/console/login", FamilyGeneric},
+ {"/zabbix/index.php", FamilyGeneric},
+ }
+ for _, c := range cases {
+ if got := FamilyOf(c.path); got != c.want {
+ t.Errorf("FamilyOf(%q) = %v, want %v", c.path, got, c.want)
+ }
+ }
+}
+
+// TestFamilyOfDoesNotClaimBusinessRoutes 保证分类不会把真实业务路径认成某个后台。
+// (是否接管由 Match + forbiddenPrefixes 决定,这里是第二道防线:即便将来
+// 诱饵表被改宽,也不该让 /user 被归进 GitLab 家族。)
+func TestFamilyOfDoesNotClaimBusinessRoutes(t *testing.T) {
+ for _, p := range []string{
+ "/", "/login", "/register", "/user", "/api/v1/questions",
+ "/explore", "/_/tamakyi", "/vote/abc", "/static/css/base.css",
+ } {
+ if fam := FamilyOf(p); fam != FamilyGeneric {
+ t.Errorf("FamilyOf(%q) = %v, want FamilyGeneric(业务路径不该被归类)", p, fam)
+ }
+ }
+}
+
+// TestEveryFamilyRendersOwnSkin 逐个家族渲染,断言页面上出现该家族特有的字段名。
+//
+// 为什么盯字段名:爆破脚本靠字段名决定要不要提交。所有家族都输出 log/pwd,
+// 等于对 phpMyAdmin 的爆破工具说「这里不是 PMA」,对方会直接放弃。
+func TestEveryFamilyRendersOwnSkin(t *testing.T) {
+ uniqueField := map[Family]string{
+ FamilyWordPress: `name="log"`,
+ FamilyPhpMyAdmin: `name="pma_username"`,
+ FamilyJenkins: `name="j_username"`,
+ FamilyGrafana: `name="user"`,
+ FamilyGitLab: `name="user[login]"`,
+ FamilyLuCI: `name="luci_username"`,
+ FamilyJoomla: `name="username"`,
+ FamilyGeneric: `name="csrf_token"`,
+ }
+ for _, fam := range Families() {
+ body := string(RenderFamily(fam, LoginPageVars{Action: "/probe"}))
+ if !strings.Contains(body, CanaryToken) {
+ t.Errorf("%v: 页面缺少 canary", fam)
+ }
+ if !strings.Contains(body, `action="/probe"`) {
+ t.Errorf("%v: 表单 action 未回填请求路径", fam)
+ }
+ if want, ok := uniqueField[fam]; ok && !strings.Contains(body, want) {
+ t.Errorf("%v: 页面缺少特征字段 %s", fam, want)
+ }
+ // 每个家族都必须有密码框,否则爆破脚本不会提交。
+ if !strings.Contains(body, `type="password"`) && fam != FamilyTomcat {
+ t.Errorf("%v: 页面没有密码输入框", fam)
+ }
+ // generator 必须伪装成真实产品,绝不能出现蜜罐标记(扫描器必查项)。
+ if strings.Contains(body, "TamaBox") || strings.Contains(body, "honeypot") {
+ t.Errorf("%v: 页面自曝了蜜罐身份", fam)
+ }
+ }
+}
+
+// TestTomcatFamilyUsesBasicAuth 验证 Tomcat 家族走的是 401 + WWW-Authenticate。
+//
+// 真实 Tomcat Manager 未认证时就是 401 Basic,给一个 HTML 表单反而不像;
+// 而且 Basic 头里的凭据 ExtractCredentials 能直接解析,不损失捕获能力。
+func TestTomcatFamilyUsesBasicAuth(t *testing.T) {
+ rec := httptest.NewRecorder()
+ WriteFamilyPage(rec, FamilyTomcat, LoginPageVars{Action: "/manager/html"})
+ if rec.Code != 401 {
+ t.Errorf("status = %d, want 401", rec.Code)
+ }
+ if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Tomcat") {
+ t.Errorf("WWW-Authenticate = %q, want 含 Tomcat realm", got)
+ }
+}
+
+// TestFamilyHeadersLookReal 检查各家族的指纹响应头。
+// 这些头是扫描器的第一判断依据,比页面内容更早被读到。
+func TestFamilyHeadersLookReal(t *testing.T) {
+ cases := []struct {
+ fam Family
+ header string
+ wantSub string
+ }{
+ {FamilyWordPress, "X-Powered-By", "PHP"},
+ {FamilyPhpMyAdmin, "X-Powered-By", "PHP"},
+ {FamilyJenkins, "X-Jenkins", "2.401"},
+ {FamilyGrafana, "X-Grafana-Org-Id", "1"},
+ }
+ for _, c := range cases {
+ rec := httptest.NewRecorder()
+ WriteFamilyPage(rec, c.fam, LoginPageVars{Action: "/x"})
+ if got := rec.Header().Get(c.header); !strings.Contains(got, c.wantSub) {
+ t.Errorf("%v: %s = %q, want 含 %q", c.fam, c.header, got, c.wantSub)
+ }
+ }
+ // WordPress 真实登录页会下发 test cookie,爆破工具常拿它判断会话是否有效。
+ rec := httptest.NewRecorder()
+ WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
+ if !strings.Contains(strings.Join(rec.Header().Values("Set-Cookie"), ";"), "wordpress_test_cookie") {
+ t.Error("WordPress 家族缺少 wordpress_test_cookie")
+ }
+}
+
+func TestAssetFor(t *testing.T) {
+ if body, ctype, status, ok := AssetFor("/wp-admin/css/login.min.css"); !ok || status != 200 ||
+ !strings.Contains(ctype, "text/css") || len(body) == 0 {
+ t.Errorf("css 资源未命中:ok=%v status=%d ctype=%q", ok, status, ctype)
+ }
+ if _, ctype, _, ok := AssetFor("/wp-includes/js/jquery/jquery.min.js"); !ok || !strings.Contains(ctype, "javascript") {
+ t.Errorf("js 资源 ctype = %q", ctype)
+ }
+ if body, _, _, ok := AssetFor("/wp-admin/images/logo.png"); !ok || !bytes.HasPrefix(body, []byte{0x89, 'P', 'N', 'G'}) {
+ t.Error("png 资源内容不是合法 PNG")
+ }
+ // 插件目录下的 php 是 webshell 探测,回 404 比回登录页更真实
+ if _, _, status, ok := AssetFor("/wp-content/plugins/x.php"); !ok || status != 404 {
+ t.Errorf("未知后缀 status = %d, want 404", status)
+ }
+ // 不在资源前缀内的一律交回登录页逻辑
+ for _, p := range []string{"/admin", "/wp-login.php", "/static/css/base.css", "/assets/app.js"} {
+ if _, _, _, ok := AssetFor(p); ok {
+ t.Errorf("%q 不该被当成页面资源", p)
+ }
+ }
+}
+
+// TestRenderIsCheap 粗略守住「单页体积」,防止有人往 skin 里塞大段样式/脚本。
+func TestRenderIsCheap(t *testing.T) {
+ for _, fam := range Families() {
+ if n := len(RenderFamily(fam, LoginPageVars{Action: "/x"})); n > 4096 {
+ t.Errorf("%v: 页面体积 %d 字节,超过 4KB 预算", fam, n)
+ }
+ }
+}
+
+// BenchmarkRenderFamily 量化「渲染一个伪装页」的成本。
+// 蜜罐会被扫描器高频命中,这里刻意守住:单页只做一次 Builder 拼接。
+func BenchmarkRenderFamily(b *testing.B) {
+ vars := LoginPageVars{Action: "/wp-login.php", Redir: "/wp-admin/"}
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = RenderFamily(FamilyWordPress, vars)
+ }
+}
+
+// BenchmarkAssetFor 资源请求必须零分配:一次页面加载会打 5–10 个资源请求。
+func BenchmarkAssetFor(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
+ }
+}
diff --git a/internal/security/honeypot/honeypot.go b/internal/security/honeypot/honeypot.go
index 3fe0304..e83bd4c 100644
--- a/internal/security/honeypot/honeypot.go
+++ b/internal/security/honeypot/honeypot.go
@@ -58,6 +58,13 @@ var honeypotPrefixes = []string{
"/.config", "/.npm", "/.kube", "/.vscode", "/.idea",
"/actuator", "/swagger", "/swagger-ui", "/openapi", "/v2/api-docs", "/v3/api-docs",
"/api-docs", "/druid", "/nacos", "/solr", "/elastic", "/apollo",
+ // 其它常见后台/运维面板:每条都对应 family.go 里的一个伪装家族,
+ // 这些前缀同样与业务路由零交集(业务只有 /user、/login、/_/ 等)。
+ "/jenkins", "/jnlpJars", "/grafana", "/users/sign_in", "/users/password",
+ "/users/sign_up", "/luci", "/monitoring", "/zabbix", "/nagios",
+ "/jira", "/confluence", "/gitlab", "/nextcloud", "/owncloud",
+ "/webmail", "/roundcube", "/whm", "/host-manager", "/jmx-console",
+ "/web-console", "/invoker",
"/server-status", "/server-info", "/info.php", "/phpinfo", "/test.php",
"/backup", "/backups", "/dump", "/database", "/db", "/sql", "/mysql",
"/config", "/configuration", "/vendor", "/storage", "/tmp", "/temp",
@@ -274,6 +281,11 @@ var credentialKeys = map[string]bool{
"password": true, "passwd": true, "pw": true, "email": true,
"account": true, "login": true, "wp_user": true, "user_login": true,
"wp_password": true, "uname": true, "nickname": true, "token": true,
+ // 其它家族的表单字段名(见 family.go):
+ "pma_username": true, "pma_password": true, // phpMyAdmin
+ "j_username": true, "j_password": true, // Jenkins / Tomcat
+ "luci_username": true, "luci_password": true, // OpenWrt LuCI
+ "user[login]": true, "user[password]": true, // GitLab
}
// ExtractCredentials 从请求中提取破解凭据。
@@ -302,12 +314,18 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
if _, exists := out.Fields[k]; !exists {
out.Fields[k] = v
}
+ // 注意:token 只算「凭据字段」(用于判定 HadFields),不算密码。
+ // phpMyAdmin / GitLab 的登录表单会带一个 hidden CSRF token,
+ // 把它当密码会让真正的 pma_password 被随机覆盖
+ // (map 遍历无固定顺序,"密码"会时对时错)。
switch k {
- case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname":
+ case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname",
+ "pma_username", "j_username", "luci_username", "user[login]":
if out.Username == "" {
out.Username = v
}
- case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
+ case "pwd", "password", "passwd", "pw", "pass", "wp_password",
+ "pma_password", "j_password", "luci_password", "user[password]":
if out.Password == "" {
out.Password = v
}
@@ -413,7 +431,8 @@ func (c *Credentials) finish() {
func isPasswordKey(k string) bool {
switch k {
- case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
+ case "pwd", "password", "passwd", "pw", "pass", "wp_password",
+ "pma_password", "j_password", "luci_password", "user[password]":
return true
}
return false
diff --git a/internal/security/honeypot/page.go b/internal/security/honeypot/page.go
index c2345c4..8f2aede 100644
--- a/internal/security/honeypot/page.go
+++ b/internal/security/honeypot/page.go
@@ -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 注释里,不放进 。
-// 上一版把 "TamaBox honeypot " 写进了 generator,等于同时告诉扫描器
-// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
-// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
-func RenderLoginPage(vars LoginPageVars) []byte {
+// 写的时候刻意不用 `"a" + b + "c"`:每个 + 都是一次临时分配。
+// 连续 WriteString 走 append,配合一次 Grow,整页只有 2–3 次分配
+// (见 family_test.go 的 BenchmarkRenderFamily)。
+//
+// canary 只放 HTML 注释里,不放进 :
+// 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 = `` + html.EscapeString(vars.Notice) + `
`
+ notice := vars.Notice
+ if notice == "" && vars.Failed {
+ notice = s.failure
}
- redir := html.EscapeString(vars.Redir)
var b strings.Builder
- b.Grow(4096)
- b.WriteString(`` + "\n")
- // canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- // generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
- b.WriteString(`` + "\n")
- b.WriteString(`` + html.EscapeString(title) + `` + "\n")
- // 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`
` + "\n")
- b.WriteString(notice + "\n")
- b.WriteString(`
` + "\n")
- b.WriteString(`
Lost your password? | Register
` + "\n")
- b.WriteString(`
` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
- b.WriteString(`` + "\n")
+ b.Grow(3072 + len(s.css))
+
+ b.WriteString("\n\n\n")
+ b.WriteString("\n")
+ b.WriteString("\n")
+ b.WriteString("\n")
+ if s.gen != "" {
+ b.WriteString("\n")
+ }
+ b.WriteString("")
+ b.WriteString(html.EscapeString(title))
+ b.WriteString("\n\n\n\n\n\n
")
+ b.WriteString(html.EscapeString(brand))
+ b.WriteString("
\n")
+ if s.tagline != "" {
+ b.WriteString("
")
+ b.WriteString(s.tagline)
+ b.WriteString("
\n")
+ }
+ if notice != "" {
+ b.WriteString("
")
+ b.WriteString(html.EscapeString(notice))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\n")
+ if s.footer != "" {
+ b.WriteString("\n")
+ }
+ b.WriteString("
\n\n\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)
}