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), "