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 分配。
This commit is contained in:
tamakyi
2026-10-23 12:58:48 +08:00
parent 7225f680d4
commit 307bd94ed1
9 changed files with 740 additions and 12 deletions
+115
View File
@@ -179,3 +179,118 @@ func BenchmarkAssetFor(b *testing.B) {
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
}
}
// hiddenValue 取某个 hidden input 的 value。
func hiddenValue(body, name string) string {
marker := `name="` + name + `" value="`
i := strings.Index(body, marker)
if i < 0 {
return ""
}
rest := body[i+len(marker):]
if j := strings.IndexByte(rest, '"'); j >= 0 {
return rest[:j]
}
return ""
}
// TestDynamicTokensDifferPerRequest 守住「每次请求的 token 必须不同」。
//
// 恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 CSRF token,
// 任何脚本都能判定对面是伪造的 —— 而这正是「伪装得像」的核心。
func TestDynamicTokensDifferPerRequest(t *testing.T) {
cases := []struct {
fam Family
name string
size int
}{
{FamilyPhpMyAdmin, "token", 32},
{FamilyGeneric, "csrf_token", 16},
}
for _, c := range cases {
first := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
second := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
v1, v2 := hiddenValue(first, c.name), hiddenValue(second, c.name)
if v1 == "" || v2 == "" {
t.Fatalf("%s: 取不到 %s 的值", c.fam, c.name)
}
if v1 == v2 {
t.Errorf("%s 的 %s 两次相同(%q)—— 恒定值可被指纹识别", c.fam, c.name, v1)
}
for _, v := range []string{v1, v2} {
if len(v) != c.size {
t.Errorf("%s 的 %s 长度 = %d, want %d(%q)", c.fam, c.name, len(v), c.size, v)
}
if strings.Trim(v, "0123456789abcdef") != "" {
t.Errorf("%s 的 %s = %q, 含非 hex 字符(真实产品只给 hex)", c.fam, c.name, v)
}
}
}
}
// TestHeadersDynamicValues 守住响应头里的动态值:Jenkins 会话号、GitLab 关联 id。
func TestHeadersDynamicValues(t *testing.T) {
got := func(f Family, header string) string {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, f, LoginPageVars{Action: "/x"})
return rec.Header().Get(header)
}
s1, s2 := got(FamilyJenkins, "X-Jenkins-Session"), got(FamilyJenkins, "X-Jenkins-Session")
if len(s1) != 8 || s1 == s2 {
t.Errorf("X-Jenkins-Session = %q / %q, want 两个不同的 8 位 hex", s1, s2)
}
g1, g2 := got(FamilyGitLab, "X-Gitlab-Meta"), got(FamilyGitLab, "X-Gitlab-Meta")
if g1 == g2 || !strings.Contains(g1, `"correlation_id":"`) {
t.Errorf("X-Gitlab-Meta = %q / %q, want 每次不同的 correlation_id", g1, g2)
}
// 会话 cookie 同样不能恒定。
if c1, c2 := got(FamilyGitLab, "Set-Cookie"), got(FamilyGitLab, "Set-Cookie"); c1 == c2 {
t.Errorf("Set-Cookie = %q 两次相同", c1)
}
}
// TestPingbackAbsoluteURL 守住 X-Pingback 必须是绝对地址:
// 真实 WordPress 给的是 "https://host/xmlrpc.php",相对路径本身就是破绽。
// 没配置 external_url 时宁可不发这个头。
func TestPingbackAbsoluteURL(t *testing.T) {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{
Action: "/wp-login.php",
BaseURL: "https://box.test/",
})
if got := rec.Header().Get("X-Pingback"); got != "https://box.test/xmlrpc.php" {
t.Errorf("X-Pingback = %q, want 绝对地址(尾部斜杠要折叠)", got)
}
rec = httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
if got := rec.Header().Get("X-Pingback"); got != "" {
t.Errorf("X-Pingback = %q, want 不发(相对路径比不发更假)", got)
}
}
// TestNoUnresolvedPlaceholders 守住渲染后不残留 $ 占位符 ——
// 漏展开会在页面上留下 "$hex32" 这种一眼假的值。
func TestNoUnresolvedPlaceholders(t *testing.T) {
for _, fam := range Families() {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, fam, LoginPageVars{
Action: "/x",
Redir: "/wp-admin/",
BaseURL: "https://box.test",
})
body := rec.Body.String()
if strings.Contains(body, "$hex") || strings.Contains(body, "$base") {
t.Errorf("%s 页面残留占位符:%s", fam, body)
}
for k, vs := range rec.Header() {
for _, v := range vs {
if strings.Contains(v, "$hex") || strings.Contains(v, "$base") {
t.Errorf("%s 响应头 %s 残留占位符:%q", fam, k, v)
}
}
}
}
}