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 抓 methodNameX... // metacall 的每个 pacel 里都有一份,普通调用则在 标签里。 xmlMethodNameRe = regexp.MustCompile(`(?s)\s*methodName\s*\s*\s*([^<]*)`) xmlMethodTagRe = regexp.MustCompile(`(?s)\s*([^<]*)`) xmlStringRe = regexp.MustCompile(`(?s)(.*?)`) ) // ExtractXMLRPCCredentials 从 XML-RPC 请求体里提取凭据。 // // 解析策略是「够用就好」,刻意不上 encoding/xml:蜜罐会被高频命中, // 而这里的 payload 结构固定(methodCall + 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, " 标签(普通调用)与 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 := `` + "\n" + `` + `faultCode` + strconv.Itoa(code) + `` + `faultString` + msg + `` + `` + "\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(`` + "\n") for _, m := range xmlRPCMethods { b.WriteString("") b.WriteString(m) b.WriteString("") } b.WriteString("\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)) }