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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user