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

223 lines
8.4 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 (
"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))
}