Files
tamabox/internal/security/honeypot/honeypot.go
T
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00

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