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