diff --git a/internal/route/honeypot.go b/internal/route/honeypot.go
index 25f30c3..db1ca0a 100644
--- a/internal/route/honeypot.go
+++ b/internal/route/honeypot.go
@@ -164,6 +164,13 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
return
}
+ // XML-RPC 端点单独处理:真实 WordPress 在这里回 XML,而不是登录页。
+ // 我们自己的 X-Pingback 头正指着这个地址,回 HTML 等于自曝。
+ if honeypot.IsXMLRPCPath(path) {
+ h.serveXMLRPC(ctx, clientIP, banKey, path)
+ return
+ }
+
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
fam := honeypot.FamilyOf(path)
@@ -282,12 +289,60 @@ func isLoopbackIP(ip string) bool {
// 现在响应头也归 page.go 统一设置,改一处即可。
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
- Action: path,
- Failed: failed,
- Redir: ctx.Request().URL.Query().Get("redirect_to"),
+ Action: path,
+ Failed: failed,
+ Redir: ctx.Request().URL.Query().Get("redirect_to"),
+ BaseURL: conf.App.ExternalURL,
})
}
+// serveXMLRPC 处理 /xmlrpc.php。
+//
+// 为什么单独做:system.multicall 一次请求能带上千组用户名/密码,且绕开登录页的
+// 限速插件 —— 这是 WordPress 爆破里产出最高的一条通道,xxer、WPScan 的 xmlrpc
+// 模式都走它。原来的实现把这个路径当普通诱饵,回一个 HTML 登录页:
+// 既拿不到字典,又因为 X-Pingback 指着它而暴露「整个后台是假的」。
+//
+// 响应形态对齐真实 WordPress:
+// - 非 POST(GET/PUT/…)→ 405 + fault「XML-RPC services are disabled on this site.」
+// - POST + 凭据 → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
+// - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
+func (h *honeypotHandler) serveXMLRPC(ctx flamego.Context, clientIP, banKey, path string) {
+ req := ctx.Request().Request
+ w := ctx.ResponseWriter()
+
+ if req.Method != http.MethodPost {
+ // 纯探测:计入常规敏感路径统计(阈值同其它诱饵),不立即封。
+ if h.ab != nil && banKey != "" {
+ h.ab.Record(banKey, path, autoban.KindSensitive)
+ }
+ h.record(clientIP, banKey, req, path, honeypot.Credentials{}, false)
+ honeypot.WriteXMLRPCFault(w, http.StatusMethodNotAllowed, 405, honeypot.XMLRPCFaultDisabled)
+ return
+ }
+
+ // body 只能读一次,提取完就不能再给后面的通用逻辑用了。
+ creds := honeypot.ExtractXMLRPCCredentials(req)
+
+ // 与表单提交同一套判定:带凭据 → 蜜罐通道立即封;否则走常规阈值。
+ banned := false
+ if h.ab != nil && banKey != "" {
+ if creds.HadFields {
+ banned = h.ab.RecordHoneypot(banKey, path)
+ } else {
+ h.ab.Record(banKey, path, autoban.KindSensitive)
+ }
+ }
+
+ if !creds.HadFields && creds.Fields["xmlrpc.method"] == "system.listMethods" {
+ honeypot.WriteXMLRPCMethods(w)
+ } else {
+ honeypot.WriteXMLRPCFault(w, http.StatusOK, 403, honeypot.XMLRPCFaultBadLogin)
+ }
+
+ h.record(clientIP, banKey, req, path, creds, banned)
+}
+
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
//
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
diff --git a/internal/route/honeypot_test.go b/internal/route/honeypot_test.go
index e6e4b44..7873f3d 100644
--- a/internal/route/honeypot_test.go
+++ b/internal/route/honeypot_test.go
@@ -644,3 +644,86 @@ func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
}
}
+
+// TestXMLRPCPostCapturesCredentials 守住 /xmlrpc.php 是真正的 XML-RPC 端点。
+//
+// 之前它回的是 HTML 登录页,而响应头 X-Pingback 又指着这个地址 ——
+// 扫描器跟过来一看是 HTML 就知道整站是假的;更可惜的是 system.multicall
+// 这条「一次上千组凭据」的通道被白白浪费。
+func TestXMLRPCPostCapturesCredentials(t *testing.T) {
+ cs, err := honeypot.NewFileCredStore(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := newHoneypotTestServerFull(t, true, false, cs)
+
+ body := `wp.getUsersBlogs` +
+ `http://127.0.0.1/xmlrpc.php` +
+ `root` +
+ `toor` +
+ ``
+
+ resp, err := http.Post(ts.URL+"/xmlrpc.php", "text/xml", strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200(真实 WordPress 用 200 + fault 表达登录失败)", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
+ t.Errorf("Content-Type = %q, want text/xml", ct)
+ }
+ if strings.Contains(string(payload), "= 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)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/security/honeypot/honeypot.go b/internal/security/honeypot/honeypot.go
index e83bd4c..ca698ed 100644
--- a/internal/security/honeypot/honeypot.go
+++ b/internal/security/honeypot/honeypot.go
@@ -222,6 +222,16 @@ func (h *Honeypot) Match(rawPath string) bool {
if _, ok := h.prefix[p]; ok {
return true
}
+ // .php 变体:清单里写的是 /phpinfo,而扫描器打的是 /phpinfo.php。
+ // 段前缀匹配按 '/' 与 '.' 做边界,但 /phpinfo.php 的 '.' 后面直接是 "php",
+ // 段前缀拼出来的 /phpinfo.php 本身不在集合里,于是会漏掉 ——
+ // 漏掉的表现是 302 回首页,等于明确告诉对方「这个文件不存在」,
+ // 而真跑着 PHP 的站点会回 200 或 403。
+ if strings.HasSuffix(p, ".php") {
+ if _, ok := h.prefix[strings.TrimSuffix(p, ".php")]; ok {
+ return true
+ }
+ }
// 段前缀:/wp-admin/install.php 命中 /wp-admin
segs := strings.Split(p, "/")
for i := 1; i < len(segs); i++ {
diff --git a/internal/security/honeypot/honeypot_test.go b/internal/security/honeypot/honeypot_test.go
index c6587b7..a80c342 100644
--- a/internal/security/honeypot/honeypot_test.go
+++ b/internal/security/honeypot/honeypot_test.go
@@ -213,3 +213,22 @@ func TestSplitPaths(t *testing.T) {
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
}
}
+
+// TestMatchPHPVariant 守住 .php 变体:清单里是 /phpinfo,扫描器打的是 /phpinfo.php。
+//
+// 漏掉的后果不是「少记一条日志」,而是回 302 → /:等于明确告诉对方
+// 「这个文件不存在」,而真跑着 PHP 的站点会回 200 或 403。
+func TestMatchPHPVariant(t *testing.T) {
+ h := New(Options{Enabled: true})
+ for _, p := range []string{"/phpinfo.php", "/info.php", "/test.php", "/adminer.php", "/phpmyadmin/index.php"} {
+ if !h.Match(p) {
+ t.Errorf("Match(%q) = false, want true", p)
+ }
+ }
+ // 业务路由与普通页面不能被这条规则牵连。
+ for _, p := range []string{"/login", "/user/abc", "/api/v1/questions", "/question/xyz"} {
+ if h.Match(p) {
+ t.Errorf("Match(%q) = true, want false(业务路径不得被接管)", p)
+ }
+ }
+}
diff --git a/internal/security/honeypot/page.go b/internal/security/honeypot/page.go
index 8f2aede..76777e3 100644
--- a/internal/security/honeypot/page.go
+++ b/internal/security/honeypot/page.go
@@ -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("\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 != "" {
diff --git a/internal/security/honeypot/xmlrpc.go b/internal/security/honeypot/xmlrpc.go
new file mode 100644
index 0000000..fa4b4ad
--- /dev/null
+++ b/internal/security/honeypot/xmlrpc.go
@@ -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 抓 methodNameX...
+ // metacall 的每个 pacel 里都有一份,普通调用则在 标签里。
+ xmlMethodNameRe = regexp.MustCompile(`(?s)\s*methodName\s*\s*\s*([^<]*)`)
+ xmlMethodTagRe = regexp.MustCompile(`(?s)\s*([^<]*)`)
+ xmlStringRe = regexp.MustCompile(`(?s)(.*?)`)
+)
+
+// ExtractXMLRPCCredentials 从 XML-RPC 请求体里提取凭据。
+//
+// 解析策略是「够用就好」,刻意不上 encoding/xml:蜜罐会被高频命中,
+// 而这里的 payload 结构固定(methodCall + 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, " 标签(普通调用)与 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:那会把 之类的 HTML 实体也展开,
+// 而这里处理的是 XML 文本节点,多展开反而是错的。
+func unescapeXMLText(s string) string {
+ if !strings.Contains(s, "&") {
+ return s
+ }
+ return strings.NewReplacer(
+ "<", "<",
+ ">", ">",
+ """, `"`,
+ "'", "'",
+ "&", "&", // 必须最后替换
+ ).Replace(s)
+}
+
+// WriteXMLRPCFault 按 WordPress 的真实格式写一个 fault 响应。
+func WriteXMLRPCFault(w http.ResponseWriter, status, code int, msg string) {
+ body := `` + "\n" +
+ `` +
+ `faultCode` + strconv.Itoa(code) + `` +
+ `faultString` + msg + `` +
+ `` + "\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(`` + "\n")
+ for _, m := range xmlRPCMethods {
+ b.WriteString("")
+ b.WriteString(m)
+ b.WriteString("")
+ }
+ b.WriteString("\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))
+}
diff --git a/internal/security/honeypot/xmlrpc_test.go b/internal/security/honeypot/xmlrpc_test.go
new file mode 100644
index 0000000..4b48b56
--- /dev/null
+++ b/internal/security/honeypot/xmlrpc_test.go
@@ -0,0 +1,152 @@
+package honeypot
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestIsXMLRPCPath(t *testing.T) {
+ cases := map[string]bool{
+ "/xmlrpc.php": true,
+ "/xmlrpc": true,
+ "/XMLRPC.PHP": true,
+ "/xmlrpc.php/": true,
+ "/xmlrpcx.php": false,
+ "/wp-login.php": false,
+ "": false,
+ }
+ for p, want := range cases {
+ if got := IsXMLRPCPath(p); got != want {
+ t.Errorf("IsXMLRPCPath(%q) = %v, want %v", p, got, want)
+ }
+ }
+}
+
+// TestExtractXMLRPCCredentialsUsersBlogs 覆盖最常见的爆破形态:
+// wp.getUsersBlogs 的参数是 [站点地址, 用户名, 密码],站点地址必须被剔除。
+func TestExtractXMLRPCCredentialsUsersBlogs(t *testing.T) {
+ body := `
+wp.getUsersBlogs
+
+https://target.test/xmlrpc.php
+admin
+s3cr3t
+
+`
+
+ req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
+ req.Header.Set("Content-Type", "text/xml")
+ creds := ExtractXMLRPCCredentials(req)
+
+ if !creds.HadFields {
+ t.Fatal("HadFields = false, want true(这是标准的凭据提交)")
+ }
+ if creds.Username != "admin" || creds.Password != "s3cr3t" {
+ t.Errorf("凭据 = %q/%q, want admin/s3cr3t(站点 URL 必须被剔除)", creds.Username, creds.Password)
+ }
+ if got := creds.Fields["xmlrpc.method"]; got != "wp.getUsersBlogs" {
+ t.Errorf("xmlrpc.method = %q", got)
+ }
+}
+
+// TestExtractXMLRPCCredentialsMulticall 覆盖 system.multicall:
+// 一包多组凭据时,第一组进标准字段,其余进 cred2/cred3,不能只剩第一组。
+func TestExtractXMLRPCCredentialsMulticall(t *testing.T) {
+ pacel := func(user, pass string) string {
+ return `
+methodNamewp.getUsersBlogs
+params
+https://target.test/xmlrpc.php
+` + user + `
+` + pass + `
+
+`
+ }
+ body := `system.multicall` +
+ pacel("root", "toor") + pacel("root", "hunter2") +
+ ``
+
+ req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
+ creds := ExtractXMLRPCCredentials(req)
+
+ if creds.Username != "root" || creds.Password != "toor" {
+ t.Errorf("第一组 = %q/%q, want root/toor", creds.Username, creds.Password)
+ }
+ if got := creds.Fields["cred2.password"]; got != "hunter2" {
+ t.Errorf("cred2.password = %q, want hunter2(multicall 的字典不能只留第一组)", got)
+ }
+}
+
+// TestExtractXMLRPCCredentialsNumericBlogID 覆盖 blog_id=1 的错位场景:
+// 参数是 [1, user, pass],前面的 URL 过滤没有剔除 "1",配对会整体错一位。
+func TestExtractXMLRPCCredentialsNumericBlogID(t *testing.T) {
+ body := `wp.getUsersBlogs
+1
+admin
+123456
+`
+
+ req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
+ creds := ExtractXMLRPCCredentials(req)
+
+ if creds.Username != "admin" || creds.Password != "123456" {
+ t.Errorf("凭据 = %q/%q, want admin/123456(短数字首位应视为 blog_id 丢弃)",
+ creds.Username, creds.Password)
+ }
+}
+
+// TestExtractXMLRPCCredentialsIgnored 覆盖「不是 XML-RPC 调用」的请求:
+// 不能把普通探测误判成凭据提交(否则会触发一封假告警并立即封禁)。
+func TestExtractXMLRPCCredentialsIgnored(t *testing.T) {
+ for _, body := range []string{
+ "log=admin&pwd=x",
+ `{"log":"admin","pwd":"x"}`,
+ `system.listMethods`,
+ } {
+ req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
+ creds := ExtractXMLRPCCredentials(req)
+ if creds.HadFields {
+ t.Errorf("body %q: HadFields = true, want false", body)
+ }
+ }
+}
+
+func TestWriteXMLRPCFault(t *testing.T) {
+ for _, status := range []int{http.StatusOK, http.StatusMethodNotAllowed} {
+ rec := httptest.NewRecorder()
+ WriteXMLRPCFault(rec, status, 403, XMLRPCFaultBadLogin)
+
+ if rec.Code != status {
+ t.Errorf("status = %d, want %d", rec.Code, status)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
+ t.Errorf("Content-Type = %q, want text/xml(真实 WordPress 不会回 HTML)", ct)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "403") || !strings.Contains(body, XMLRPCFaultBadLogin) {
+ t.Errorf("body = %q, want 含 faultCode 与文案", body)
+ }
+ if strings.Contains(body, "system.multicall") {
+ t.Errorf("body 缺 system.multicall:%q", body)
+ }
+ if !strings.Contains(body, "") {
+ t.Errorf("body 不是 methodResponse:%q", body)
+ }
+}