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
+58 -3
View File
@@ -164,6 +164,13 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
return return
} }
// XML-RPC 端点单独处理:真实 WordPress 在这里回 XML,而不是登录页。
// 我们自己的 X-Pingback 头正指着这个地址,回 HTML 等于自曝。
if honeypot.IsXMLRPCPath(path) {
h.serveXMLRPC(ctx, clientIP, banKey, path)
return
}
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins / // 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。 // Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
fam := honeypot.FamilyOf(path) fam := honeypot.FamilyOf(path)
@@ -282,12 +289,60 @@ func isLoopbackIP(ip string) bool {
// 现在响应头也归 page.go 统一设置,改一处即可。 // 现在响应头也归 page.go 统一设置,改一处即可。
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) { func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{ honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
Action: path, Action: path,
Failed: failed, Failed: failed,
Redir: ctx.Request().URL.Query().Get("redirect_to"), 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 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。 // record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
// //
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。 // clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
+83
View File
@@ -644,3 +644,86 @@ func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits)) 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 := `<methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><array><data>` +
`<value><string>http://127.0.0.1/xmlrpc.php</string></value>` +
`<value><string>root</string></value>` +
`<value><string>toor</string></value>` +
`</data></array></value></param></params></methodCall>`
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), "<html") || strings.Contains(string(payload), honeypot.CanaryToken) {
t.Errorf("响应里出现了假登录页:%q", truncate(string(payload), 120))
}
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 {
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
}
if recs[0].Username != "root" || recs[0].Password != "toor" {
t.Errorf("凭据 = %q/%q, want root/toor", recs[0].Username, recs[0].Password)
}
if !recs[0].Banned {
t.Error("multicall 提交凭据应触发立即封禁")
}
// 封禁生效后,连普通 GET 也应被中间件挡掉。
resp2, err := http.Get(ts.URL + "/xmlrpc.php")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("封禁后 status = %d, want 403", resp2.StatusCode)
}
}
// TestXMLRPCGetReturnsFault 覆盖非 POST:真实 WordPress 回 405 + XML fault,
// 不是登录页。
func TestXMLRPCGetReturnsFault(t *testing.T) {
ts := newHoneypotTestServerFull(t, true, false, nil)
resp, err := http.Get(ts.URL + "/xmlrpc.php")
if err != nil {
t.Fatal(err)
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", 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), honeypot.XMLRPCFaultDisabled) {
t.Errorf("body = %q, want 含 %q", truncate(string(payload), 120), honeypot.XMLRPCFaultDisabled)
}
}
+7 -5
View File
@@ -116,7 +116,7 @@ var skins = []loginSkin{
footer: "", footer: "",
headers: [][2]string{ headers: [][2]string{
{"X-Powered-By", "PHP/7.4.33"}, {"X-Powered-By", "PHP/7.4.33"},
{"X-Pingback", "/xmlrpc.php"}, {"X-Pingback", "$base/xmlrpc.php"},
}, },
cookies: []string{"wordpress_test_cookie=WP%20Cookie%20check; path=/"}, cookies: []string{"wordpress_test_cookie=WP%20Cookie%20check; path=/"},
}, },
@@ -138,7 +138,7 @@ var skins = []loginSkin{
hidden: [][2]string{ hidden: [][2]string{
{"server", "1"}, {"server", "1"},
{"target", "index.php"}, {"target", "index.php"},
{"token", "e1a4c0b7d2f58g93"}, {"token", "$hex32"},
}, },
failure: `#1045 - Cannot log in to the MySQL server`, failure: `#1045 - Cannot log in to the MySQL server`,
footer: "Language: English &middot; Theme: pmahomme", footer: "Language: English &middot; Theme: pmahomme",
@@ -183,7 +183,7 @@ var skins = []loginSkin{
footer: "Jenkins ver. 2.401.3", footer: "Jenkins ver. 2.401.3",
headers: [][2]string{ headers: [][2]string{
{"X-Jenkins", "2.401.3"}, {"X-Jenkins", "2.401.3"},
{"X-Jenkins-Session", "8b1f2c4d"}, {"X-Jenkins-Session", "$hex8"},
}, },
}, },
{ {
@@ -207,6 +207,7 @@ var skins = []loginSkin{
headers: [][2]string{ headers: [][2]string{
{"X-Grafana-Org-Id", "1"}, {"X-Grafana-Org-Id", "1"},
}, },
cookies: []string{"grafana_sess=$hex32; Path=/; HttpOnly"},
}, },
{ {
family: FamilyGitLab, family: FamilyGitLab,
@@ -227,8 +228,9 @@ var skins = []loginSkin{
failure: "Invalid Login or password.", failure: "Invalid Login or password.",
footer: "GitLab Community Edition 16.8.1", footer: "GitLab Community Edition 16.8.1",
headers: [][2]string{ headers: [][2]string{
{"X-Gitlab-Meta", `{"correlation_id":"a1b2c3"}`}, {"X-Gitlab-Meta", `{"correlation_id":"$hex32"}`},
}, },
cookies: []string{"_gitlab_session=$hex32; path=/; HttpOnly"},
}, },
{ {
family: FamilyLuCI, family: FamilyLuCI,
@@ -292,7 +294,7 @@ var skins = []loginSkin{
{name: "username", label: "Username"}, {name: "username", label: "Username"},
{name: "password", label: "Password", secret: true}, {name: "password", label: "Password", secret: true},
}, },
hidden: [][2]string{{"csrf_token", "a7f3c1e9b2d4"}}, hidden: [][2]string{{"csrf_token", "$hex16"}},
failure: "Invalid username or password.", failure: "Invalid username or password.",
footer: "Restricted area. Authorized personnel only.", footer: "Restricted area. Authorized personnel only.",
headers: [][2]string{ headers: [][2]string{
+115
View File
@@ -179,3 +179,118 @@ func BenchmarkAssetFor(b *testing.B) {
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css") _, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
} }
} }
// hiddenValue 取某个 hidden input 的 value。
func hiddenValue(body, name string) string {
marker := `name="` + name + `" value="`
i := strings.Index(body, marker)
if i < 0 {
return ""
}
rest := body[i+len(marker):]
if j := strings.IndexByte(rest, '"'); j >= 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)
}
}
}
}
}
+10
View File
@@ -222,6 +222,16 @@ func (h *Honeypot) Match(rawPath string) bool {
if _, ok := h.prefix[p]; ok { if _, ok := h.prefix[p]; ok {
return true 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 // 段前缀:/wp-admin/install.php 命中 /wp-admin
segs := strings.Split(p, "/") segs := strings.Split(p, "/")
for i := 1; i < len(segs); i++ { for i := 1; i < len(segs); i++ {
@@ -213,3 +213,22 @@ func TestSplitPaths(t *testing.T) {
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got) 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)
}
}
}
+74 -4
View File
@@ -1,7 +1,10 @@
package honeypot package honeypot
import ( import (
"encoding/binary"
"encoding/hex"
"html" "html"
"math/rand/v2"
"net/http" "net/http"
"strings" "strings"
) )
@@ -19,6 +22,52 @@ type LoginPageVars struct {
Failed bool Failed bool
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。 // Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
Redir string 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 家族假登录页的标题(与真实登录页一致)。 // LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
@@ -72,6 +121,13 @@ func RenderFamily(f Family, vars LoginPageVars) []byte {
if notice == "" && vars.Failed { if notice == "" && vars.Failed {
notice = s.failure 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 var b strings.Builder
b.Grow(3072 + len(s.css)) 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("<input type=\"hidden\" name=\"")
b.WriteString(h[0]) b.WriteString(h[0])
b.WriteString("\" value=\"") b.WriteString("\" value=\"")
b.WriteString(html.EscapeString(v)) b.WriteString(html.EscapeString(expandDyn(v, token, base)))
b.WriteString("\">\n") b.WriteString("\">\n")
} }
if s.submit != "" { if s.submit != "" {
@@ -172,15 +228,29 @@ func RenderLoginPage(vars LoginPageVars) []byte {
// 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。 // 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。
func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) { func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) {
s := skinOf(f) s := skinOf(f)
// 响应头与页面共用同一套动态值:真实产品的 CSRF token 在表单里、
// 会话号在 Set-Cookie 里,两者不会是各自独立随机的。
if len(vars.Nonce) != dynamicTokenLen {
vars.Nonce = newToken()
}
base := strings.TrimRight(vars.BaseURL, "/")
h := w.Header() h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8") 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") h.Set("X-Robots-Tag", "noindex, nofollow")
for _, kv := range s.headers { 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 { for _, c := range s.cookies {
h.Add("Set-Cookie", c) h.Add("Set-Cookie", expandDyn(c, vars.Nonce, base))
} }
status := http.StatusOK status := http.StatusOK
if s.basicRealm != "" { if s.basicRealm != "" {
+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))
}
+152
View File
@@ -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 := `<?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)
}
}