线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装: 一、/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 分配。
153 lines
5.5 KiB
Go
153 lines
5.5 KiB
Go
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)
|
||
}
|
||
}
|