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:
+39
-25
@@ -146,6 +146,28 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
// 但落库与假表单的 action 用的是原始值 —— 会留下脏取证数据,
|
||||
// 甚至让爆破脚本把表单提交到错误路径。这里统一清洗一次。
|
||||
path := cleanPath(req.URL.Path)
|
||||
|
||||
// 静态资源短路(必须放在最前面)。
|
||||
//
|
||||
// 真实后台登录页会加载一堆 CSS/JS/图标;这些请求如果也走后面的落库逻辑,
|
||||
// 一次页面加载就能放大出十几条 INSERT,等于帮攻击者把写入成本转嫁给我们。
|
||||
// 这里只回占位内容:不解析 IP、不判定封禁、不读 body、不记录 ——
|
||||
// 已封禁的来源在中间件里就被挡掉了,轮不到这里。
|
||||
if body, ctype, status, isAsset := honeypot.AssetFor(path); isAsset {
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
|
||||
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
|
||||
fam := honeypot.FamilyOf(path)
|
||||
|
||||
// 诊断:清洗过说明上游注入了空白/零宽字符。用 %q 打出来 ——
|
||||
// 这类字符肉眼不可见,裸打印会看不出哪里不同(曾经为此排查了一轮)。
|
||||
if raw := req.URL.Path; raw != path {
|
||||
@@ -193,9 +215,10 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
h.serveLoginPage(ctx, path)
|
||||
h.writeLogin(ctx, fam, path, false)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||
h.serveLoginFailure(ctx, path)
|
||||
// 提交后仍返回该家族的登录页,带自己的失败文案(Tomcat 家族则是 401)。
|
||||
h.writeLogin(ctx, fam, path, true)
|
||||
default:
|
||||
// 其余方法(DELETE / OPTIONS / TRACE / CONNECT)返回空 200,模拟后端存在。
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
@@ -246,31 +269,22 @@ func isLoopbackIP(ip string) bool {
|
||||
return strings.HasPrefix(host, "127.")
|
||||
}
|
||||
|
||||
// serveLoginPage 返回假登录页。
|
||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||
h.writeLogin(ctx, path, "")
|
||||
}
|
||||
|
||||
// serveLoginFailure 返回「登录失败」的假响应。
|
||||
// writeLogin 是假登录响应的唯一出口。
|
||||
//
|
||||
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
||||
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string) {
|
||||
h.writeLogin(ctx, path, honeypot.LoginFailureNotice)
|
||||
}
|
||||
|
||||
// writeLogin 是两种响应形态的唯一出口。
|
||||
// 按家族渲染(WordPress / phpMyAdmin / Jenkins / Tomcat …),families 见
|
||||
// honeypot.FamilyOf:扫描器会按路径先判断「这是什么后台」,再拿它认识的
|
||||
// 字段名去填;所有路径都回 WordPress 页面等于告诉对方「这里没有你要找的东西」。
|
||||
//
|
||||
// 之前 serveLoginFailure 手抄了一遍 WriteLoginPage 的响应头设置(Content-Type /
|
||||
// Cache-Control / X-Robots-Tag),已经开始漂移(标题一处写字面量 `‹`、
|
||||
// 一处写 `\u2039`)。统一走 page.go,改响应头只需改一处。
|
||||
func (h *honeypotHandler) writeLogin(ctx flamego.Context, path, notice string) {
|
||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: honeypot.LoginPageTitle,
|
||||
Heading: "WordPress",
|
||||
Notice: notice,
|
||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||
// failed 表示这是提交后的响应,需要显示该家族自己的失败文案
|
||||
// (不区分「用户不存在」与「密码错误」,避免给爆破脚本用户名枚举信号)。
|
||||
//
|
||||
// 之前这里手抄过一遍响应头,已经漂移(标题一处字面量 `‹`、一处 `\u2039`);
|
||||
// 现在响应头也归 page.go 统一设置,改一处即可。
|
||||
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
|
||||
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Failed: failed,
|
||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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