Files
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

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

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

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

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

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

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

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00

96 lines
4.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package honeypot
import (
"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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" fill="#c3c4c7"/></svg>`)
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
}
}