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:
@@ -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 是聚合键(只用于采样去重)。
|
||||
|
||||
@@ -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 := `<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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user