// 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 } // 段前缀:/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 }