线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装: 一、/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 分配。
297 lines
11 KiB
Go
297 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|