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:
@@ -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(`<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
|
||||
}
|
||||
}
|
||||
@@ -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 · 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 注释里,不放进 <meta name="generator">。
|
||||
// 上一版把 "TamaBox honeypot <canary>" 写进了 generator,等于同时告诉扫描器
|
||||
// 「这是蜜罐」和「这个站是 TamaBox」—— 有能力的扫描器(nuclei 一类模板)
|
||||
// 会直接跳过并针对性地换打法。generator 是扫描器必查项,必须伪装成真的。
|
||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
// 写的时候刻意不用 `"a" + b + "c"`:每个 + 都是一次临时分配。
|
||||
// 连续 WriteString 走 append,配合一次 Grow,整页只有 2–3 次分配
|
||||
// (见 family_test.go 的 BenchmarkRenderFamily)。
|
||||
//
|
||||
// canary 只放 HTML 注释里,不放进 <meta name="generator">:
|
||||
// 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 = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
|
||||
notice := vars.Notice
|
||||
if notice == "" && vars.Failed {
|
||||
notice = s.failure
|
||||
}
|
||||
redir := html.EscapeString(vars.Redir)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(4096)
|
||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||||
// canary 伪装成构建标记:溯源时能认出来,扫描器不会把它当蜜罐指纹。
|
||||
b.WriteString(`<!-- build: ` + CanaryToken + ` -->` + "\n")
|
||||
b.WriteString(`<html lang="en-US">` + "\n")
|
||||
b.WriteString(`<head>` + "\n")
|
||||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||||
// generator 必须伪装:真实 WP 站点一定带这一行,扫描器也一定会查。
|
||||
b.WriteString(`<meta name="generator" content="WordPress 6.4.3" />` + "\n")
|
||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||
b.WriteString(`</head>` + "\n")
|
||||
b.WriteString(`<body class="login">` + "\n")
|
||||
b.WriteString(`<div id="login">` + "\n")
|
||||
b.WriteString(`<h1><a href="` + action + `" title="` + html.EscapeString(heading) + `">` + html.EscapeString(heading) + `</a></h1>` + "\n")
|
||||
b.WriteString(notice + "\n")
|
||||
b.WriteString(`<form name="loginform" id="loginform" action="` + action + `" method="post" enctype="application/x-www-form-urlencoded">` + "\n")
|
||||
b.WriteString(`<p><label for="user_login">Username<br /><input type="text" name="log" id="user_login" class="input" value="" size="20" autocapitalize="off" autocomplete="username" /></label></p>` + "\n")
|
||||
b.WriteString(`<p><label for="user_pass">Password<br /><input type="password" name="pwd" id="user_pass" class="input" value="" size="20" autocomplete="current-password" /></label></p>` + "\n")
|
||||
b.WriteString(`<p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" /> Remember Me</p>` + "\n")
|
||||
b.WriteString(`<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" /></p>` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="redirect_to" value="` + redir + `" />` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="testcookie" value="1" />` + "\n")
|
||||
b.WriteString(`</form>` + "\n")
|
||||
b.WriteString(`<p id="nav"><a href="` + action + `?action=lostpassword">Lost your password?</a> | <a href="` + action + `?action=register">Register</a></p>` + "\n")
|
||||
b.WriteString(`</div>` + "\n")
|
||||
b.WriteString(`<div id="footer"><p>` + html.EscapeString(NotFoundFooter) + `</p></div>` + "\n")
|
||||
b.WriteString(`</body>` + "\n")
|
||||
b.WriteString(`</html>` + "\n")
|
||||
b.Grow(3072 + len(s.css))
|
||||
|
||||
b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
||||
b.WriteString("<meta charset=\"utf-8\">\n")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n")
|
||||
b.WriteString("<meta name=\"robots\" content=\"noindex,nofollow\">\n")
|
||||
if s.gen != "" {
|
||||
b.WriteString("<meta name=\"generator\" content=\"")
|
||||
b.WriteString(html.EscapeString(s.gen))
|
||||
b.WriteString("\">\n")
|
||||
}
|
||||
b.WriteString("<title>")
|
||||
b.WriteString(html.EscapeString(title))
|
||||
b.WriteString("</title>\n<style>")
|
||||
b.WriteString(baseLoginCSS)
|
||||
b.WriteString(s.css)
|
||||
b.WriteString("</style>\n</head>\n<body class=\"")
|
||||
b.WriteString(s.class)
|
||||
b.WriteString("\">\n<!-- build: ")
|
||||
b.WriteString(CanaryToken)
|
||||
b.WriteString(" -->\n<div class=\"card\">\n<h1>")
|
||||
b.WriteString(html.EscapeString(brand))
|
||||
b.WriteString("</h1>\n")
|
||||
if s.tagline != "" {
|
||||
b.WriteString("<p class=\"tag\">")
|
||||
b.WriteString(s.tagline)
|
||||
b.WriteString("</p>\n")
|
||||
}
|
||||
if notice != "" {
|
||||
b.WriteString("<div class=\"err\">")
|
||||
b.WriteString(html.EscapeString(notice))
|
||||
b.WriteString("</div>\n")
|
||||
}
|
||||
b.WriteString("<form method=\"post\" action=\"")
|
||||
b.WriteString(action)
|
||||
b.WriteString("\" name=\"")
|
||||
b.WriteString(s.form)
|
||||
b.WriteString("\" id=\"")
|
||||
b.WriteString(s.form)
|
||||
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("<label for=\"")
|
||||
b.WriteString(id)
|
||||
b.WriteString("\">")
|
||||
b.WriteString(fld.label)
|
||||
b.WriteString("<input type=\"")
|
||||
b.WriteString(typ)
|
||||
b.WriteString("\" name=\"")
|
||||
b.WriteString(fld.name)
|
||||
b.WriteString("\" id=\"")
|
||||
b.WriteString(id)
|
||||
b.WriteString("\" value=\"\" autocomplete=\"")
|
||||
b.WriteString(ac)
|
||||
b.WriteString("\"></label>\n")
|
||||
}
|
||||
for _, h := range s.hidden {
|
||||
v := h[1]
|
||||
if v == "$redirect" {
|
||||
v = vars.Redir
|
||||
}
|
||||
b.WriteString("<input type=\"hidden\" name=\"")
|
||||
b.WriteString(h[0])
|
||||
b.WriteString("\" value=\"")
|
||||
b.WriteString(html.EscapeString(v))
|
||||
b.WriteString("\">\n")
|
||||
}
|
||||
if s.submit != "" {
|
||||
b.WriteString("<button type=\"submit\">")
|
||||
b.WriteString(s.submit)
|
||||
b.WriteString("</button>\n")
|
||||
}
|
||||
b.WriteString("</form>\n")
|
||||
if s.footer != "" {
|
||||
b.WriteString("<p class=\"foot\">")
|
||||
b.WriteString(s.footer)
|
||||
b.WriteString("</p>\n")
|
||||
}
|
||||
b.WriteString("</div>\n</body>\n</html>\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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user