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 → 页面缺特征字段。两项均按预期变红。
This commit is contained in:
@@ -507,3 +507,140 @@ func (c *captureStore) LegacyCredentialColumns(context.Context) ([]string, error
|
||||
func (c *captureStore) PurgeLegacyCredentialColumns(context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestHoneypotServesFamilySpecificPage 验证按路径分发到不同后台的伪装。
|
||||
//
|
||||
// 扫描器先按路径判断「这是什么后台」,再拿对应字段名去填表;
|
||||
// 所有路径都回 WordPress 页面等于告诉对方「这里没有 PMA/Jenkins」。
|
||||
func TestHoneypotServesFamilySpecificPage(t *testing.T) {
|
||||
ts := newHoneypotTestServerFull(t, true, false, nil)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/wp-login.php", `name="log"`},
|
||||
{"/phpmyadmin/index.php", `name="pma_username"`},
|
||||
{"/jenkins/login", `name="j_username"`},
|
||||
{"/cgi-bin/luci", `name="luci_username"`},
|
||||
{"/users/sign_in", `name="user[login]"`},
|
||||
{"/administrator/index.php", `name="passwd"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
resp, err := http.Get(ts.URL + c.path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", c.path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200", c.path, resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), c.want) {
|
||||
t.Errorf("GET %s 页面缺少特征字段 %s", c.path, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tomcat Manager 真实未认证行为是 401 + Basic,给 HTML 表单反而不像。
|
||||
tomcat, err := http.Get(ts.URL + "/manager/html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tomcat.Body.Close()
|
||||
if tomcat.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("GET /manager/html status = %d, want 401", tomcat.StatusCode)
|
||||
}
|
||||
if !strings.Contains(tomcat.Header.Get("WWW-Authenticate"), "Tomcat") {
|
||||
t.Errorf("WWW-Authenticate = %q, want 含 Tomcat", tomcat.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotCapturesFamilyFields 验证各家族的字段名都能被提取成凭据。
|
||||
//
|
||||
// 页面换了字段名但提取逻辑没跟上,等于做了个好看的空壳。
|
||||
func TestHoneypotCapturesFamilyFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
path, body, wantUser, wantPass string
|
||||
}{
|
||||
{"/phpmyadmin/index.php", "pma_username=root&pma_password=toor123", "root", "toor123"},
|
||||
{"/jenkins/login", "j_username=admin&j_password=jenkins1", "admin", "jenkins1"},
|
||||
{"/cgi-bin/luci", "luci_username=root&luci_password=openwrt", "root", "openwrt"},
|
||||
{"/users/sign_in", "user%5Blogin%5D=gitlab&user%5Bpassword%5D=glpass", "gitlab", "glpass"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cs, err := honeypot.NewFileCredStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := newHoneypotTestServerFull(t, true, false, cs)
|
||||
|
||||
resp, err := http.Post(ts.URL+c.path, "application/x-www-form-urlencoded", strings.NewReader(c.body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", c.path, err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("%s: 凭据记录数 = %d, want 1", c.path, len(recs))
|
||||
}
|
||||
if recs[0].Username != c.wantUser || recs[0].Password != c.wantPass {
|
||||
t.Errorf("%s: 提取到 %q/%q, want %q/%q",
|
||||
c.path, recs[0].Username, recs[0].Password, c.wantUser, c.wantPass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotAssetRequestsAreNotRecorded 验证静态资源请求只回占位内容、不落库。
|
||||
//
|
||||
// 真实后台页面会加载 5–10 个 CSS/JS/图标;如果每个都 INSERT,一次页面加载就能
|
||||
// 放大出十几条记录,而 honeypot_hits 的写入完全由外部流量驱动 ——
|
||||
// 等于把攻击者的写入成本转嫁给我们。
|
||||
func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
|
||||
store := &captureStore{}
|
||||
f := flamego.New()
|
||||
hp := honeypot.New(honeypot.Options{Enabled: true})
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: true, HoneypotEnabled: true,
|
||||
ThresholdSensitive: 10, Window: time.Minute, Duration: time.Hour,
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, store, nil)
|
||||
f.Use(autobanMiddleware(ab, hp))
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
if hp.Match(ctx.Request().URL.Path) {
|
||||
hph.Serve(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
})
|
||||
ts := httptest.NewServer(f)
|
||||
defer ts.Close()
|
||||
|
||||
for _, p := range []string{
|
||||
"/wp-admin/css/login.min.css",
|
||||
"/wp-includes/js/wp-embed.min.js",
|
||||
"/wp-admin/images/wordpress-logo.png",
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + p)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", p, err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200", p, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct == "" || strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("GET %s Content-Type = %q, want 非 HTML 的静态类型", p, ct)
|
||||
}
|
||||
}
|
||||
|
||||
if len(store.hits) != 0 {
|
||||
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user