原来所有诱饵路径都回同一个 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 → 页面缺特征字段。两项均按预期变红。
96 lines
4.3 KiB
Go
96 lines
4.3 KiB
Go
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
|
||
}
|
||
}
|