线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装: 一、/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 分配。
235 lines
7.9 KiB
Go
235 lines
7.9 KiB
Go
package honeypot
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
func TestMatch(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
|
||
hit := []string{
|
||
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
|
||
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
|
||
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
|
||
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
|
||
"/wp-admin/../wp-content/x.php",
|
||
// 后缀匹配:备份/配置/密钥泄露扫描
|
||
"/backup.zip", "/www.zip", "/site.tar.gz", "/db.sql", "/dump.sql",
|
||
"/x.bak", "/config.php.bak", "/.env.bak", "/id_rsa.pem", "/private.key",
|
||
"/server.jks", "/data.ovpn",
|
||
}
|
||
for _, p := range hit {
|
||
if !h.Match(p) {
|
||
t.Errorf("Match(%q) = false, want true", p)
|
||
}
|
||
}
|
||
|
||
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
|
||
miss := []string{
|
||
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
|
||
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
|
||
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
|
||
"/explore", "/i18n/en",
|
||
// 业务 URL 空间里不存在这些后缀
|
||
"/_/admin.ini", "/api/v1/questions.json", "/user/settings.yaml",
|
||
}
|
||
for _, p := range miss {
|
||
if h.Match(p) {
|
||
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMatchDisabled(t *testing.T) {
|
||
h := New(Options{Enabled: false})
|
||
if h.Match("/wp-admin") {
|
||
t.Error("蜜罐未启用时不应匹配任何路径")
|
||
}
|
||
}
|
||
|
||
func TestMatchEncoded(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
// %2f 编码绕过
|
||
if !h.Match("/wp-admin%2f..%2fetc") {
|
||
t.Error("编码后的 wp-admin 路径应被匹配")
|
||
}
|
||
// 大小写
|
||
if !h.Match("/WP-ADMIN") {
|
||
t.Error("大小写不同的 WP-ADMIN 应被匹配")
|
||
}
|
||
// 查询串不应影响判定
|
||
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
|
||
t.Error("带查询串的 wp-login.php 应被匹配")
|
||
}
|
||
}
|
||
|
||
func TestExtraPaths(t *testing.T) {
|
||
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
|
||
if !h.Match("/secret-backdoor") {
|
||
t.Error("自定义诱饵路径应被匹配")
|
||
}
|
||
if !h.Match("/secret-backdoor/x.php") {
|
||
t.Error("自定义诱饵路径的子路径应被匹配")
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsForm(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.Username != "admin" {
|
||
t.Errorf("Username = %q, want admin", creds.Username)
|
||
}
|
||
if creds.Password != "admin123" {
|
||
t.Errorf("Password = %q, want admin123", creds.Password)
|
||
}
|
||
if !creds.HadFields {
|
||
t.Error("HadFields = false, want true")
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsJSON(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.Username != "root" {
|
||
t.Errorf("Username = %q, want root", creds.Username)
|
||
}
|
||
if creds.Password != "toor" {
|
||
t.Errorf("Password = %q, want toor", creds.Password)
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsQuery(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.Username != "admin" || creds.Password != "secret" {
|
||
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsBasicAuth(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||
req.SetBasicAuth("admin", "hunter2")
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.Username != "admin" || creds.Password != "hunter2" {
|
||
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsEmpty(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.HadFields {
|
||
t.Error("空请求不应标记为携带凭据")
|
||
}
|
||
}
|
||
|
||
func TestExtractCredentialsMalformed(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
// 畸形 body 不应 panic
|
||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||
strings.NewReader("%zz&log=admin&&=="))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.Username != "admin" {
|
||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
||
}
|
||
}
|
||
|
||
// TestClipRunesKeepsValidUTF8 守住「按字节截断」的回归。
|
||
//
|
||
// 凭据是直接落库的,非法 UTF-8 会让 PostgreSQL 以
|
||
// `invalid byte sequence for encoding "UTF8"` 拒绝整条插入 —— 取证记录静默丢失。
|
||
// 攻击者只要发一个超长中文密码就能稳定触发,所以这是可达路径。
|
||
func TestClipRunesKeepsValidUTF8(t *testing.T) {
|
||
s := strings.Repeat("密码", 400) // 2400 字节 / 800 字符
|
||
got := clipRunes(s, 512)
|
||
|
||
if !utf8.ValidString(got) {
|
||
t.Fatal("截断结果不是合法 UTF-8(说明退回了字节截断)")
|
||
}
|
||
if n := len([]rune(got)); n != 512 {
|
||
t.Errorf("截断后字符数 = %d, want 512", n)
|
||
}
|
||
// 确认这个用例真的能区分两种实现:字节截断在 512 处必然切在字符中间。
|
||
if utf8.ValidString(s[:512]) {
|
||
t.Fatal("测试素材本身能被字节安全截断,用例失去区分力")
|
||
}
|
||
}
|
||
|
||
// TestExtractCredentialsIgnoresCommonJSONFields 守住误报回归。
|
||
//
|
||
// "id" / "name" 是任意 JSON 里最常见的字段。把它们当凭据会让
|
||
// 「扫描器 POST 了一个无关探测」被记成破解提交,并触发假的告警邮件。
|
||
func TestExtractCredentialsIgnoresCommonJSONFields(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/thing",
|
||
strings.NewReader(`{"id":1,"name":"probe","status":"ok"}`))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
creds := h.ExtractCredentials(req)
|
||
if creds.HadFields {
|
||
t.Errorf("普通 JSON 探测不应被判定为凭据提交(Username=%q Password=%q)",
|
||
creds.Username, creds.Password)
|
||
}
|
||
}
|
||
|
||
// TestSplitPaths 覆盖自定义诱饵路径的解析与归一化。
|
||
func TestSplitPaths(t *testing.T) {
|
||
raw := " /admin, foo;bar\n/baz "
|
||
got := SplitPaths(raw)
|
||
want := []string{"/admin", "/foo", "/bar", "/baz"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("SplitPaths() = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
|
||
}
|
||
}
|
||
|
||
// 未写前导斜杠的项也应在 New 里被归一化,否则永远匹配不上。
|
||
h := New(Options{Enabled: true, ExtraPaths: SplitPaths("foo")})
|
||
if !h.Match("/foo/bar") {
|
||
t.Error("自定义路径 foo 归一化后应能匹配 /foo/bar")
|
||
}
|
||
|
||
// 根路径会让所有 404 都变成假登录页,必须拒绝。
|
||
if got := SplitPaths("/"); len(got) != 0 {
|
||
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
|
||
}
|
||
}
|
||
|
||
// TestMatchPHPVariant 守住 .php 变体:清单里是 /phpinfo,扫描器打的是 /phpinfo.php。
|
||
//
|
||
// 漏掉的后果不是「少记一条日志」,而是回 302 → /:等于明确告诉对方
|
||
// 「这个文件不存在」,而真跑着 PHP 的站点会回 200 或 403。
|
||
func TestMatchPHPVariant(t *testing.T) {
|
||
h := New(Options{Enabled: true})
|
||
for _, p := range []string{"/phpinfo.php", "/info.php", "/test.php", "/adminer.php", "/phpmyadmin/index.php"} {
|
||
if !h.Match(p) {
|
||
t.Errorf("Match(%q) = false, want true", p)
|
||
}
|
||
}
|
||
// 业务路由与普通页面不能被这条规则牵连。
|
||
for _, p := range []string{"/login", "/user/abc", "/api/v1/questions", "/question/xyz"} {
|
||
if h.Match(p) {
|
||
t.Errorf("Match(%q) = true, want false(业务路径不得被接管)", p)
|
||
}
|
||
}
|
||
}
|