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 注释里,不放进 : // 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("\n\n
\n") b.WriteString("\n") b.WriteString("\n") b.WriteString("\n") if s.gen != "" { b.WriteString("\n") } b.WriteString("") b.WriteString(s.tagline) b.WriteString("
\n") } if notice != "" { b.WriteString("") b.WriteString(s.footer) b.WriteString("
\n") } b.WriteString("