From 811578dcf165e4bf795ef0858e288f43559968df Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 12:38:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(honeypot):=20=E6=8C=89=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=BC=AA=E8=A3=85=E6=88=90=209=20=E7=B1=BB?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=EF=BC=8C=E9=9D=99=E6=80=81=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8D=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来所有诱饵路径都回同一个 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 → 页面缺特征字段。两项均按预期变红。 --- dev/preview-honeypot/main.go | 77 +++++ internal/cmd/honeypot.go | 14 + internal/route/honeypot.go | 64 ++-- internal/route/honeypot_test.go | 137 +++++++++ internal/security/honeypot/asset.go | 95 ++++++ internal/security/honeypot/family.go | 347 ++++++++++++++++++++++ internal/security/honeypot/family_test.go | 181 +++++++++++ internal/security/honeypot/honeypot.go | 25 +- internal/security/honeypot/page.go | 235 ++++++++++----- 9 files changed, 1067 insertions(+), 108 deletions(-) create mode 100644 dev/preview-honeypot/main.go create mode 100644 internal/security/honeypot/asset.go create mode 100644 internal/security/honeypot/family.go create mode 100644 internal/security/honeypot/family_test.go 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(`

` + html.EscapeString(heading) + `

` + "\n") - b.WriteString(notice + "\n") - b.WriteString(`
` + "\n") - b.WriteString(`

` + "\n") - b.WriteString(`

` + "\n") - b.WriteString(`

Remember Me

` + "\n") - b.WriteString(`

` + "\n") - b.WriteString(`` + "\n") - b.WriteString(`` + "\n") - b.WriteString(`
` + "\n") - b.WriteString(`` + "\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") + + 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("\n") + } + for _, h := range s.hidden { + v := h[1] + if v == "$redirect" { + v = vars.Redir + } + b.WriteString("\n") + } + if s.submit != "" { + b.WriteString("\n") + } + b.WriteString("
\n") + if s.footer != "" { + b.WriteString("

") + b.WriteString(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) }