Files
tamakyi 307bd94ed1 feat(honeypot): /xmlrpc.php 走真实 XML-RPC,动态 token 不再恒定
线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装:

一、/xmlrpc.php 回的是 HTML 登录页(P1)
我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来,
一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。
更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、
且绕开登录页限速插件,是 WP 爆破里产出最高的入口。

新增 xmlrpc.go:
- 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致)
- POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
- POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
- 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除
  methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让
  配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进
  fields(xmlrpc.0/1/…),配对失败也不丢原文。
  multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串
  密码是常见字典形态,只留第一组等于丢掉整份字典。
- 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。

二、进入页面的隐藏值每次请求都一样(P2)
两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能
判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token,
不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级),
页头、hidden 与 Set-Cookie 共用同一套。

顺带:
- X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身
  就是破绽;没配 external_url 时干脆不发这个头。
- Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0
  (no-store 更像安全设备/代理加的,不像登录页)。
- Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。
- Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php,
  原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。

测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id /
非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且
是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、
所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 +
回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。
四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、
.php 兜底失效。

性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),
AssetFor 仍 0 分配。
2026-10-23 12:58:48 +08:00

350 lines
11 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 "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", "$base/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", "$hex32"},
},
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", "$hex8"},
},
},
{
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"},
},
cookies: []string{"grafana_sess=$hex32; Path=/; HttpOnly"},
},
{
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":"$hex32"}`},
},
cookies: []string{"_gitlab_session=$hex32; path=/; HttpOnly"},
},
{
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", "$hex16"}},
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
}