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

153 lines
5.5 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 (
"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 := `<?xml version="1.0"?>
<methodCall><methodName>wp.getUsersBlogs</methodName><params>
<param><value><array><data>
<value><string>https://target.test/xmlrpc.php</string></value>
<value><string>admin</string></value>
<value><string>s3cr3t</string></value>
</data></array></value></param>
</params></methodCall>`
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 `<value><struct>
<member><name>methodName</name><value><string>wp.getUsersBlogs</string></value></member>
<member><name>params</name><value><array><data>
<value><string>https://target.test/xmlrpc.php</string></value>
<value><string>` + user + `</string></value>
<value><string>` + pass + `</string></value>
</data></array></value></member>
</struct></value>`
}
body := `<methodCall><methodName>system.multicall</methodName><params><param><value><array><data>` +
pacel("root", "toor") + pacel("root", "hunter2") +
`</data></array></value></param></params></methodCall>`
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 := `<methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><array><data>
<value><string>1</string></value>
<value><string>admin</string></value>
<value><string>123456</string></value>
</data></array></value></param></params></methodCall>`
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"}`,
`<methodCall><methodName>system.listMethods</methodName></methodCall>`,
} {
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, "<int>403</int>") || !strings.Contains(body, XMLRPCFaultBadLogin) {
t.Errorf("body = %q, want 含 faultCode 与文案", body)
}
if strings.Contains(body, "<html") {
t.Error("body 里不能出现 HTML —— 这正是要修的破绽")
}
}
}
func TestWriteXMLRPCMethods(t *testing.T) {
rec := httptest.NewRecorder()
WriteXMLRPCMethods(rec)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
// 枚举类工具靠 system.multicall 判断「对面能收字典」。
if !strings.Contains(body, "<string>system.multicall</string>") {
t.Errorf("body 缺 system.multicall:%q", body)
}
if !strings.Contains(body, "<methodResponse><params>") {
t.Errorf("body 不是 methodResponse:%q", body)
}
}