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

235 lines
7.9 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
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
func TestMatch(t *testing.T) {
h := New(Options{Enabled: true})
hit := []string{
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
"/wp-admin/../wp-content/x.php",
// 后缀匹配:备份/配置/密钥泄露扫描
"/backup.zip", "/www.zip", "/site.tar.gz", "/db.sql", "/dump.sql",
"/x.bak", "/config.php.bak", "/.env.bak", "/id_rsa.pem", "/private.key",
"/server.jks", "/data.ovpn",
}
for _, p := range hit {
if !h.Match(p) {
t.Errorf("Match(%q) = false, want true", p)
}
}
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
miss := []string{
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
"/explore", "/i18n/en",
// 业务 URL 空间里不存在这些后缀
"/_/admin.ini", "/api/v1/questions.json", "/user/settings.yaml",
}
for _, p := range miss {
if h.Match(p) {
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
}
}
}
func TestMatchDisabled(t *testing.T) {
h := New(Options{Enabled: false})
if h.Match("/wp-admin") {
t.Error("蜜罐未启用时不应匹配任何路径")
}
}
func TestMatchEncoded(t *testing.T) {
h := New(Options{Enabled: true})
// %2f 编码绕过
if !h.Match("/wp-admin%2f..%2fetc") {
t.Error("编码后的 wp-admin 路径应被匹配")
}
// 大小写
if !h.Match("/WP-ADMIN") {
t.Error("大小写不同的 WP-ADMIN 应被匹配")
}
// 查询串不应影响判定
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
t.Error("带查询串的 wp-login.php 应被匹配")
}
}
func TestExtraPaths(t *testing.T) {
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
if !h.Match("/secret-backdoor") {
t.Error("自定义诱饵路径应被匹配")
}
if !h.Match("/secret-backdoor/x.php") {
t.Error("自定义诱饵路径的子路径应被匹配")
}
}
func TestExtractCredentialsForm(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin", creds.Username)
}
if creds.Password != "admin123" {
t.Errorf("Password = %q, want admin123", creds.Password)
}
if !creds.HadFields {
t.Error("HadFields = false, want true")
}
}
func TestExtractCredentialsJSON(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
req.Header.Set("Content-Type", "application/json")
creds := h.ExtractCredentials(req)
if creds.Username != "root" {
t.Errorf("Username = %q, want root", creds.Username)
}
if creds.Password != "toor" {
t.Errorf("Password = %q, want toor", creds.Password)
}
}
func TestExtractCredentialsQuery(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "secret" {
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
}
}
func TestExtractCredentialsBasicAuth(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
req.SetBasicAuth("admin", "hunter2")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" || creds.Password != "hunter2" {
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
}
}
func TestExtractCredentialsEmpty(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
creds := h.ExtractCredentials(req)
if creds.HadFields {
t.Error("空请求不应标记为携带凭据")
}
}
func TestExtractCredentialsMalformed(t *testing.T) {
h := New(Options{Enabled: true})
// 畸形 body 不应 panic
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
strings.NewReader("%zz&log=admin&&=="))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
creds := h.ExtractCredentials(req)
if creds.Username != "admin" {
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
}
}
// TestClipRunesKeepsValidUTF8 守住「按字节截断」的回归。
//
// 凭据是直接落库的,非法 UTF-8 会让 PostgreSQL 以
// `invalid byte sequence for encoding "UTF8"` 拒绝整条插入 —— 取证记录静默丢失。
// 攻击者只要发一个超长中文密码就能稳定触发,所以这是可达路径。
func TestClipRunesKeepsValidUTF8(t *testing.T) {
s := strings.Repeat("密码", 400) // 2400 字节 / 800 字符
got := clipRunes(s, 512)
if !utf8.ValidString(got) {
t.Fatal("截断结果不是合法 UTF-8(说明退回了字节截断)")
}
if n := len([]rune(got)); n != 512 {
t.Errorf("截断后字符数 = %d, want 512", n)
}
// 确认这个用例真的能区分两种实现:字节截断在 512 处必然切在字符中间。
if utf8.ValidString(s[:512]) {
t.Fatal("测试素材本身能被字节安全截断,用例失去区分力")
}
}
// TestExtractCredentialsIgnoresCommonJSONFields 守住误报回归。
//
// "id" / "name" 是任意 JSON 里最常见的字段。把它们当凭据会让
// 「扫描器 POST 了一个无关探测」被记成破解提交,并触发假的告警邮件。
func TestExtractCredentialsIgnoresCommonJSONFields(t *testing.T) {
h := New(Options{Enabled: true})
req := httptest.NewRequest(http.MethodPost, "/api/v1/thing",
strings.NewReader(`{"id":1,"name":"probe","status":"ok"}`))
req.Header.Set("Content-Type", "application/json")
creds := h.ExtractCredentials(req)
if creds.HadFields {
t.Errorf("普通 JSON 探测不应被判定为凭据提交(Username=%q Password=%q)",
creds.Username, creds.Password)
}
}
// TestSplitPaths 覆盖自定义诱饵路径的解析与归一化。
func TestSplitPaths(t *testing.T) {
raw := " /admin, foo;bar\n/baz "
got := SplitPaths(raw)
want := []string{"/admin", "/foo", "/bar", "/baz"}
if len(got) != len(want) {
t.Fatalf("SplitPaths() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
}
}
// 未写前导斜杠的项也应在 New 里被归一化,否则永远匹配不上。
h := New(Options{Enabled: true, ExtraPaths: SplitPaths("foo")})
if !h.Match("/foo/bar") {
t.Error("自定义路径 foo 归一化后应能匹配 /foo/bar")
}
// 根路径会让所有 404 都变成假登录页,必须拒绝。
if got := SplitPaths("/"); len(got) != 0 {
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
}
}
// TestMatchPHPVariant 守住 .php 变体:清单里是 /phpinfo,扫描器打的是 /phpinfo.php。
//
// 漏掉的后果不是「少记一条日志」,而是回 302 → /:等于明确告诉对方
// 「这个文件不存在」,而真跑着 PHP 的站点会回 200 或 403。
func TestMatchPHPVariant(t *testing.T) {
h := New(Options{Enabled: true})
for _, p := range []string{"/phpinfo.php", "/info.php", "/test.php", "/adminer.php", "/phpmyadmin/index.php"} {
if !h.Match(p) {
t.Errorf("Match(%q) = false, want true", p)
}
}
// 业务路由与普通页面不能被这条规则牵连。
for _, p := range []string{"/login", "/user/abc", "/api/v1/questions", "/question/xyz"} {
if h.Match(p) {
t.Errorf("Match(%q) = true, want false(业务路径不得被接管)", p)
}
}
}