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
+347
View File
@@ -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 // <meta name="generator">,空则不输出
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 &middot; 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 &middot; 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 &middot; 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 &middot; 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 &middot; 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 &middot; 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 &middot; 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
}