Files
tamabox/internal/security/honeypot/honeypot.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

501 lines
17 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 提供「虚假后台登录界面」诱饵能力。
//
// 设计原则(务必遵守):
// 1. 只返回假内容,绝不返回、绝不猜测任何真实凭据;
// 2. 只在 flamego 的 NotFound 分流点生效 —— 即「没有任何路由匹配」的路径,
// 因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等真实业务
// 路由天然零交集,不会劫持正常用户;
// 3. 命中即短路:不查业务表、不渲染业务模板、不调用 ctx.Next()。
package honeypot
import (
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
)
// CanaryToken 是所有假页面统一的溯源标记。它只会出现在诱饵响应里,
// 一旦在别处(真实页面、错误日志)出现即可判定为蜜罐泄漏/误挂。
const CanaryToken = "tb-hp-7f3a1c9e"
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
const maxCredentialFieldLen = 512
// clipRunes 按字符(rune)截断字符串。
//
// 为什么不能用 s[:n]:按字节切会把多字节 UTF-8 字符劈成半个序列,得到非法
// UTF-8。凭据是直接落库的,PostgreSQL 会以 `invalid byte sequence for encoding
// "UTF8"` 拒绝整条插入 —— 取证记录就这么丢了,而且只在日志里留一行 warn。
//
// 攻击者可以随便发一个 600 字节的中文密码来稳定触发这个问题,所以这里是
// 可达路径,不是理论风险。
func clipRunes(s string, n int) string {
// 字节数已不超过 n,字符数必然也不超过,无需转换。
if n <= 0 || len(s) <= n {
return s
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
var honeypotPrefixes = []string{
"/admin", "/administrator", "/wp-admin", "/wp-content", "/wp-includes",
"/wp-login", "/wp-login.php", "/wp-cron", "/wp-trackback", "/wp-comments-post",
"/wp-json", "/wp-config", "/wp-mail", "/wp-signup", "/wp-activate", "/wp-links-opml",
"/wp-blog-header", "/wp-load", "/wp-settings", "/wp-load.php", "/wp-settings.php",
"/xmlrpc", "/console", "/manager", "/manager/html", "/phpmyadmin", "/pma",
"/cpanel", "/plesk", "/webadmin", "/adminer", "/shell", "/cmd", "/eval",
"/webshell", "/cgi-bin", "/shell.php", "/admin.php", "/login.php",
"/.ssh", "/.aws", "/.env", "/.git", "/.svn", "/.hg", "/.docker",
"/.config", "/.npm", "/.kube", "/.vscode", "/.idea",
"/actuator", "/swagger", "/swagger-ui", "/openapi", "/v2/api-docs", "/v3/api-docs",
"/api-docs", "/druid", "/nacos", "/solr", "/elastic", "/apollo",
// 其它常见后台/运维面板:每条都对应 family.go 里的一个伪装家族,
// 这些前缀同样与业务路由零交集(业务只有 /user、/login、/_/ 等)。
"/jenkins", "/jnlpJars", "/grafana", "/users/sign_in", "/users/password",
"/users/sign_up", "/luci", "/monitoring", "/zabbix", "/nagios",
"/jira", "/confluence", "/gitlab", "/nextcloud", "/owncloud",
"/webmail", "/roundcube", "/whm", "/host-manager", "/jmx-console",
"/web-console", "/invoker",
"/server-status", "/server-info", "/info.php", "/phpinfo", "/test.php",
"/backup", "/backups", "/dump", "/database", "/db", "/sql", "/mysql",
"/config", "/configuration", "/vendor", "/storage", "/tmp", "/temp",
"/upload", "/uploads", "/files", "/log", "/logs",
}
// honeypotFragments 为任意位置子串匹配的诱饵路径片段(大小写不敏感)。
var honeypotFragments = []string{
"/wp-admin", "/wp-login", "/wp-content", "/wp-includes", "/wp-json",
"/wp-config", "/xmlrpc.php", "/wordpress", "/phpmyadmin", "/cgi-bin/",
"/.git/", "/.env", "/.aws/", "/.ssh/", "/actuator/", "/swagger",
"/druid/", "/solr/", "/nacos/", "/vuln", "/shell?", "/cmd?",
"/.bak", "/.old", "/.orig", "/.save", "/.swp",
}
// honeypotSuffixes 是按「路径结尾」匹配的敏感文件后缀。
//
// 为什么单独列:备份/配置类泄露扫描(/backup.zip、/db.sql、/site.tar.gz、/x.bak)
// 的文件名主体是随机的,只有后缀稳定。上一版用子串 "/.zip" 匹配,
// 结果 /backup.zip 命中不了(前面是 "k" 不是 "/"),而 /www.zip、/1.sql 全部漏网。
// 结尾匹配既精确又不会误伤业务路由(业务 URL 空间里不存在这些后缀),
// 且 forbiddenPrefixes 仍会先剔除业务前缀。
var honeypotSuffixes = []string{
".zip", ".tar", ".tar.gz", ".tgz", ".gz", ".rar", ".7z", ".bz2",
".sql", ".sql.gz", ".dump", ".bak", ".old", ".orig", ".save",
".swp", ".env", ".env.bak", ".env.local",
".pem", ".key", ".p12", ".pfx", ".jks", ".kdbx", ".ovpn",
}
// forbiddenPrefixes 是业务路由前缀:即便有人往诱饵表里塞了这些前缀,
// 匹配时也会被强制剔除,构成第二道保险。
var forbiddenPrefixes = []string{
"/login", "/register", "/logout", "/signup", "/sign-in", "/sign_up",
"/user", "/api", "/_", "/assets", "/static", "/css", "/js", "/images",
"/favicon", "/robots", "/sitemap", "/feed", "/rss", "/about", "/rules",
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
}
// SplitPaths 解析配置中的自定义诱饵路径(逗号 / 分号 / 空白 / 换行分隔)。
//
// 放在本包而不是 route:CLI 的 `honeypot paths` 也要用同一份解析规则,
// 两边各写一遍迟早漂移(一处补了分号、另一处没补),而配置解析不一致
// 会让「我配了但没生效」这类问题更难查。
func SplitPaths(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
// 在这里就补前导斜杠:CLI 会把这个结果直接打给运维看,
// 显示 "foo" 而实际生效 "/foo" 会让人以为是两个不同的东西
// (New 里的 normalize 是幂等的,重复补不会出问题)。
if !strings.HasPrefix(f, "/") {
f = "/" + f
}
// 拒绝根路径:"/" 会让**所有** 404 都变成假登录页 —— 正常访客输错
// 网址也看到 WordPress 后台,是灾难级的配置失误,几乎不可能是本意。
if f == "/" {
continue
}
out = append(out, f)
}
return out
}
// Options 是蜜罐行为配置。
type Options struct {
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
Enabled bool
// ExtraPaths 追加自定义诱饵路径(支持段前缀语义,写成 "/a/b" 即前缀匹配)。
ExtraPaths []string
// MaxPayloadBytes 单次可记录的用户名/密码等字段长度上限。
MaxPayloadBytes int
}
func (o Options) maxField() int {
if o.MaxPayloadBytes > 0 {
return o.MaxPayloadBytes
}
return maxCredentialFieldLen
}
// Honeypot 是蜜罐匹配器,构造后不可变,可安全并发使用。
type Honeypot struct {
enabled bool
prefix map[string]bool
maxField int
}
// New 构造蜜罐匹配器。ExtraPaths 会参与前缀匹配。
func New(o Options) *Honeypot {
h := &Honeypot{
enabled: o.Enabled,
prefix: make(map[string]bool, len(honeypotPrefixes)+len(o.ExtraPaths)),
maxField: o.maxField(),
}
for _, p := range honeypotPrefixes {
h.prefix[normalize(p)] = true
}
for _, p := range o.ExtraPaths {
if p = normalize(p); p != "" {
h.prefix[p] = true
}
}
return h
}
// Enabled 报告蜜罐是否启用。
func (h *Honeypot) Enabled() bool { return h != nil && h.enabled }
// normalize 归一化路径:小写、合并重复斜杠、去掉结尾斜杠(根除外)。
func normalize(p string) string {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
return ""
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
// 折叠重复斜杠
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
for strings.HasSuffix(p, "/") && len(p) > 1 {
p = strings.TrimSuffix(p, "/")
}
return p
}
// Match 判断路径是否应被蜜罐接管。
//
// 归一化步骤:去查询串、URL 反解码一次(对抗 %2f 编码绕过)、折叠斜杠、
// 去掉尾部 /index.html 之类不影响结果的尾斜杠。
func (h *Honeypot) Match(rawPath string) bool {
if !h.Enabled() {
return false
}
p := normalizePath(rawPath)
if p == "" {
return false
}
for _, f := range forbiddenPrefixes {
if p == f || strings.HasPrefix(p, f+"/") {
// 业务路由,绝不接管
return false
}
}
if _, ok := h.prefix[p]; ok {
return true
}
// .php 变体:清单里写的是 /phpinfo,而扫描器打的是 /phpinfo.php。
// 段前缀匹配按 '/' 与 '.' 做边界,但 /phpinfo.php 的 '.' 后面直接是 "php",
// 段前缀拼出来的 /phpinfo.php 本身不在集合里,于是会漏掉 ——
// 漏掉的表现是 302 回首页,等于明确告诉对方「这个文件不存在」,
// 而真跑着 PHP 的站点会回 200 或 403。
if strings.HasSuffix(p, ".php") {
if _, ok := h.prefix[strings.TrimSuffix(p, ".php")]; ok {
return true
}
}
// 段前缀:/wp-admin/install.php 命中 /wp-admin
segs := strings.Split(p, "/")
for i := 1; i < len(segs); i++ {
joined := strings.Join(segs[:i+1], "/")
if h.prefix[joined] {
return true
}
}
for _, f := range honeypotFragments {
if strings.Contains(p, f) {
return true
}
}
// 后缀匹配:备份/配置/密钥类泄露扫描(/backup.zip、/db.sql、/id_rsa.pem)。
base := path.Base(p)
for _, s := range honeypotSuffixes {
if strings.HasSuffix(base, s) {
return true
}
}
return false
}
func normalizePath(raw string) string {
p := raw
// 去查询串与 fragment(畸形请求可能没有 '?',用 Cut 兜底)
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
if p == "" {
return ""
}
// 反解码一次;失败(非法百分号编码)则用原串继续。
if dec, err := url.PathUnescape(p); err == nil && dec != "" {
p = dec
}
p = strings.ReplaceAll(p, "\\", "/")
return normalize(p)
}
// Credentials 是从一次诱饵提交中提取出的破解信息。
type Credentials struct {
Username string
Password string
Fields map[string]string
RawBody string
HadFields bool
}
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
//
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
var credentialKeys = map[string]bool{
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
"password": true, "passwd": true, "pw": true, "email": true,
"account": true, "login": true, "wp_user": true, "user_login": true,
"wp_password": true, "uname": true, "nickname": true, "token": true,
// 其它家族的表单字段名(见 family.go):
"pma_username": true, "pma_password": true, // phpMyAdmin
"j_username": true, "j_password": true, // Jenkins / Tomcat
"luci_username": true, "luci_password": true, // OpenWrt LuCI
"user[login]": true, "user[password]": true, // GitLab
}
// ExtractCredentials 从请求中提取破解凭据。
//
// 同时支持三类来源,按优先级合并:
// 1. URL 查询串 / POST 表单字段(application/x-www-form-urlencoded、multipart);
// 2. JSON body({"username":"admin","password":"123456"});
// 3. Basic Auth 头。
//
// 永不 panic、永不因解析失败阻断响应。
func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
out := Credentials{Fields: map[string]string{}}
limit := maxCredentialFieldLen
if h != nil && h.maxField > 0 {
limit = h.maxField
}
put := func(k, v string) {
k = strings.ToLower(strings.TrimSpace(k))
v = strings.TrimSpace(v)
if k == "" {
return
}
if len(v) > limit {
v = clipRunes(v, limit)
}
if _, exists := out.Fields[k]; !exists {
out.Fields[k] = v
}
// 注意:token 只算「凭据字段」(用于判定 HadFields),不算密码。
// phpMyAdmin / GitLab 的登录表单会带一个 hidden CSRF token,
// 把它当密码会让真正的 pma_password 被随机覆盖
// (map 遍历无固定顺序,"密码"会时对时错)。
switch k {
case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname",
"pma_username", "j_username", "luci_username", "user[login]":
if out.Username == "" {
out.Username = v
}
case "pwd", "password", "passwd", "pw", "pass", "wp_password",
"pma_password", "j_password", "luci_password", "user[password]":
if out.Password == "" {
out.Password = v
}
}
}
// 1. 查询串
if r != nil && r.URL != nil {
for k, vs := range r.URL.Query() {
if len(vs) > 0 {
put(k, vs[0])
}
}
}
if r == nil || r.Body == nil {
out.finish()
return out
}
if r.Body != nil {
defer func() { _ = r.Body.Close() }()
}
// 全量读取(bodylimit 中间件已对非 GET 请求施加上限,这里再做一层防御)
buf := make([]byte, 0, 1024)
tmp := make([]byte, 4096)
reader := r.Body
for {
n, err := reader.Read(tmp)
if n > 0 {
if len(buf)+n > limit*8 {
buf = append(buf, tmp[:n]...)
break
}
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}
raw := string(buf)
if len(raw) > limit {
raw = clipRunes(raw, limit)
}
out.RawBody = raw
ct := strings.ToLower(r.Header.Get("Content-Type"))
switch {
case strings.Contains(ct, "json"):
parseJSONCredentials(raw, put)
default:
// ParseQuery 遇到非法百分号编码会返回 error,但仍会给出已成功解析的部分字段。
// 蜜罐要的是「尽可能多拿到凭据」,故忽略 error 继续用部分结果。
vals, _ := url.ParseQuery(raw)
if len(vals) > 0 {
for k, vs := range vals {
if len(vs) > 0 {
put(k, vs[0])
}
}
} else {
// 完全不像表单时再按 JSON 试一次。
parseJSONCredentials(raw, put)
}
}
// 3. Basic Auth
if user, pass, ok := r.BasicAuth(); ok {
put("username", user)
put("password", pass)
}
out.finish()
return out
}
// finish 依据 Fields 补齐 Username/Password,并置 HadFields。
func (c *Credentials) finish() {
for k, v := range c.Fields {
if credentialKeys[k] && v != "" {
c.HadFields = true
}
}
if c.Username == "" || c.Password == "" {
for k, v := range c.Fields {
if v == "" {
continue
}
if c.Username == "" && !isPasswordKey(k) {
if credentialKeys[k] {
c.Username = v
}
}
if c.Password == "" && isPasswordKey(k) {
c.Password = v
}
}
}
if c.Username != "" || c.Password != "" {
c.HadFields = true
}
}
func isPasswordKey(k string) bool {
switch k {
case "pwd", "password", "passwd", "pw", "pass", "wp_password",
"pma_password", "j_password", "luci_password", "user[password]":
return true
}
return false
}
// jsonPairRe 匹配 JSON 扁平字段:"key": value(value 可为字符串/数字/布尔/null)。
// 只取第一层深度已足够覆盖 {"username":"admin","password":123456} 这类破解 payload;
// 嵌套对象/数组的内部内容不作为凭据,避免把无关结构误记成密码。
var jsonPairRe = regexp.MustCompile(`"([A-Za-z0-9_.\-]{1,64})"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)`)
// parseJSONCredentials 极简 JSON 字段提取,容忍不规范 body,永不 panic。
func parseJSONCredentials(raw string, put func(k, v string)) {
raw = strings.TrimSpace(raw)
if raw == "" || (raw[0] != '{' && raw[0] != '[') {
return
}
for _, m := range jsonPairRe.FindAllStringSubmatch(raw, -1) {
k := strings.TrimSpace(m[1])
v := strings.TrimSpace(m[2])
if len(v) >= 2 && v[0] == '"' {
if s, err := strconv.Unquote(v); err == nil {
v = s
} else {
v = strings.Trim(v, `"`)
}
}
if v == "null" {
v = ""
}
put(k, v)
}
}
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
//
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
// 调用方自己决定怎么展示。
func (h *Honeypot) Prefixes() []string {
if h == nil || !h.enabled {
return nil
}
out := make([]string, 0, len(h.prefix))
for k := range h.prefix {
out = append(out, k)
}
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
// 排序依赖渗透进整个包的 import 列表。
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}