原来所有诱饵路径都回同一个 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 → 页面缺特征字段。两项均按预期变红。
182 lines
6.8 KiB
Go
182 lines
6.8 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")
|
|
}
|
|
}
|