Files
tamabox/internal/security/honeypot/page.go
T
tamakyi 307bd94ed1 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 分配。
2026-10-23 12:58:48 +08:00

268 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package honeypot
import (
"encoding/binary"
"encoding/hex"
"html"
"math/rand/v2"
"net/http"
"strings"
)
// LoginPageVars 是假登录页的渲染参数。
type LoginPageVars struct {
// Action 是表单提交地址(当前诱饵路径本身)。
Action string
// Title / Heading 覆盖家族默认的标题与品牌名(留空即用家族设定)。
Title string
Heading string
// Notice 是自定义提示语;留空时由 Failed 决定是否显示家族的失败文案。
Notice string
// Failed 表示这是一次「提交后」的响应,需要显示登录失败提示。
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 家族假登录页的标题(与真实登录页一致)。
// 抽成常量:之前 route 层手抄了一份字面量,一处写 `‹`、一处写 `\u2039`,
// 已经出现漂移。
const LoginPageTitle = "Log In ‹ WordPress"
// LoginFailureNotice 是 WordPress 提交凭据后的提示
// (不区分「用户不存在」与「密码错误」,避免响应差异让爆破脚本调整策略)。
const LoginFailureNotice = "ERROR: The password you entered for the username is incorrect."
// baseLoginCSS 是全部家族共用的骨架样式,各家族只叠加自己的配色。
const baseLoginCSS = `*{box-sizing:border-box}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;background:#f0f0f1;color:#3c434a}` +
`.card{width:340px;margin:8vh auto;background:#fff;border:1px solid #dcdcde;border-radius:4px;padding:26px 24px 30px}` +
`h1{margin:0 0 6px;font-size:22px;font-weight:400;text-align:center}` +
`.tag{margin:0 0 16px;text-align:center;font-size:11px;color:#787c82;letter-spacing:.3px}` +
`label{display:block;margin-top:12px;font-size:13px}` +
`input[type=text],input[type=password]{display:block;width:100%;margin-top:5px;padding:7px 9px;border:1px solid #c3c4c7;border-radius:3px;font-size:14px}` +
`button{display:block;width:100%;margin-top:18px;padding:9px;border:0;border-radius:3px;color:#fff;font-size:14px;cursor:pointer}` +
`.err{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:0 0 16px;font-size:13px;line-height:1.5}` +
`.foot{margin:22px auto 0;text-align:center;font-size:12px;color:#787c82;line-height:1.7}`
// fieldIDReplacer 把 user[login] 这类字段名转成合法的 HTML id。
// 提到包级:每次渲染新建一个 Replacer 要分配十几次,而蜜罐是被高频命中的。
var fieldIDReplacer = strings.NewReplacer("[", "_", "]", "")
// RenderFamily 按家族渲染假登录页。
//
// 资源占用刻意压到最低:全部是编译期常量 + 一次 strings.Builder 拼接,
// 没有 html/template 解析、没有文件读取、没有外部请求。一个页面约 1.5–2.5 KB。
//
// 写的时候刻意不用 `"a" + b + "c"`:每个 + 都是一次临时分配。
// 连续 WriteString 走 append,配合一次 Grow,整页只有 2–3 次分配
// (见 family_test.go 的 BenchmarkRenderFamily)。
//
// canary 只放 HTML 注释里,不放进 <meta name="generator">:
// generator 是扫描器的必查项,必须伪装成真实产品,否则等于自曝蜜罐。
func RenderFamily(f Family, vars LoginPageVars) []byte {
s := skinOf(f)
action := html.EscapeString(vars.Action)
title := vars.Title
if title == "" {
title = s.title
}
brand := vars.Heading
if brand == "" {
brand = s.brand
}
notice := vars.Notice
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))
b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
b.WriteString("<meta charset=\"utf-8\">\n")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n")
b.WriteString("<meta name=\"robots\" content=\"noindex,nofollow\">\n")
if s.gen != "" {
b.WriteString("<meta name=\"generator\" content=\"")
b.WriteString(html.EscapeString(s.gen))
b.WriteString("\">\n")
}
b.WriteString("<title>")
b.WriteString(html.EscapeString(title))
b.WriteString("</title>\n<style>")
b.WriteString(baseLoginCSS)
b.WriteString(s.css)
b.WriteString("</style>\n</head>\n<body class=\"")
b.WriteString(s.class)
b.WriteString("\">\n<!-- build: ")
b.WriteString(CanaryToken)
b.WriteString(" -->\n<div class=\"card\">\n<h1>")
b.WriteString(html.EscapeString(brand))
b.WriteString("</h1>\n")
if s.tagline != "" {
b.WriteString("<p class=\"tag\">")
b.WriteString(s.tagline)
b.WriteString("</p>\n")
}
if notice != "" {
b.WriteString("<div class=\"err\">")
b.WriteString(html.EscapeString(notice))
b.WriteString("</div>\n")
}
b.WriteString("<form method=\"post\" action=\"")
b.WriteString(action)
b.WriteString("\" name=\"")
b.WriteString(s.form)
b.WriteString("\" id=\"")
b.WriteString(s.form)
b.WriteString("\">\n")
for _, fld := range s.fields {
typ, ac := "text", "username"
if fld.secret {
typ, ac = "password", "current-password"
}
id := "id_" + fieldIDReplacer.Replace(fld.name)
b.WriteString("<label for=\"")
b.WriteString(id)
b.WriteString("\">")
b.WriteString(fld.label)
b.WriteString("<input type=\"")
b.WriteString(typ)
b.WriteString("\" name=\"")
b.WriteString(fld.name)
b.WriteString("\" id=\"")
b.WriteString(id)
b.WriteString("\" value=\"\" autocomplete=\"")
b.WriteString(ac)
b.WriteString("\"></label>\n")
}
for _, h := range s.hidden {
v := h[1]
if v == "$redirect" {
v = vars.Redir
}
b.WriteString("<input type=\"hidden\" name=\"")
b.WriteString(h[0])
b.WriteString("\" value=\"")
b.WriteString(html.EscapeString(expandDyn(v, token, base)))
b.WriteString("\">\n")
}
if s.submit != "" {
b.WriteString("<button type=\"submit\">")
b.WriteString(s.submit)
b.WriteString("</button>\n")
}
b.WriteString("</form>\n")
if s.footer != "" {
b.WriteString("<p class=\"foot\">")
b.WriteString(s.footer)
b.WriteString("</p>\n")
}
b.WriteString("</div>\n</body>\n</html>\n")
return []byte(b.String())
}
// RenderLoginPage 保留旧签名(WordPress 家族),供既有调用方与测试使用。
func RenderLoginPage(vars LoginPageVars) []byte {
return RenderFamily(FamilyWordPress, vars)
}
// WriteFamilyPage 写出某个家族的假登录页,并带上该家族的伪装响应头。
//
// Tomcat Manager 未认证时真实返回 401 + WWW-Authenticate,所以这里也照做 ——
// 凭据从 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")
// 真实 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 {
// 依赖对外地址的头(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", expandDyn(c, vars.Nonce, base))
}
status := http.StatusOK
if s.basicRealm != "" {
h.Set("WWW-Authenticate", `Basic realm="`+s.basicRealm+`"`)
status = http.StatusUnauthorized
}
w.WriteHeader(status)
_, _ = w.Write(RenderFamily(f, vars))
}
// WriteLoginPage 保留旧签名(WordPress 家族)。
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
WriteFamilyPage(w, FamilyWordPress, vars)
}