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