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
+222
View File
@@ -0,0 +1,222 @@
package honeypot
import (
"io"
"net/http"
"regexp"
"strconv"
"strings"
)
// WordPress 的 /xmlrpc.php 是爆破里最值钱的一个入口:system.multicall 允许
// 一次请求塞进上千组用户名/密码,且绕开登录页的限速插件 —— xxer、WPScan 的
// xmlrpc 模式都靠它。
//
// 之前这个路径回的是假登录 HTML,有两个后果:
// 1. 我们自己的 X-Pingback 响应头就指着 /xmlrpc.php,扫描器跟过来一看是 HTML,
// 立刻知道整个站点是假的(真实的 WordPress 在这里回 text/xml);
// 2. 最值钱的那条凭据来源直接丢了 —— 对方提交的字典全落在 HTML 页面上。
const (
// XMLRPCFaultDisabled 与真实 WordPress 对非 POST 请求返回的 fault 文案一致。
XMLRPCFaultDisabled = "XML-RPC services are disabled on this site."
// XMLRPCFaultBadLogin 与真实 WordPress 用户名/密码错误时的文案一致
// (不区分「用户不存在」与「密码错误」,避免给爆破脚本用户名枚举信号)。
XMLRPCFaultBadLogin = "Incorrect username or password."
// maxXMLRPCBody 限制参与解析的 body 大小。system.multicall 一包上千组凭据
// 也就 100 KB 上下,256 KB 足够;再大的一律截断(仍会留下前 256 KB 取证)。
maxXMLRPCBody = 256 << 10
// maxXMLRPCRawBody 是写进取证文件的原始 body 上限(按 rune)。
// 比普通表单字段宽得多:multicall 的字典本身就是要留的证据。
maxXMLRPCRawBody = 8192
// maxXMLRPCPairs 是最多收录多少组凭据(防止一包把取证文件撑爆)。
maxXMLRPCPairs = 16
)
// IsXMLRPCPath 判断路径是否为 XML-RPC 端点(入参应已 cleanPath)。
//
// 大小写不敏感:/XMLRPC.PHP 在真实 WordPress 上同样命中(路由不区分大小写)。
func IsXMLRPCPath(p string) bool {
p = strings.TrimSuffix(p, "/")
return strings.EqualFold(p, "/xmlrpc.php") || strings.EqualFold(p, "/xmlrpc")
}
var (
// xmlMethodNameRe 抓 <member><name>methodName</name><value><string>X</string>...
// metacall 的每个 pacel 里都有一份,普通调用则在 <methodName> 标签里。
xmlMethodNameRe = regexp.MustCompile(`(?s)<name>\s*methodName\s*</name>\s*<value>\s*<string>([^<]*)</string>`)
xmlMethodTagRe = regexp.MustCompile(`(?s)<methodName>\s*([^<]*)</methodName>`)
xmlStringRe = regexp.MustCompile(`(?s)<string>(.*?)</string>`)
)
// ExtractXMLRPCCredentials 从 XML-RPC 请求体里提取凭据。
//
// 解析策略是「够用就好」,刻意不上 encoding/xml:蜜罐会被高频命中,
// 而这里的 payload 结构固定(methodCall + string 参数)。做法是抓出所有
// <string>,剔除方法名与 URL,再按出现顺序两两配对。
//
// 局限:wp.getUsersBlogs 的第一个参数是 blog_id,通常是 URL 或空串(已被剔除);
// 若对方偏偏传了数字(blog_id=1),配对会整体错位一位。为此额外做了两件事 ——
// 位数为奇数且首项是短数字时丢掉首项,以及把所有原始串写进 Fields 的
// xmlrpc.N 里。即便配对失败,取证文件里也留得下完整原文。
func ExtractXMLRPCCredentials(r *http.Request) Credentials {
out := Credentials{Fields: map[string]string{}}
if r == nil || r.Body == nil {
return out
}
defer func() { _ = r.Body.Close() }()
buf, _ := io.ReadAll(io.LimitReader(r.Body, maxXMLRPCBody))
if len(buf) == 0 {
return out
}
body := string(buf)
out.RawBody = clipRunes(body, maxXMLRPCRawBody)
// 不是 XML-RPC 调用(比如扫描器拿它当普通路径探测):只留原始 body,
// HadFields 保持 false,不会被误判成「提交了凭据」。
if !strings.Contains(body, "<methodCall") {
return out
}
// 方法名:<methodName> 标签(普通调用)与 methodName 成员(multicall 的 pacel)。
skip := make(map[string]bool, 8)
for _, m := range xmlMethodTagRe.FindAllStringSubmatch(body, -1) {
name := strings.TrimSpace(m[1])
skip[name] = true
if out.Fields["xmlrpc.method"] == "" {
out.Fields["xmlrpc.method"] = clipRunes(name, 64)
}
}
for _, m := range xmlMethodNameRe.FindAllStringSubmatch(body, -1) {
skip[strings.TrimSpace(m[1])] = true
}
// 参数值:剔除空的、方法名、站点 URL 之后按顺序收集。
vals := make([]string, 0, 8)
for _, m := range xmlStringRe.FindAllStringSubmatch(body, -1) {
v := strings.TrimSpace(unescapeXMLText(m[1]))
if v == "" || skip[v] {
continue
}
low := strings.ToLower(v)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
continue // 站点地址,不是凭据
}
vals = append(vals, clipRunes(v, maxCredentialFieldLen))
}
if len(vals) == 0 {
return out
}
// blog_id 兜底:wp.getUsersBlogs 的 pacel 是 [blog_id, username, password],
// blog_id 若是短数字(1),前面的 URL 过滤没能剔除,会让整组错位。
if len(vals)%2 == 1 && isShortDigits(vals[0]) {
vals = vals[1:]
}
// 全部原始值都留一份:配对可能不完美,原文不能丢。
for i, v := range vals {
if i >= maxXMLRPCPairs*2 {
break
}
out.Fields["xmlrpc."+strconv.Itoa(i)] = v
}
// 两两配对。第一组写进标准字段(CLI / 邮件 / 统计都读它),
// 其余的按 cred2 / cred3 … 留在 Fields 里 —— multicall 的字典常常
// 就是「一个用户名 + 一长串密码」,只留第一组等于丢掉整份字典。
pair := 0
for i := 0; i+1 < len(vals); i += 2 {
user, pass := vals[i], vals[i+1]
if pair == 0 {
out.Fields["username"] = user
out.Fields["password"] = pass
out.Username, out.Password, out.HadFields = user, pass, true
} else if pair < maxXMLRPCPairs {
prefix := "cred" + strconv.Itoa(pair+1) + "."
out.Fields[prefix+"username"] = user
out.Fields[prefix+"password"] = pass
}
pair++
}
return out
}
// isShortDigits 判断是否为 1–3 位纯数字(典型的 blog_id)。
func isShortDigits(s string) bool {
if len(s) == 0 || len(s) > 3 {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// unescapeXMLText 还原 XML 文本里最常见的五个实体。
//
// 不用 html.UnescapeString:那会把 &nbsp; 之类的 HTML 实体也展开,
// 而这里处理的是 XML 文本节点,多展开反而是错的。
func unescapeXMLText(s string) string {
if !strings.Contains(s, "&") {
return s
}
return strings.NewReplacer(
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&apos;", "'",
"&amp;", "&", // 必须最后替换
).Replace(s)
}
// WriteXMLRPCFault 按 WordPress 的真实格式写一个 fault 响应。
func WriteXMLRPCFault(w http.ResponseWriter, status, code int, msg string) {
body := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
`<methodResponse><fault><value><struct>` +
`<member><name>faultCode</name><value><int>` + strconv.Itoa(code) + `</int></value></member>` +
`<member><name>faultString</name><value><string>` + msg + `</string></value></member>` +
`</struct></value></fault></methodResponse>` + "\n"
writeXMLRPC(w, status, body)
}
// xmlRPCMethods 是 system.listMethods 的返回内容。
//
// 枚举类工具(WPScan / 各种指纹脚本)会先调这个确认「对方真的是 WordPress」。
// 回一个空 fault 或 HTML 都太假,静态列表足够且零成本。
var xmlRPCMethods = []string{
"system.multicall", "system.listMethods", "system.getCapabilities",
"blogger.getUsersBlogs", "blogger.deletePost", "blogger.getRecentPosts",
"metaWeblog.getPost", "metaWeblog.getRecentPosts", "metaWeblog.newPost",
"metaWeblog.editPost", "wp.getUsersBlogs", "wp.getPost", "wp.getPosts",
"wp.getMediaLibrary", "wp.getCategories", "wp.getTags",
}
// WriteXMLRPCMethods 回应 system.listMethods。
func WriteXMLRPCMethods(w http.ResponseWriter) {
var b strings.Builder
b.Grow(64 + len(xmlRPCMethods)*40)
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<methodResponse><params><param><value><array><data>")
for _, m := range xmlRPCMethods {
b.WriteString("<value><string>")
b.WriteString(m)
b.WriteString("</string></value>")
}
b.WriteString("</data></array></value></param></params></methodResponse>\n")
writeXMLRPC(w, http.StatusOK, b.String())
}
func writeXMLRPC(w http.ResponseWriter, status int, body string) {
h := w.Header()
h.Set("Content-Type", "text/xml; charset=UTF-8")
// 真实 WordPress 对 xmlrpc.php 不允许缓存。
h.Set("Cache-Control", "no-cache, must-revalidate, max-age=0")
h.Set("X-Robots-Tag", "noindex, nofollow")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}