Files
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

297 lines
11 KiB
Go
Raw Permalink 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 (
"bytes"
"net/http/httptest"
"strings"
"testing"
)
func TestFamilyOf(t *testing.T) {
cases := []struct {
path string
want Family
}{
{"/wp-login.php", FamilyWordPress},
{"/wp-admin/setup-config.php", FamilyWordPress},
{"/wp-json/wp/v2/users", FamilyWordPress},
{"/phpmyadmin/index.php", FamilyPhpMyAdmin},
{"/pma", FamilyPhpMyAdmin},
{"/adminer.php", FamilyPhpMyAdmin},
{"/manager/html", FamilyTomcat},
{"/host-manager/html", FamilyTomcat},
{"/jenkins/login", FamilyJenkins},
{"/grafana/login", FamilyGrafana},
{"/users/sign_in", FamilyGitLab},
{"/cgi-bin/luci", FamilyLuCI},
{"/administrator/index.php", FamilyJoomla},
{"/admin", FamilyGeneric},
{"/console/login", FamilyGeneric},
{"/zabbix/index.php", FamilyGeneric},
}
for _, c := range cases {
if got := FamilyOf(c.path); got != c.want {
t.Errorf("FamilyOf(%q) = %v, want %v", c.path, got, c.want)
}
}
}
// TestFamilyOfDoesNotClaimBusinessRoutes 保证分类不会把真实业务路径认成某个后台。
// (是否接管由 Match + forbiddenPrefixes 决定,这里是第二道防线:即便将来
// 诱饵表被改宽,也不该让 /user 被归进 GitLab 家族。)
func TestFamilyOfDoesNotClaimBusinessRoutes(t *testing.T) {
for _, p := range []string{
"/", "/login", "/register", "/user", "/api/v1/questions",
"/explore", "/_/tamakyi", "/vote/abc", "/static/css/base.css",
} {
if fam := FamilyOf(p); fam != FamilyGeneric {
t.Errorf("FamilyOf(%q) = %v, want FamilyGeneric(业务路径不该被归类)", p, fam)
}
}
}
// TestEveryFamilyRendersOwnSkin 逐个家族渲染,断言页面上出现该家族特有的字段名。
//
// 为什么盯字段名:爆破脚本靠字段名决定要不要提交。所有家族都输出 log/pwd,
// 等于对 phpMyAdmin 的爆破工具说「这里不是 PMA」,对方会直接放弃。
func TestEveryFamilyRendersOwnSkin(t *testing.T) {
uniqueField := map[Family]string{
FamilyWordPress: `name="log"`,
FamilyPhpMyAdmin: `name="pma_username"`,
FamilyJenkins: `name="j_username"`,
FamilyGrafana: `name="user"`,
FamilyGitLab: `name="user[login]"`,
FamilyLuCI: `name="luci_username"`,
FamilyJoomla: `name="username"`,
FamilyGeneric: `name="csrf_token"`,
}
for _, fam := range Families() {
body := string(RenderFamily(fam, LoginPageVars{Action: "/probe"}))
if !strings.Contains(body, CanaryToken) {
t.Errorf("%v: 页面缺少 canary", fam)
}
if !strings.Contains(body, `action="/probe"`) {
t.Errorf("%v: 表单 action 未回填请求路径", fam)
}
if want, ok := uniqueField[fam]; ok && !strings.Contains(body, want) {
t.Errorf("%v: 页面缺少特征字段 %s", fam, want)
}
// 每个家族都必须有密码框,否则爆破脚本不会提交。
if !strings.Contains(body, `type="password"`) && fam != FamilyTomcat {
t.Errorf("%v: 页面没有密码输入框", fam)
}
// generator 必须伪装成真实产品,绝不能出现蜜罐标记(扫描器必查项)。
if strings.Contains(body, "TamaBox") || strings.Contains(body, "honeypot") {
t.Errorf("%v: 页面自曝了蜜罐身份", fam)
}
}
}
// TestTomcatFamilyUsesBasicAuth 验证 Tomcat 家族走的是 401 + WWW-Authenticate。
//
// 真实 Tomcat Manager 未认证时就是 401 Basic,给一个 HTML 表单反而不像;
// 而且 Basic 头里的凭据 ExtractCredentials 能直接解析,不损失捕获能力。
func TestTomcatFamilyUsesBasicAuth(t *testing.T) {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyTomcat, LoginPageVars{Action: "/manager/html"})
if rec.Code != 401 {
t.Errorf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Tomcat") {
t.Errorf("WWW-Authenticate = %q, want 含 Tomcat realm", got)
}
}
// TestFamilyHeadersLookReal 检查各家族的指纹响应头。
// 这些头是扫描器的第一判断依据,比页面内容更早被读到。
func TestFamilyHeadersLookReal(t *testing.T) {
cases := []struct {
fam Family
header string
wantSub string
}{
{FamilyWordPress, "X-Powered-By", "PHP"},
{FamilyPhpMyAdmin, "X-Powered-By", "PHP"},
{FamilyJenkins, "X-Jenkins", "2.401"},
{FamilyGrafana, "X-Grafana-Org-Id", "1"},
}
for _, c := range cases {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, c.fam, LoginPageVars{Action: "/x"})
if got := rec.Header().Get(c.header); !strings.Contains(got, c.wantSub) {
t.Errorf("%v: %s = %q, want 含 %q", c.fam, c.header, got, c.wantSub)
}
}
// WordPress 真实登录页会下发 test cookie,爆破工具常拿它判断会话是否有效。
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
if !strings.Contains(strings.Join(rec.Header().Values("Set-Cookie"), ";"), "wordpress_test_cookie") {
t.Error("WordPress 家族缺少 wordpress_test_cookie")
}
}
func TestAssetFor(t *testing.T) {
if body, ctype, status, ok := AssetFor("/wp-admin/css/login.min.css"); !ok || status != 200 ||
!strings.Contains(ctype, "text/css") || len(body) == 0 {
t.Errorf("css 资源未命中:ok=%v status=%d ctype=%q", ok, status, ctype)
}
if _, ctype, _, ok := AssetFor("/wp-includes/js/jquery/jquery.min.js"); !ok || !strings.Contains(ctype, "javascript") {
t.Errorf("js 资源 ctype = %q", ctype)
}
if body, _, _, ok := AssetFor("/wp-admin/images/logo.png"); !ok || !bytes.HasPrefix(body, []byte{0x89, 'P', 'N', 'G'}) {
t.Error("png 资源内容不是合法 PNG")
}
// 插件目录下的 php 是 webshell 探测,回 404 比回登录页更真实
if _, _, status, ok := AssetFor("/wp-content/plugins/x.php"); !ok || status != 404 {
t.Errorf("未知后缀 status = %d, want 404", status)
}
// 不在资源前缀内的一律交回登录页逻辑
for _, p := range []string{"/admin", "/wp-login.php", "/static/css/base.css", "/assets/app.js"} {
if _, _, _, ok := AssetFor(p); ok {
t.Errorf("%q 不该被当成页面资源", p)
}
}
}
// TestRenderIsCheap 粗略守住「单页体积」,防止有人往 skin 里塞大段样式/脚本。
func TestRenderIsCheap(t *testing.T) {
for _, fam := range Families() {
if n := len(RenderFamily(fam, LoginPageVars{Action: "/x"})); n > 4096 {
t.Errorf("%v: 页面体积 %d 字节,超过 4KB 预算", fam, n)
}
}
}
// BenchmarkRenderFamily 量化「渲染一个伪装页」的成本。
// 蜜罐会被扫描器高频命中,这里刻意守住:单页只做一次 Builder 拼接。
func BenchmarkRenderFamily(b *testing.B) {
vars := LoginPageVars{Action: "/wp-login.php", Redir: "/wp-admin/"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = RenderFamily(FamilyWordPress, vars)
}
}
// BenchmarkAssetFor 资源请求必须零分配:一次页面加载会打 5–10 个资源请求。
func BenchmarkAssetFor(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
}
}
// hiddenValue 取某个 hidden input 的 value。
func hiddenValue(body, name string) string {
marker := `name="` + name + `" value="`
i := strings.Index(body, marker)
if i < 0 {
return ""
}
rest := body[i+len(marker):]
if j := strings.IndexByte(rest, '"'); j >= 0 {
return rest[:j]
}
return ""
}
// TestDynamicTokensDifferPerRequest 守住「每次请求的 token 必须不同」。
//
// 恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 CSRF token,
// 任何脚本都能判定对面是伪造的 —— 而这正是「伪装得像」的核心。
func TestDynamicTokensDifferPerRequest(t *testing.T) {
cases := []struct {
fam Family
name string
size int
}{
{FamilyPhpMyAdmin, "token", 32},
{FamilyGeneric, "csrf_token", 16},
}
for _, c := range cases {
first := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
second := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
v1, v2 := hiddenValue(first, c.name), hiddenValue(second, c.name)
if v1 == "" || v2 == "" {
t.Fatalf("%s: 取不到 %s 的值", c.fam, c.name)
}
if v1 == v2 {
t.Errorf("%s 的 %s 两次相同(%q)—— 恒定值可被指纹识别", c.fam, c.name, v1)
}
for _, v := range []string{v1, v2} {
if len(v) != c.size {
t.Errorf("%s 的 %s 长度 = %d, want %d(%q)", c.fam, c.name, len(v), c.size, v)
}
if strings.Trim(v, "0123456789abcdef") != "" {
t.Errorf("%s 的 %s = %q, 含非 hex 字符(真实产品只给 hex)", c.fam, c.name, v)
}
}
}
}
// TestHeadersDynamicValues 守住响应头里的动态值:Jenkins 会话号、GitLab 关联 id。
func TestHeadersDynamicValues(t *testing.T) {
got := func(f Family, header string) string {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, f, LoginPageVars{Action: "/x"})
return rec.Header().Get(header)
}
s1, s2 := got(FamilyJenkins, "X-Jenkins-Session"), got(FamilyJenkins, "X-Jenkins-Session")
if len(s1) != 8 || s1 == s2 {
t.Errorf("X-Jenkins-Session = %q / %q, want 两个不同的 8 位 hex", s1, s2)
}
g1, g2 := got(FamilyGitLab, "X-Gitlab-Meta"), got(FamilyGitLab, "X-Gitlab-Meta")
if g1 == g2 || !strings.Contains(g1, `"correlation_id":"`) {
t.Errorf("X-Gitlab-Meta = %q / %q, want 每次不同的 correlation_id", g1, g2)
}
// 会话 cookie 同样不能恒定。
if c1, c2 := got(FamilyGitLab, "Set-Cookie"), got(FamilyGitLab, "Set-Cookie"); c1 == c2 {
t.Errorf("Set-Cookie = %q 两次相同", c1)
}
}
// TestPingbackAbsoluteURL 守住 X-Pingback 必须是绝对地址:
// 真实 WordPress 给的是 "https://host/xmlrpc.php",相对路径本身就是破绽。
// 没配置 external_url 时宁可不发这个头。
func TestPingbackAbsoluteURL(t *testing.T) {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{
Action: "/wp-login.php",
BaseURL: "https://box.test/",
})
if got := rec.Header().Get("X-Pingback"); got != "https://box.test/xmlrpc.php" {
t.Errorf("X-Pingback = %q, want 绝对地址(尾部斜杠要折叠)", got)
}
rec = httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
if got := rec.Header().Get("X-Pingback"); got != "" {
t.Errorf("X-Pingback = %q, want 不发(相对路径比不发更假)", got)
}
}
// TestNoUnresolvedPlaceholders 守住渲染后不残留 $ 占位符 ——
// 漏展开会在页面上留下 "$hex32" 这种一眼假的值。
func TestNoUnresolvedPlaceholders(t *testing.T) {
for _, fam := range Families() {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, fam, LoginPageVars{
Action: "/x",
Redir: "/wp-admin/",
BaseURL: "https://box.test",
})
body := rec.Body.String()
if strings.Contains(body, "$hex") || strings.Contains(body, "$base") {
t.Errorf("%s 页面残留占位符:%s", fam, body)
}
for k, vs := range rec.Header() {
for _, v := range vs {
if strings.Contains(v, "$hex") || strings.Contains(v, "$base") {
t.Errorf("%s 响应头 %s 残留占位符:%q", fam, k, v)
}
}
}
}
}