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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ var skins = []loginSkin{
|
||||
footer: "",
|
||||
headers: [][2]string{
|
||||
{"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=/"},
|
||||
},
|
||||
@@ -138,7 +138,7 @@ var skins = []loginSkin{
|
||||
hidden: [][2]string{
|
||||
{"server", "1"},
|
||||
{"target", "index.php"},
|
||||
{"token", "e1a4c0b7d2f58g93"},
|
||||
{"token", "$hex32"},
|
||||
},
|
||||
failure: `#1045 - Cannot log in to the MySQL server`,
|
||||
footer: "Language: English · Theme: pmahomme",
|
||||
@@ -183,7 +183,7 @@ var skins = []loginSkin{
|
||||
footer: "Jenkins ver. 2.401.3",
|
||||
headers: [][2]string{
|
||||
{"X-Jenkins", "2.401.3"},
|
||||
{"X-Jenkins-Session", "8b1f2c4d"},
|
||||
{"X-Jenkins-Session", "$hex8"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -207,6 +207,7 @@ var skins = []loginSkin{
|
||||
headers: [][2]string{
|
||||
{"X-Grafana-Org-Id", "1"},
|
||||
},
|
||||
cookies: []string{"grafana_sess=$hex32; Path=/; HttpOnly"},
|
||||
},
|
||||
{
|
||||
family: FamilyGitLab,
|
||||
@@ -227,8 +228,9 @@ var skins = []loginSkin{
|
||||
failure: "Invalid Login or password.",
|
||||
footer: "GitLab Community Edition 16.8.1",
|
||||
headers: [][2]string{
|
||||
{"X-Gitlab-Meta", `{"correlation_id":"a1b2c3"}`},
|
||||
{"X-Gitlab-Meta", `{"correlation_id":"$hex32"}`},
|
||||
},
|
||||
cookies: []string{"_gitlab_session=$hex32; path=/; HttpOnly"},
|
||||
},
|
||||
{
|
||||
family: FamilyLuCI,
|
||||
@@ -292,7 +294,7 @@ var skins = []loginSkin{
|
||||
{name: "username", label: "Username"},
|
||||
{name: "password", label: "Password", secret: true},
|
||||
},
|
||||
hidden: [][2]string{{"csrf_token", "a7f3c1e9b2d4"}},
|
||||
hidden: [][2]string{{"csrf_token", "$hex16"}},
|
||||
failure: "Invalid username or password.",
|
||||
footer: "Restricted area. Authorized personnel only.",
|
||||
headers: [][2]string{
|
||||
|
||||
@@ -179,3 +179,118 @@ func BenchmarkAssetFor(b *testing.B) {
|
||||
_, _, _, _ = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<input type=\"hidden\" name=\"")
|
||||
b.WriteString(h[0])
|
||||
b.WriteString("\" value=\"")
|
||||
b.WriteString(html.EscapeString(v))
|
||||
b.WriteString(html.EscapeString(expandDyn(v, token, base)))
|
||||
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 != "" {
|
||||
|
||||
@@ -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:那会把 之类的 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 := `<?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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user