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
+74 -4
View File
@@ -1,7 +1,10 @@
package honeypot
import (
"encoding/binary"
"encoding/hex"
"html"
"math/rand/v2"
"net/http"
"strings"
)
@@ -19,6 +22,52 @@ type LoginPageVars struct {
Failed bool
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
Redir string
// BaseURL 是站点对外地址(conf.App.ExternalURL),供 X-Pingback 这类
// 需要绝对 URL 的响应头使用。真实 WordPress 发的是绝对地址,
// 相对路径本身就是个破绽,所以留空时干脆不发这个头。
BaseURL string
// Nonce 是本次请求的动态占位符源(32 位 hex),由 WriteFamilyPage 生成。
// 留空时渲染阶段自行生成 —— 直接调 RenderFamily 的调用方(预览工具、
// 测试)不需要关心它。
Nonce string
}
// dynamicTokenLen 是动态占位符源的字符数(32 个 hex 字符)。
const dynamicTokenLen = 32
// newToken 生成一次请求用的伪随机 hex 串。
//
// 刻意不用 crypto/rand:这些值是假的 CSRF token / 会话号,不需要不可预测,
// 只需要「每次请求都不一样」。而恒定值恰恰是最容易被指纹识别的特征 ——
// 连打两次一模一样的 token,任何脚本都能判定对面是伪造的。
// math/rand/v2 单次约几纳秒,crypto/rand 在 Windows 上要走系统调用,贵一个量级。
func newToken() string {
var b [16]byte
binary.LittleEndian.PutUint64(b[0:8], rand.Uint64())
binary.LittleEndian.PutUint64(b[8:16], rand.Uint64())
return hex.EncodeToString(b[:])
}
// expandDyn 替换伪装页与响应头里的动态占位符:
//
// $hex8 / $hex16 / $hex32 —— 从本次请求的随机串里截取
// $base —— 站点对外地址(不含尾部斜杠)
//
// 只处理这四个,命中不了的原样返回,所以对普通常量没有额外开销。
func expandDyn(s, token, baseURL string) string {
if !strings.Contains(s, "$") {
return s
}
if strings.Contains(s, "$base") {
s = strings.ReplaceAll(s, "$base", baseURL)
}
if !strings.Contains(s, "$hex") {
return s
}
// 先长后短:$hex32 替换出来的都是 hex 字符,不会再被后两条命中。
s = strings.ReplaceAll(s, "$hex32", token)
s = strings.ReplaceAll(s, "$hex16", token[:16])
return strings.ReplaceAll(s, "$hex8", token[:8])
}
// LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
@@ -72,6 +121,13 @@ func RenderFamily(f Family, vars LoginPageVars) []byte {
if notice == "" && vars.Failed {
notice = s.failure
}
// 本次请求的动态值源。WriteFamilyPage 会先填好 vars.Nonce,
// 这样响应头与页面里的 token 是同一套;直接调本函数的调用方则各自生成。
token := vars.Nonce
if len(token) != dynamicTokenLen {
token = newToken()
}
base := strings.TrimRight(vars.BaseURL, "/")
var b strings.Builder
b.Grow(3072 + len(s.css))
@@ -143,7 +199,7 @@ func RenderFamily(f Family, vars LoginPageVars) []byte {
b.WriteString("<input type=\"hidden\" name=\"")
b.WriteString(h[0])
b.WriteString("\" value=\"")
b.WriteString(html.EscapeString(v))
b.WriteString(html.EscapeString(expandDyn(v, token, base)))
b.WriteString("\">\n")
}
if s.submit != "" {
@@ -172,15 +228,29 @@ func RenderLoginPage(vars LoginPageVars) []byte {
// 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。
func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) {
s := skinOf(f)
// 响应头与页面共用同一套动态值:真实产品的 CSRF token 在表单里、
// 会话号在 Set-Cookie 里,两者不会是各自独立随机的。
if len(vars.Nonce) != dynamicTokenLen {
vars.Nonce = newToken()
}
base := strings.TrimRight(vars.BaseURL, "/")
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
// 真实 wp-login.php / phpMyAdmin 都是这条,no-store 反而不像登录页
// (那是安全设备与代理爱加的头)。
h.Set("Cache-Control", "no-cache, must-revalidate, max-age=0")
h.Set("X-Robots-Tag", "noindex, nofollow")
for _, kv := range s.headers {
h.Set(kv[0], kv[1])
// 依赖对外地址的头(X-Pingback):没配置就不发。
// 发一个相对路径比不发更假 —— 真实 WordPress 给的是绝对 URL。
if strings.Contains(kv[1], "$base") && base == "" {
continue
}
h.Set(kv[0], expandDyn(kv[1], vars.Nonce, base))
}
for _, c := range s.cookies {
h.Add("Set-Cookie", c)
h.Add("Set-Cookie", expandDyn(c, vars.Nonce, base))
}
status := http.StatusOK
if s.basicRealm != "" {