feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库

原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
This commit is contained in:
tamakyi
2026-10-23 12:38:35 +08:00
parent edde075406
commit 811578dcf1
9 changed files with 1067 additions and 108 deletions
+39 -25
View File
@@ -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"),
})
}
+137
View File
@@ -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))
}
}