Compare commits
2
Commits
7225f680d4
...
9d3f5a72fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3f5a72fa | ||
|
|
307bd94ed1 |
@@ -113,3 +113,43 @@ IPv6 丢掉了精确地址(IPv4 无损);邮件里也只给键,运维会
|
|||||||
**新工具** `dev/preview-honeypot`:渲染 9 个家族到 `outputs/honeypot-preview/index.html`,
|
**新工具** `dev/preview-honeypot`:渲染 9 个家族到 `outputs/honeypot-preview/index.html`,
|
||||||
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
|
配 chrome headless 截图核对外观。靠它发现两处「源码看不出、截图一眼假」:
|
||||||
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
|
Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着 404 文案。
|
||||||
|
|
||||||
|
### 测试环境验收:9 家族伪装(部署 7225f68 后,只读 GET)
|
||||||
|
|
||||||
|
全部符合预期:8 个家族 title/generator 各不相同、Tomcat 401+Basic realm、
|
||||||
|
静态资源 200 且 MIME 正确(128B css / 81B js / 70B png)、插件目录 .php 回 404、
|
||||||
|
业务路由(/login /user /static /api)无 canary、界外路径仍 302 → /。
|
||||||
|
|
||||||
|
发现的漏洞(按优先级):
|
||||||
|
1. **P1 `/xmlrpc.php` 回 HTML 登录页** —— 而我们自己发了 `X-Pingback: /xmlrpc.php`,
|
||||||
|
真实 WP 回 text/xml,等于自己指给自己一个明显假的地址;同时丢掉 WP 爆破最有价值的
|
||||||
|
捕获通道(xmlrpc system.multicall 一次可试上千组,绕开登录限速)。
|
||||||
|
2. **P2 每次请求恒定值**:PMA `token=e1a4c0b7d2f58g93`(还带非 hex 的 g)、
|
||||||
|
generic `csrf_token=a7f3c1e9b2d4`、`X-Jenkins-Session: 8b1f2c4d`。
|
||||||
|
两次请求完全相同 → 跨请求指纹即可识别。应改每请求随机。
|
||||||
|
3. **P2 `X-Pingback` 用相对路径**,真实 WP 给绝对 URL。
|
||||||
|
4. **P3 `/phpinfo.php` 漏匹配**:路径清单里是 `/phpinfo`、`/info.php` 精确项,
|
||||||
|
段前缀匹配不覆盖 `.php` 变体 → 302。可加「p 去掉 .php 后在集合内」的兜底。
|
||||||
|
5. **P3 `/wp-json/wp/v2/users` 回 HTML**,真实 WP 回 JSON(用户枚举接口)。
|
||||||
|
6. P3 `X-Frame-Options: DENY` / `Cache-Control: no-store` 与真实 WP 不同,
|
||||||
|
但属全局中间件,改动影响全站。
|
||||||
|
|
||||||
|
### 修 P1/P2:XML-RPC 端点 + 动态 token + .php 兜底
|
||||||
|
|
||||||
|
- 新增 `internal/security/honeypot/xmlrpc.go`:`/xmlrpc.php` 不再回登录页。
|
||||||
|
GET→405+XML fault「XML-RPC services are disabled」;POST methodCall→200+fault
|
||||||
|
「Incorrect username or password」;POST system.listMethods→方法列表模板。
|
||||||
|
凭据提取用正则(不上 encoding/xml,蜜罐高频命中):抓 `<string>`、剔除
|
||||||
|
methodName 与 http(s) URL、两两配对;`blog_id=1` 这种「奇数长度+首项短数字」
|
||||||
|
会丢首项纠正错位;第一组进标准字段,其余进 `cred2.*`,原始串全量进 `xmlrpc.N`
|
||||||
|
(配置上限 16 对 / raw 8KB)。
|
||||||
|
- 动态占位符:`LoginPageVars.Nonce`(32 hex,`math/rand/v2`,刻意不用 crypto/rand,
|
||||||
|
这些是假 token 只需「每次不同」)。支持 `$hex8/16/32` 与 `$base`;
|
||||||
|
页头、hidden、Set-Cookie 共用同一套 nonce。
|
||||||
|
- `X-Pingback` 改绝对地址(`$base/xmlrpc.php`),没配 `external_url` 就不发。
|
||||||
|
- `Cache-Control` 从 `no-store` 改 `no-cache, must-revalidate, max-age=0`(更像 WP/PMA)。
|
||||||
|
- Grafana/GitLab 补真实会种的会话 cookie(`grafana_sess`、`_gitlab_session`,动态)。
|
||||||
|
- `Match` 增加 `.php` 变体兜底:`/phpinfo.php` 之前漏匹配 → 302。
|
||||||
|
- 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码),可接受。
|
||||||
|
|
||||||
|
四处变异验证有效(固定 token / xmlrpc 分支失效 / Pingback 相对路径 / .php 兜底失效)。
|
||||||
|
|||||||
@@ -45,6 +45,19 @@
|
|||||||
|
|
||||||
**静态资源也一起伪装**:`/wp-admin/css/login.min.css`、`/wp-includes/js/*.js`、`/wp-admin/images/*.png` 这类请求回占位 CSS/JS/真 1×1 PNG。这些请求**不落库、不计数** —— 一次页面加载会打 5–10 个资源,逐个 INSERT 等于帮攻击者把写入放大十倍。插件目录下的 `.php` 则回 404(那是 webshell 探测,回登录页反而不像)。
|
**静态资源也一起伪装**:`/wp-admin/css/login.min.css`、`/wp-includes/js/*.js`、`/wp-admin/images/*.png` 这类请求回占位 CSS/JS/真 1×1 PNG。这些请求**不落库、不计数** —— 一次页面加载会打 5–10 个资源,逐个 INSERT 等于帮攻击者把写入放大十倍。插件目录下的 `.php` 则回 404(那是 webshell 探测,回登录页反而不像)。
|
||||||
|
|
||||||
|
**`/xmlrpc.php` 单独按 XML-RPC 回应**,不是登录页:
|
||||||
|
|
||||||
|
| 请求 | 响应 |
|
||||||
|
|---|---|
|
||||||
|
| `GET` / 其它非 POST | `405` + XML fault「XML-RPC services are disabled on this site.」 |
|
||||||
|
| `POST` 普通 `methodCall` | `200` + XML fault「Incorrect username or password.」 |
|
||||||
|
| `POST` `system.multicall` | 同上,但**一包里的所有凭据对都会被记下来** |
|
||||||
|
| `POST` `system.listMethods` | `200` + 方法列表(枚举类工具靠它确认对面是真 WP) |
|
||||||
|
|
||||||
|
为什么值得单独做:`system.multicall` 一次请求能塞进上千组用户名/密码,且绕开登录页的限速插件,是 WordPress 爆破里产出最高的一条通道。我们自己的 `X-Pingback` 响应头又正指着这个地址 —— 回 HTML 等于自己指路给对方看破绽。
|
||||||
|
|
||||||
|
动态值(phpMyAdmin 的 `token`、通用后台的 `csrf_token`、Jenkins 的 `X-Jenkins-Session`、GitLab 的 `_gitlab_session`)**每个请求都不同**。恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 token,任何脚本都能判定对面是伪造的。
|
||||||
|
|
||||||
单页渲染约 3.7 µs / 2 次分配、页面 1.5–2.5 KB,资源请求 0 分配(`BenchmarkRenderFamily` / `BenchmarkAssetFor` 守着)。
|
单页渲染约 3.7 µs / 2 次分配、页面 1.5–2.5 KB,资源请求 0 分配(`BenchmarkRenderFamily` / `BenchmarkAssetFor` 守着)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -87,6 +100,8 @@ honeypot_cred_dir = data/honeypot-creds
|
|||||||
| `GET /wp-login.php` 看一眼假页面 | 常规 `autoban.Record` | `ban_threshold_sensitive`(默认 10 次 / 60 秒) | 否 |
|
| `GET /wp-login.php` 看一眼假页面 | 常规 `autoban.Record` | `ban_threshold_sensitive`(默认 10 次 / 60 秒) | 否 |
|
||||||
| `GET /wp-admin/css/login.min.css` 等资源 | **不计数、不落库** | — | 否 |
|
| `GET /wp-admin/css/login.min.css` 等资源 | **不计数、不落库** | — | 否 |
|
||||||
| `POST` 带该家族的凭据字段(`log`/`pwd`、`pma_*`、`j_*`、`user[login]` …) | 蜜罐 `autoban.RecordHoneypot` | `honeypot_threshold`(默认 1) | 是,立即封 |
|
| `POST` 带该家族的凭据字段(`log`/`pwd`、`pma_*`、`j_*`、`user[login]` …) | 蜜罐 `autoban.RecordHoneypot` | `honeypot_threshold`(默认 1) | 是,立即封 |
|
||||||
|
| `POST /xmlrpc.php` 带 `methodCall`(含 `system.multicall`) | 蜜罐 `autoban.RecordHoneypot` | 同上 | 是,立即封 |
|
||||||
|
| `GET /xmlrpc.php` / `POST system.listMethods` | 常规通道 | 同上 | 否 |
|
||||||
| `POST` 不带任何凭据字段 | 常规通道 | 同上 | 否 |
|
| `POST` 不带任何凭据字段 | 常规通道 | 同上 | 否 |
|
||||||
|
|
||||||
**为什么 GET 不立即封**:爆破脚本的标准流程是「先 GET 探表单 → 再 POST 提交」。如果 GET 就吃 403,假登录页只被看了一次,对方根本没机会填表,凭据永远拿不到 —— 而这个功能的全部价值就在那份凭据上。
|
**为什么 GET 不立即封**:爆破脚本的标准流程是「先 GET 探表单 → 再 POST 提交」。如果 GET 就吃 403,假登录页只被看了一次,对方根本没机会填表,凭据永远拿不到 —— 而这个功能的全部价值就在那份凭据上。
|
||||||
@@ -250,3 +265,5 @@ pg_dump -Fc <dbname> > before-purge-$(date +%F-%H%M).dump
|
|||||||
- **只挡 NotFound**:如果将来给 `/admin` 之类路径注册了真实路由,蜜罐会自动让位 —— 这是刻意设计(业务优先)。
|
- **只挡 NotFound**:如果将来给 `/admin` 之类路径注册了真实路由,蜜罐会自动让位 —— 这是刻意设计(业务优先)。
|
||||||
- **告警邮件仍带明文用户名/密码**:这正是它的价值(看到对方在用什么字典),但也意味着邮件是敏感物,收件人只应是你自己。
|
- **告警邮件仍带明文用户名/密码**:这正是它的价值(看到对方在用什么字典),但也意味着邮件是敏感物,收件人只应是你自己。
|
||||||
- **不主动反制**:不回打、不上报第三方、不做删除/篡改,只有「记录 + 临时封禁」。
|
- **不主动反制**:不回打、不上报第三方、不做删除/篡改,只有「记录 + 临时封禁」。
|
||||||
|
- **XML-RPC 的凭据提取是启发式的**:不上 `encoding/xml` 解析(蜜罐会被高频命中),而是抓出所有 `<string>`、剔除方法名与站点 URL 后两两配对。`wp.getUsersBlogs` 的第一个参数是 `blog_id`,若是数字会让配对错位一位——为此会把原始串全量写进取证文件的 `fields`(`xmlrpc.0`、`xmlrpc.1` …),即便配对失败原文也不丢。
|
||||||
|
- **`X-Pingback` 需要配 `external_url`**:没配就不发这个头(相对路径比不发更假)。
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// XML-RPC 端点单独处理:真实 WordPress 在这里回 XML,而不是登录页。
|
||||||
|
// 我们自己的 X-Pingback 头正指着这个地址,回 HTML 等于自曝。
|
||||||
|
if honeypot.IsXMLRPCPath(path) {
|
||||||
|
h.serveXMLRPC(ctx, clientIP, banKey, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
|
// 家族决定用哪种后台的外观与字段名:WordPress / phpMyAdmin / Jenkins /
|
||||||
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
|
// Tomcat / Grafana / GitLab / OpenWrt / Joomla / 通用后台。
|
||||||
fam := honeypot.FamilyOf(path)
|
fam := honeypot.FamilyOf(path)
|
||||||
@@ -282,12 +289,60 @@ func isLoopbackIP(ip string) bool {
|
|||||||
// 现在响应头也归 page.go 统一设置,改一处即可。
|
// 现在响应头也归 page.go 统一设置,改一处即可。
|
||||||
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
|
func (h *honeypotHandler) writeLogin(ctx flamego.Context, fam honeypot.Family, path string, failed bool) {
|
||||||
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
|
honeypot.WriteFamilyPage(ctx.ResponseWriter(), fam, honeypot.LoginPageVars{
|
||||||
Action: path,
|
Action: path,
|
||||||
Failed: failed,
|
Failed: failed,
|
||||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||||
|
BaseURL: conf.App.ExternalURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveXMLRPC 处理 /xmlrpc.php。
|
||||||
|
//
|
||||||
|
// 为什么单独做:system.multicall 一次请求能带上千组用户名/密码,且绕开登录页的
|
||||||
|
// 限速插件 —— 这是 WordPress 爆破里产出最高的一条通道,xxer、WPScan 的 xmlrpc
|
||||||
|
// 模式都走它。原来的实现把这个路径当普通诱饵,回一个 HTML 登录页:
|
||||||
|
// 既拿不到字典,又因为 X-Pingback 指着它而暴露「整个后台是假的」。
|
||||||
|
//
|
||||||
|
// 响应形态对齐真实 WordPress:
|
||||||
|
// - 非 POST(GET/PUT/…)→ 405 + fault「XML-RPC services are disabled on this site.」
|
||||||
|
// - POST + 凭据 → 200 + fault「Incorrect username or password.」(不区分用户是否存在)
|
||||||
|
// - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP)
|
||||||
|
func (h *honeypotHandler) serveXMLRPC(ctx flamego.Context, clientIP, banKey, path string) {
|
||||||
|
req := ctx.Request().Request
|
||||||
|
w := ctx.ResponseWriter()
|
||||||
|
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
// 纯探测:计入常规敏感路径统计(阈值同其它诱饵),不立即封。
|
||||||
|
if h.ab != nil && banKey != "" {
|
||||||
|
h.ab.Record(banKey, path, autoban.KindSensitive)
|
||||||
|
}
|
||||||
|
h.record(clientIP, banKey, req, path, honeypot.Credentials{}, false)
|
||||||
|
honeypot.WriteXMLRPCFault(w, http.StatusMethodNotAllowed, 405, honeypot.XMLRPCFaultDisabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// body 只能读一次,提取完就不能再给后面的通用逻辑用了。
|
||||||
|
creds := honeypot.ExtractXMLRPCCredentials(req)
|
||||||
|
|
||||||
|
// 与表单提交同一套判定:带凭据 → 蜜罐通道立即封;否则走常规阈值。
|
||||||
|
banned := false
|
||||||
|
if h.ab != nil && banKey != "" {
|
||||||
|
if creds.HadFields {
|
||||||
|
banned = h.ab.RecordHoneypot(banKey, path)
|
||||||
|
} else {
|
||||||
|
h.ab.Record(banKey, path, autoban.KindSensitive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !creds.HadFields && creds.Fields["xmlrpc.method"] == "system.listMethods" {
|
||||||
|
honeypot.WriteXMLRPCMethods(w)
|
||||||
|
} else {
|
||||||
|
honeypot.WriteXMLRPCFault(w, http.StatusOK, 403, honeypot.XMLRPCFaultBadLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.record(clientIP, banKey, req, path, creds, banned)
|
||||||
|
}
|
||||||
|
|
||||||
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
// record 追加一条命中记录(列宽对齐 + 探测采样都在这里收口)。
|
||||||
//
|
//
|
||||||
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
|
// clientIP 是精确客户端地址(写进取证数据),banKey 是聚合键(只用于采样去重)。
|
||||||
|
|||||||
@@ -644,3 +644,86 @@ func TestHoneypotAssetRequestsAreNotRecorded(t *testing.T) {
|
|||||||
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
|
t.Errorf("资源请求不应落库,实际写了 %d 条", len(store.hits))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXMLRPCPostCapturesCredentials 守住 /xmlrpc.php 是真正的 XML-RPC 端点。
|
||||||
|
//
|
||||||
|
// 之前它回的是 HTML 登录页,而响应头 X-Pingback 又指着这个地址 ——
|
||||||
|
// 扫描器跟过来一看是 HTML 就知道整站是假的;更可惜的是 system.multicall
|
||||||
|
// 这条「一次上千组凭据」的通道被白白浪费。
|
||||||
|
func TestXMLRPCPostCapturesCredentials(t *testing.T) {
|
||||||
|
cs, err := honeypot.NewFileCredStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := newHoneypotTestServerFull(t, true, false, cs)
|
||||||
|
|
||||||
|
body := `<methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><array><data>` +
|
||||||
|
`<value><string>http://127.0.0.1/xmlrpc.php</string></value>` +
|
||||||
|
`<value><string>root</string></value>` +
|
||||||
|
`<value><string>toor</string></value>` +
|
||||||
|
`</data></array></value></param></params></methodCall>`
|
||||||
|
|
||||||
|
resp, err := http.Post(ts.URL+"/xmlrpc.php", "text/xml", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200(真实 WordPress 用 200 + fault 表达登录失败)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/xml", ct)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(payload), "<html") || strings.Contains(string(payload), honeypot.CanaryToken) {
|
||||||
|
t.Errorf("响应里出现了假登录页:%q", truncate(string(payload), 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
recs, err := cs.ListSince(time.Now().Add(-time.Hour), "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("凭据记录数 = %d, want 1", len(recs))
|
||||||
|
}
|
||||||
|
if recs[0].Username != "root" || recs[0].Password != "toor" {
|
||||||
|
t.Errorf("凭据 = %q/%q, want root/toor", recs[0].Username, recs[0].Password)
|
||||||
|
}
|
||||||
|
if !recs[0].Banned {
|
||||||
|
t.Error("multicall 提交凭据应触发立即封禁")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封禁生效后,连普通 GET 也应被中间件挡掉。
|
||||||
|
resp2, err := http.Get(ts.URL + "/xmlrpc.php")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("封禁后 status = %d, want 403", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestXMLRPCGetReturnsFault 覆盖非 POST:真实 WordPress 回 405 + XML fault,
|
||||||
|
// 不是登录页。
|
||||||
|
func TestXMLRPCGetReturnsFault(t *testing.T) {
|
||||||
|
ts := newHoneypotTestServerFull(t, true, false, nil)
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/xmlrpc.php")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/xml", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(payload), honeypot.XMLRPCFaultDisabled) {
|
||||||
|
t.Errorf("body = %q, want 含 %q", truncate(string(payload), 120), honeypot.XMLRPCFaultDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ var skins = []loginSkin{
|
|||||||
footer: "",
|
footer: "",
|
||||||
headers: [][2]string{
|
headers: [][2]string{
|
||||||
{"X-Powered-By", "PHP/7.4.33"},
|
{"X-Powered-By", "PHP/7.4.33"},
|
||||||
{"X-Pingback", "/xmlrpc.php"},
|
{"X-Pingback", "$base/xmlrpc.php"},
|
||||||
},
|
},
|
||||||
cookies: []string{"wordpress_test_cookie=WP%20Cookie%20check; path=/"},
|
cookies: []string{"wordpress_test_cookie=WP%20Cookie%20check; path=/"},
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ var skins = []loginSkin{
|
|||||||
hidden: [][2]string{
|
hidden: [][2]string{
|
||||||
{"server", "1"},
|
{"server", "1"},
|
||||||
{"target", "index.php"},
|
{"target", "index.php"},
|
||||||
{"token", "e1a4c0b7d2f58g93"},
|
{"token", "$hex32"},
|
||||||
},
|
},
|
||||||
failure: `#1045 - Cannot log in to the MySQL server`,
|
failure: `#1045 - Cannot log in to the MySQL server`,
|
||||||
footer: "Language: English · Theme: pmahomme",
|
footer: "Language: English · Theme: pmahomme",
|
||||||
@@ -183,7 +183,7 @@ var skins = []loginSkin{
|
|||||||
footer: "Jenkins ver. 2.401.3",
|
footer: "Jenkins ver. 2.401.3",
|
||||||
headers: [][2]string{
|
headers: [][2]string{
|
||||||
{"X-Jenkins", "2.401.3"},
|
{"X-Jenkins", "2.401.3"},
|
||||||
{"X-Jenkins-Session", "8b1f2c4d"},
|
{"X-Jenkins-Session", "$hex8"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,6 +207,7 @@ var skins = []loginSkin{
|
|||||||
headers: [][2]string{
|
headers: [][2]string{
|
||||||
{"X-Grafana-Org-Id", "1"},
|
{"X-Grafana-Org-Id", "1"},
|
||||||
},
|
},
|
||||||
|
cookies: []string{"grafana_sess=$hex32; Path=/; HttpOnly"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
family: FamilyGitLab,
|
family: FamilyGitLab,
|
||||||
@@ -227,8 +228,9 @@ var skins = []loginSkin{
|
|||||||
failure: "Invalid Login or password.",
|
failure: "Invalid Login or password.",
|
||||||
footer: "GitLab Community Edition 16.8.1",
|
footer: "GitLab Community Edition 16.8.1",
|
||||||
headers: [][2]string{
|
headers: [][2]string{
|
||||||
{"X-Gitlab-Meta", `{"correlation_id":"a1b2c3"}`},
|
{"X-Gitlab-Meta", `{"correlation_id":"$hex32"}`},
|
||||||
},
|
},
|
||||||
|
cookies: []string{"_gitlab_session=$hex32; path=/; HttpOnly"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
family: FamilyLuCI,
|
family: FamilyLuCI,
|
||||||
@@ -292,7 +294,7 @@ var skins = []loginSkin{
|
|||||||
{name: "username", label: "Username"},
|
{name: "username", label: "Username"},
|
||||||
{name: "password", label: "Password", secret: true},
|
{name: "password", label: "Password", secret: true},
|
||||||
},
|
},
|
||||||
hidden: [][2]string{{"csrf_token", "a7f3c1e9b2d4"}},
|
hidden: [][2]string{{"csrf_token", "$hex16"}},
|
||||||
failure: "Invalid username or password.",
|
failure: "Invalid username or password.",
|
||||||
footer: "Restricted area. Authorized personnel only.",
|
footer: "Restricted area. Authorized personnel only.",
|
||||||
headers: [][2]string{
|
headers: [][2]string{
|
||||||
|
|||||||
@@ -179,3 +179,118 @@ func BenchmarkAssetFor(b *testing.B) {
|
|||||||
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
|
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hiddenValue 取某个 hidden input 的 value。
|
||||||
|
func hiddenValue(body, name string) string {
|
||||||
|
marker := `name="` + name + `" value="`
|
||||||
|
i := strings.Index(body, marker)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := body[i+len(marker):]
|
||||||
|
if j := strings.IndexByte(rest, '"'); j >= 0 {
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDynamicTokensDifferPerRequest 守住「每次请求的 token 必须不同」。
|
||||||
|
//
|
||||||
|
// 恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 CSRF token,
|
||||||
|
// 任何脚本都能判定对面是伪造的 —— 而这正是「伪装得像」的核心。
|
||||||
|
func TestDynamicTokensDifferPerRequest(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
fam Family
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
}{
|
||||||
|
{FamilyPhpMyAdmin, "token", 32},
|
||||||
|
{FamilyGeneric, "csrf_token", 16},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
first := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
|
||||||
|
second := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"}))
|
||||||
|
|
||||||
|
v1, v2 := hiddenValue(first, c.name), hiddenValue(second, c.name)
|
||||||
|
if v1 == "" || v2 == "" {
|
||||||
|
t.Fatalf("%s: 取不到 %s 的值", c.fam, c.name)
|
||||||
|
}
|
||||||
|
if v1 == v2 {
|
||||||
|
t.Errorf("%s 的 %s 两次相同(%q)—— 恒定值可被指纹识别", c.fam, c.name, v1)
|
||||||
|
}
|
||||||
|
for _, v := range []string{v1, v2} {
|
||||||
|
if len(v) != c.size {
|
||||||
|
t.Errorf("%s 的 %s 长度 = %d, want %d(%q)", c.fam, c.name, len(v), c.size, v)
|
||||||
|
}
|
||||||
|
if strings.Trim(v, "0123456789abcdef") != "" {
|
||||||
|
t.Errorf("%s 的 %s = %q, 含非 hex 字符(真实产品只给 hex)", c.fam, c.name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeadersDynamicValues 守住响应头里的动态值:Jenkins 会话号、GitLab 关联 id。
|
||||||
|
func TestHeadersDynamicValues(t *testing.T) {
|
||||||
|
got := func(f Family, header string) string {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteFamilyPage(rec, f, LoginPageVars{Action: "/x"})
|
||||||
|
return rec.Header().Get(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
s1, s2 := got(FamilyJenkins, "X-Jenkins-Session"), got(FamilyJenkins, "X-Jenkins-Session")
|
||||||
|
if len(s1) != 8 || s1 == s2 {
|
||||||
|
t.Errorf("X-Jenkins-Session = %q / %q, want 两个不同的 8 位 hex", s1, s2)
|
||||||
|
}
|
||||||
|
g1, g2 := got(FamilyGitLab, "X-Gitlab-Meta"), got(FamilyGitLab, "X-Gitlab-Meta")
|
||||||
|
if g1 == g2 || !strings.Contains(g1, `"correlation_id":"`) {
|
||||||
|
t.Errorf("X-Gitlab-Meta = %q / %q, want 每次不同的 correlation_id", g1, g2)
|
||||||
|
}
|
||||||
|
// 会话 cookie 同样不能恒定。
|
||||||
|
if c1, c2 := got(FamilyGitLab, "Set-Cookie"), got(FamilyGitLab, "Set-Cookie"); c1 == c2 {
|
||||||
|
t.Errorf("Set-Cookie = %q 两次相同", c1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPingbackAbsoluteURL 守住 X-Pingback 必须是绝对地址:
|
||||||
|
// 真实 WordPress 给的是 "https://host/xmlrpc.php",相对路径本身就是破绽。
|
||||||
|
// 没配置 external_url 时宁可不发这个头。
|
||||||
|
func TestPingbackAbsoluteURL(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{
|
||||||
|
Action: "/wp-login.php",
|
||||||
|
BaseURL: "https://box.test/",
|
||||||
|
})
|
||||||
|
if got := rec.Header().Get("X-Pingback"); got != "https://box.test/xmlrpc.php" {
|
||||||
|
t.Errorf("X-Pingback = %q, want 绝对地址(尾部斜杠要折叠)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
|
||||||
|
if got := rec.Header().Get("X-Pingback"); got != "" {
|
||||||
|
t.Errorf("X-Pingback = %q, want 不发(相对路径比不发更假)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoUnresolvedPlaceholders 守住渲染后不残留 $ 占位符 ——
|
||||||
|
// 漏展开会在页面上留下 "$hex32" 这种一眼假的值。
|
||||||
|
func TestNoUnresolvedPlaceholders(t *testing.T) {
|
||||||
|
for _, fam := range Families() {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteFamilyPage(rec, fam, LoginPageVars{
|
||||||
|
Action: "/x",
|
||||||
|
Redir: "/wp-admin/",
|
||||||
|
BaseURL: "https://box.test",
|
||||||
|
})
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Contains(body, "$hex") || strings.Contains(body, "$base") {
|
||||||
|
t.Errorf("%s 页面残留占位符:%s", fam, body)
|
||||||
|
}
|
||||||
|
for k, vs := range rec.Header() {
|
||||||
|
for _, v := range vs {
|
||||||
|
if strings.Contains(v, "$hex") || strings.Contains(v, "$base") {
|
||||||
|
t.Errorf("%s 响应头 %s 残留占位符:%q", fam, k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,6 +222,16 @@ func (h *Honeypot) Match(rawPath string) bool {
|
|||||||
if _, ok := h.prefix[p]; ok {
|
if _, ok := h.prefix[p]; ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// .php 变体:清单里写的是 /phpinfo,而扫描器打的是 /phpinfo.php。
|
||||||
|
// 段前缀匹配按 '/' 与 '.' 做边界,但 /phpinfo.php 的 '.' 后面直接是 "php",
|
||||||
|
// 段前缀拼出来的 /phpinfo.php 本身不在集合里,于是会漏掉 ——
|
||||||
|
// 漏掉的表现是 302 回首页,等于明确告诉对方「这个文件不存在」,
|
||||||
|
// 而真跑着 PHP 的站点会回 200 或 403。
|
||||||
|
if strings.HasSuffix(p, ".php") {
|
||||||
|
if _, ok := h.prefix[strings.TrimSuffix(p, ".php")]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
// 段前缀:/wp-admin/install.php 命中 /wp-admin
|
// 段前缀:/wp-admin/install.php 命中 /wp-admin
|
||||||
segs := strings.Split(p, "/")
|
segs := strings.Split(p, "/")
|
||||||
for i := 1; i < len(segs); i++ {
|
for i := 1; i < len(segs); i++ {
|
||||||
|
|||||||
@@ -213,3 +213,22 @@ func TestSplitPaths(t *testing.T) {
|
|||||||
t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package honeypot
|
package honeypot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"html"
|
"html"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -19,6 +22,52 @@ type LoginPageVars struct {
|
|||||||
Failed bool
|
Failed bool
|
||||||
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
|
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
|
||||||
Redir string
|
Redir string
|
||||||
|
// BaseURL 是站点对外地址(conf.App.ExternalURL),供 X-Pingback 这类
|
||||||
|
// 需要绝对 URL 的响应头使用。真实 WordPress 发的是绝对地址,
|
||||||
|
// 相对路径本身就是个破绽,所以留空时干脆不发这个头。
|
||||||
|
BaseURL string
|
||||||
|
// Nonce 是本次请求的动态占位符源(32 位 hex),由 WriteFamilyPage 生成。
|
||||||
|
// 留空时渲染阶段自行生成 —— 直接调 RenderFamily 的调用方(预览工具、
|
||||||
|
// 测试)不需要关心它。
|
||||||
|
Nonce string
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamicTokenLen 是动态占位符源的字符数(32 个 hex 字符)。
|
||||||
|
const dynamicTokenLen = 32
|
||||||
|
|
||||||
|
// newToken 生成一次请求用的伪随机 hex 串。
|
||||||
|
//
|
||||||
|
// 刻意不用 crypto/rand:这些值是假的 CSRF token / 会话号,不需要不可预测,
|
||||||
|
// 只需要「每次请求都不一样」。而恒定值恰恰是最容易被指纹识别的特征 ——
|
||||||
|
// 连打两次一模一样的 token,任何脚本都能判定对面是伪造的。
|
||||||
|
// math/rand/v2 单次约几纳秒,crypto/rand 在 Windows 上要走系统调用,贵一个量级。
|
||||||
|
func newToken() string {
|
||||||
|
var b [16]byte
|
||||||
|
binary.LittleEndian.PutUint64(b[0:8], rand.Uint64())
|
||||||
|
binary.LittleEndian.PutUint64(b[8:16], rand.Uint64())
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandDyn 替换伪装页与响应头里的动态占位符:
|
||||||
|
//
|
||||||
|
// $hex8 / $hex16 / $hex32 —— 从本次请求的随机串里截取
|
||||||
|
// $base —— 站点对外地址(不含尾部斜杠)
|
||||||
|
//
|
||||||
|
// 只处理这四个,命中不了的原样返回,所以对普通常量没有额外开销。
|
||||||
|
func expandDyn(s, token, baseURL string) string {
|
||||||
|
if !strings.Contains(s, "$") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "$base") {
|
||||||
|
s = strings.ReplaceAll(s, "$base", baseURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "$hex") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// 先长后短:$hex32 替换出来的都是 hex 字符,不会再被后两条命中。
|
||||||
|
s = strings.ReplaceAll(s, "$hex32", token)
|
||||||
|
s = strings.ReplaceAll(s, "$hex16", token[:16])
|
||||||
|
return strings.ReplaceAll(s, "$hex8", token[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
|
// LoginPageTitle 是 WordPress 家族假登录页的标题(与真实登录页一致)。
|
||||||
@@ -72,6 +121,13 @@ func RenderFamily(f Family, vars LoginPageVars) []byte {
|
|||||||
if notice == "" && vars.Failed {
|
if notice == "" && vars.Failed {
|
||||||
notice = s.failure
|
notice = s.failure
|
||||||
}
|
}
|
||||||
|
// 本次请求的动态值源。WriteFamilyPage 会先填好 vars.Nonce,
|
||||||
|
// 这样响应头与页面里的 token 是同一套;直接调本函数的调用方则各自生成。
|
||||||
|
token := vars.Nonce
|
||||||
|
if len(token) != dynamicTokenLen {
|
||||||
|
token = newToken()
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(vars.BaseURL, "/")
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.Grow(3072 + len(s.css))
|
b.Grow(3072 + len(s.css))
|
||||||
@@ -143,7 +199,7 @@ func RenderFamily(f Family, vars LoginPageVars) []byte {
|
|||||||
b.WriteString("<input type=\"hidden\" name=\"")
|
b.WriteString("<input type=\"hidden\" name=\"")
|
||||||
b.WriteString(h[0])
|
b.WriteString(h[0])
|
||||||
b.WriteString("\" value=\"")
|
b.WriteString("\" value=\"")
|
||||||
b.WriteString(html.EscapeString(v))
|
b.WriteString(html.EscapeString(expandDyn(v, token, base)))
|
||||||
b.WriteString("\">\n")
|
b.WriteString("\">\n")
|
||||||
}
|
}
|
||||||
if s.submit != "" {
|
if s.submit != "" {
|
||||||
@@ -172,15 +228,29 @@ func RenderLoginPage(vars LoginPageVars) []byte {
|
|||||||
// 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。
|
// 凭据从 Basic Auth 头取(ExtractCredentials 已支持),比给一个假表单更真。
|
||||||
func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) {
|
func WriteFamilyPage(w http.ResponseWriter, f Family, vars LoginPageVars) {
|
||||||
s := skinOf(f)
|
s := skinOf(f)
|
||||||
|
// 响应头与页面共用同一套动态值:真实产品的 CSRF token 在表单里、
|
||||||
|
// 会话号在 Set-Cookie 里,两者不会是各自独立随机的。
|
||||||
|
if len(vars.Nonce) != dynamicTokenLen {
|
||||||
|
vars.Nonce = newToken()
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(vars.BaseURL, "/")
|
||||||
|
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
// 真实 wp-login.php / phpMyAdmin 都是这条,no-store 反而不像登录页
|
||||||
|
// (那是安全设备与代理爱加的头)。
|
||||||
|
h.Set("Cache-Control", "no-cache, must-revalidate, max-age=0")
|
||||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||||
for _, kv := range s.headers {
|
for _, kv := range s.headers {
|
||||||
h.Set(kv[0], kv[1])
|
// 依赖对外地址的头(X-Pingback):没配置就不发。
|
||||||
|
// 发一个相对路径比不发更假 —— 真实 WordPress 给的是绝对 URL。
|
||||||
|
if strings.Contains(kv[1], "$base") && base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.Set(kv[0], expandDyn(kv[1], vars.Nonce, base))
|
||||||
}
|
}
|
||||||
for _, c := range s.cookies {
|
for _, c := range s.cookies {
|
||||||
h.Add("Set-Cookie", c)
|
h.Add("Set-Cookie", expandDyn(c, vars.Nonce, base))
|
||||||
}
|
}
|
||||||
status := http.StatusOK
|
status := http.StatusOK
|
||||||
if s.basicRealm != "" {
|
if s.basicRealm != "" {
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package honeypot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WordPress 的 /xmlrpc.php 是爆破里最值钱的一个入口:system.multicall 允许
|
||||||
|
// 一次请求塞进上千组用户名/密码,且绕开登录页的限速插件 —— xxer、WPScan 的
|
||||||
|
// xmlrpc 模式都靠它。
|
||||||
|
//
|
||||||
|
// 之前这个路径回的是假登录 HTML,有两个后果:
|
||||||
|
// 1. 我们自己的 X-Pingback 响应头就指着 /xmlrpc.php,扫描器跟过来一看是 HTML,
|
||||||
|
// 立刻知道整个站点是假的(真实的 WordPress 在这里回 text/xml);
|
||||||
|
// 2. 最值钱的那条凭据来源直接丢了 —— 对方提交的字典全落在 HTML 页面上。
|
||||||
|
const (
|
||||||
|
// XMLRPCFaultDisabled 与真实 WordPress 对非 POST 请求返回的 fault 文案一致。
|
||||||
|
XMLRPCFaultDisabled = "XML-RPC services are disabled on this site."
|
||||||
|
// XMLRPCFaultBadLogin 与真实 WordPress 用户名/密码错误时的文案一致
|
||||||
|
// (不区分「用户不存在」与「密码错误」,避免给爆破脚本用户名枚举信号)。
|
||||||
|
XMLRPCFaultBadLogin = "Incorrect username or password."
|
||||||
|
|
||||||
|
// maxXMLRPCBody 限制参与解析的 body 大小。system.multicall 一包上千组凭据
|
||||||
|
// 也就 100 KB 上下,256 KB 足够;再大的一律截断(仍会留下前 256 KB 取证)。
|
||||||
|
maxXMLRPCBody = 256 << 10
|
||||||
|
|
||||||
|
// maxXMLRPCRawBody 是写进取证文件的原始 body 上限(按 rune)。
|
||||||
|
// 比普通表单字段宽得多:multicall 的字典本身就是要留的证据。
|
||||||
|
maxXMLRPCRawBody = 8192
|
||||||
|
|
||||||
|
// maxXMLRPCPairs 是最多收录多少组凭据(防止一包把取证文件撑爆)。
|
||||||
|
maxXMLRPCPairs = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsXMLRPCPath 判断路径是否为 XML-RPC 端点(入参应已 cleanPath)。
|
||||||
|
//
|
||||||
|
// 大小写不敏感:/XMLRPC.PHP 在真实 WordPress 上同样命中(路由不区分大小写)。
|
||||||
|
func IsXMLRPCPath(p string) bool {
|
||||||
|
p = strings.TrimSuffix(p, "/")
|
||||||
|
return strings.EqualFold(p, "/xmlrpc.php") || strings.EqualFold(p, "/xmlrpc")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// xmlMethodNameRe 抓 <member><name>methodName</name><value><string>X</string>...
|
||||||
|
// metacall 的每个 pacel 里都有一份,普通调用则在 <methodName> 标签里。
|
||||||
|
xmlMethodNameRe = regexp.MustCompile(`(?s)<name>\s*methodName\s*</name>\s*<value>\s*<string>([^<]*)</string>`)
|
||||||
|
xmlMethodTagRe = regexp.MustCompile(`(?s)<methodName>\s*([^<]*)</methodName>`)
|
||||||
|
xmlStringRe = regexp.MustCompile(`(?s)<string>(.*?)</string>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractXMLRPCCredentials 从 XML-RPC 请求体里提取凭据。
|
||||||
|
//
|
||||||
|
// 解析策略是「够用就好」,刻意不上 encoding/xml:蜜罐会被高频命中,
|
||||||
|
// 而这里的 payload 结构固定(methodCall + string 参数)。做法是抓出所有
|
||||||
|
// <string>,剔除方法名与 URL,再按出现顺序两两配对。
|
||||||
|
//
|
||||||
|
// 局限:wp.getUsersBlogs 的第一个参数是 blog_id,通常是 URL 或空串(已被剔除);
|
||||||
|
// 若对方偏偏传了数字(blog_id=1),配对会整体错位一位。为此额外做了两件事 ——
|
||||||
|
// 位数为奇数且首项是短数字时丢掉首项,以及把所有原始串写进 Fields 的
|
||||||
|
// xmlrpc.N 里。即便配对失败,取证文件里也留得下完整原文。
|
||||||
|
func ExtractXMLRPCCredentials(r *http.Request) Credentials {
|
||||||
|
out := Credentials{Fields: map[string]string{}}
|
||||||
|
if r == nil || r.Body == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer func() { _ = r.Body.Close() }()
|
||||||
|
|
||||||
|
buf, _ := io.ReadAll(io.LimitReader(r.Body, maxXMLRPCBody))
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
body := string(buf)
|
||||||
|
out.RawBody = clipRunes(body, maxXMLRPCRawBody)
|
||||||
|
|
||||||
|
// 不是 XML-RPC 调用(比如扫描器拿它当普通路径探测):只留原始 body,
|
||||||
|
// HadFields 保持 false,不会被误判成「提交了凭据」。
|
||||||
|
if !strings.Contains(body, "<methodCall") {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法名:<methodName> 标签(普通调用)与 methodName 成员(multicall 的 pacel)。
|
||||||
|
skip := make(map[string]bool, 8)
|
||||||
|
for _, m := range xmlMethodTagRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
skip[name] = true
|
||||||
|
if out.Fields["xmlrpc.method"] == "" {
|
||||||
|
out.Fields["xmlrpc.method"] = clipRunes(name, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range xmlMethodNameRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
skip[strings.TrimSpace(m[1])] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数值:剔除空的、方法名、站点 URL 之后按顺序收集。
|
||||||
|
vals := make([]string, 0, 8)
|
||||||
|
for _, m := range xmlStringRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
v := strings.TrimSpace(unescapeXMLText(m[1]))
|
||||||
|
if v == "" || skip[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
low := strings.ToLower(v)
|
||||||
|
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
|
||||||
|
continue // 站点地址,不是凭据
|
||||||
|
}
|
||||||
|
vals = append(vals, clipRunes(v, maxCredentialFieldLen))
|
||||||
|
}
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// blog_id 兜底:wp.getUsersBlogs 的 pacel 是 [blog_id, username, password],
|
||||||
|
// blog_id 若是短数字(1),前面的 URL 过滤没能剔除,会让整组错位。
|
||||||
|
if len(vals)%2 == 1 && isShortDigits(vals[0]) {
|
||||||
|
vals = vals[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部原始值都留一份:配对可能不完美,原文不能丢。
|
||||||
|
for i, v := range vals {
|
||||||
|
if i >= maxXMLRPCPairs*2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.Fields["xmlrpc."+strconv.Itoa(i)] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两两配对。第一组写进标准字段(CLI / 邮件 / 统计都读它),
|
||||||
|
// 其余的按 cred2 / cred3 … 留在 Fields 里 —— multicall 的字典常常
|
||||||
|
// 就是「一个用户名 + 一长串密码」,只留第一组等于丢掉整份字典。
|
||||||
|
pair := 0
|
||||||
|
for i := 0; i+1 < len(vals); i += 2 {
|
||||||
|
user, pass := vals[i], vals[i+1]
|
||||||
|
if pair == 0 {
|
||||||
|
out.Fields["username"] = user
|
||||||
|
out.Fields["password"] = pass
|
||||||
|
out.Username, out.Password, out.HadFields = user, pass, true
|
||||||
|
} else if pair < maxXMLRPCPairs {
|
||||||
|
prefix := "cred" + strconv.Itoa(pair+1) + "."
|
||||||
|
out.Fields[prefix+"username"] = user
|
||||||
|
out.Fields[prefix+"password"] = pass
|
||||||
|
}
|
||||||
|
pair++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isShortDigits 判断是否为 1–3 位纯数字(典型的 blog_id)。
|
||||||
|
func isShortDigits(s string) bool {
|
||||||
|
if len(s) == 0 || len(s) > 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeXMLText 还原 XML 文本里最常见的五个实体。
|
||||||
|
//
|
||||||
|
// 不用 html.UnescapeString:那会把 之类的 HTML 实体也展开,
|
||||||
|
// 而这里处理的是 XML 文本节点,多展开反而是错的。
|
||||||
|
func unescapeXMLText(s string) string {
|
||||||
|
if !strings.Contains(s, "&") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
""", `"`,
|
||||||
|
"'", "'",
|
||||||
|
"&", "&", // 必须最后替换
|
||||||
|
).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteXMLRPCFault 按 WordPress 的真实格式写一个 fault 响应。
|
||||||
|
func WriteXMLRPCFault(w http.ResponseWriter, status, code int, msg string) {
|
||||||
|
body := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
|
||||||
|
`<methodResponse><fault><value><struct>` +
|
||||||
|
`<member><name>faultCode</name><value><int>` + strconv.Itoa(code) + `</int></value></member>` +
|
||||||
|
`<member><name>faultString</name><value><string>` + msg + `</string></value></member>` +
|
||||||
|
`</struct></value></fault></methodResponse>` + "\n"
|
||||||
|
writeXMLRPC(w, status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlRPCMethods 是 system.listMethods 的返回内容。
|
||||||
|
//
|
||||||
|
// 枚举类工具(WPScan / 各种指纹脚本)会先调这个确认「对方真的是 WordPress」。
|
||||||
|
// 回一个空 fault 或 HTML 都太假,静态列表足够且零成本。
|
||||||
|
var xmlRPCMethods = []string{
|
||||||
|
"system.multicall", "system.listMethods", "system.getCapabilities",
|
||||||
|
"blogger.getUsersBlogs", "blogger.deletePost", "blogger.getRecentPosts",
|
||||||
|
"metaWeblog.getPost", "metaWeblog.getRecentPosts", "metaWeblog.newPost",
|
||||||
|
"metaWeblog.editPost", "wp.getUsersBlogs", "wp.getPost", "wp.getPosts",
|
||||||
|
"wp.getMediaLibrary", "wp.getCategories", "wp.getTags",
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteXMLRPCMethods 回应 system.listMethods。
|
||||||
|
func WriteXMLRPCMethods(w http.ResponseWriter) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(64 + len(xmlRPCMethods)*40)
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<methodResponse><params><param><value><array><data>")
|
||||||
|
for _, m := range xmlRPCMethods {
|
||||||
|
b.WriteString("<value><string>")
|
||||||
|
b.WriteString(m)
|
||||||
|
b.WriteString("</string></value>")
|
||||||
|
}
|
||||||
|
b.WriteString("</data></array></value></param></params></methodResponse>\n")
|
||||||
|
writeXMLRPC(w, http.StatusOK, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeXMLRPC(w http.ResponseWriter, status int, body string) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/xml; charset=UTF-8")
|
||||||
|
// 真实 WordPress 对 xmlrpc.php 不允许缓存。
|
||||||
|
h.Set("Cache-Control", "no-cache, must-revalidate, max-age=0")
|
||||||
|
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package honeypot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsXMLRPCPath(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"/xmlrpc.php": true,
|
||||||
|
"/xmlrpc": true,
|
||||||
|
"/XMLRPC.PHP": true,
|
||||||
|
"/xmlrpc.php/": true,
|
||||||
|
"/xmlrpcx.php": false,
|
||||||
|
"/wp-login.php": false,
|
||||||
|
"": false,
|
||||||
|
}
|
||||||
|
for p, want := range cases {
|
||||||
|
if got := IsXMLRPCPath(p); got != want {
|
||||||
|
t.Errorf("IsXMLRPCPath(%q) = %v, want %v", p, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractXMLRPCCredentialsUsersBlogs 覆盖最常见的爆破形态:
|
||||||
|
// wp.getUsersBlogs 的参数是 [站点地址, 用户名, 密码],站点地址必须被剔除。
|
||||||
|
func TestExtractXMLRPCCredentialsUsersBlogs(t *testing.T) {
|
||||||
|
body := `<?xml version="1.0"?>
|
||||||
|
<methodCall><methodName>wp.getUsersBlogs</methodName><params>
|
||||||
|
<param><value><array><data>
|
||||||
|
<value><string>https://target.test/xmlrpc.php</string></value>
|
||||||
|
<value><string>admin</string></value>
|
||||||
|
<value><string>s3cr3t</string></value>
|
||||||
|
</data></array></value></param>
|
||||||
|
</params></methodCall>`
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "text/xml")
|
||||||
|
creds := ExtractXMLRPCCredentials(req)
|
||||||
|
|
||||||
|
if !creds.HadFields {
|
||||||
|
t.Fatal("HadFields = false, want true(这是标准的凭据提交)")
|
||||||
|
}
|
||||||
|
if creds.Username != "admin" || creds.Password != "s3cr3t" {
|
||||||
|
t.Errorf("凭据 = %q/%q, want admin/s3cr3t(站点 URL 必须被剔除)", creds.Username, creds.Password)
|
||||||
|
}
|
||||||
|
if got := creds.Fields["xmlrpc.method"]; got != "wp.getUsersBlogs" {
|
||||||
|
t.Errorf("xmlrpc.method = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractXMLRPCCredentialsMulticall 覆盖 system.multicall:
|
||||||
|
// 一包多组凭据时,第一组进标准字段,其余进 cred2/cred3,不能只剩第一组。
|
||||||
|
func TestExtractXMLRPCCredentialsMulticall(t *testing.T) {
|
||||||
|
pacel := func(user, pass string) string {
|
||||||
|
return `<value><struct>
|
||||||
|
<member><name>methodName</name><value><string>wp.getUsersBlogs</string></value></member>
|
||||||
|
<member><name>params</name><value><array><data>
|
||||||
|
<value><string>https://target.test/xmlrpc.php</string></value>
|
||||||
|
<value><string>` + user + `</string></value>
|
||||||
|
<value><string>` + pass + `</string></value>
|
||||||
|
</data></array></value></member>
|
||||||
|
</struct></value>`
|
||||||
|
}
|
||||||
|
body := `<methodCall><methodName>system.multicall</methodName><params><param><value><array><data>` +
|
||||||
|
pacel("root", "toor") + pacel("root", "hunter2") +
|
||||||
|
`</data></array></value></param></params></methodCall>`
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
|
||||||
|
creds := ExtractXMLRPCCredentials(req)
|
||||||
|
|
||||||
|
if creds.Username != "root" || creds.Password != "toor" {
|
||||||
|
t.Errorf("第一组 = %q/%q, want root/toor", creds.Username, creds.Password)
|
||||||
|
}
|
||||||
|
if got := creds.Fields["cred2.password"]; got != "hunter2" {
|
||||||
|
t.Errorf("cred2.password = %q, want hunter2(multicall 的字典不能只留第一组)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractXMLRPCCredentialsNumericBlogID 覆盖 blog_id=1 的错位场景:
|
||||||
|
// 参数是 [1, user, pass],前面的 URL 过滤没有剔除 "1",配对会整体错一位。
|
||||||
|
func TestExtractXMLRPCCredentialsNumericBlogID(t *testing.T) {
|
||||||
|
body := `<methodCall><methodName>wp.getUsersBlogs</methodName><params><param><value><array><data>
|
||||||
|
<value><string>1</string></value>
|
||||||
|
<value><string>admin</string></value>
|
||||||
|
<value><string>123456</string></value>
|
||||||
|
</data></array></value></param></params></methodCall>`
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
|
||||||
|
creds := ExtractXMLRPCCredentials(req)
|
||||||
|
|
||||||
|
if creds.Username != "admin" || creds.Password != "123456" {
|
||||||
|
t.Errorf("凭据 = %q/%q, want admin/123456(短数字首位应视为 blog_id 丢弃)",
|
||||||
|
creds.Username, creds.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractXMLRPCCredentialsIgnored 覆盖「不是 XML-RPC 调用」的请求:
|
||||||
|
// 不能把普通探测误判成凭据提交(否则会触发一封假告警并立即封禁)。
|
||||||
|
func TestExtractXMLRPCCredentialsIgnored(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"log=admin&pwd=x",
|
||||||
|
`{"log":"admin","pwd":"x"}`,
|
||||||
|
`<methodCall><methodName>system.listMethods</methodName></methodCall>`,
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/xmlrpc.php", strings.NewReader(body))
|
||||||
|
creds := ExtractXMLRPCCredentials(req)
|
||||||
|
if creds.HadFields {
|
||||||
|
t.Errorf("body %q: HadFields = true, want false", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteXMLRPCFault(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusOK, http.StatusMethodNotAllowed} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteXMLRPCFault(rec, status, 403, XMLRPCFaultBadLogin)
|
||||||
|
|
||||||
|
if rec.Code != status {
|
||||||
|
t.Errorf("status = %d, want %d", rec.Code, status)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/xml") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/xml(真实 WordPress 不会回 HTML)", ct)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "<int>403</int>") || !strings.Contains(body, XMLRPCFaultBadLogin) {
|
||||||
|
t.Errorf("body = %q, want 含 faultCode 与文案", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "<html") {
|
||||||
|
t.Error("body 里不能出现 HTML —— 这正是要修的破绽")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteXMLRPCMethods(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteXMLRPCMethods(rec)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
// 枚举类工具靠 system.multicall 判断「对面能收字典」。
|
||||||
|
if !strings.Contains(body, "<string>system.multicall</string>") {
|
||||||
|
t.Errorf("body 缺 system.multicall:%q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<methodResponse><params>") {
|
||||||
|
t.Errorf("body 不是 methodResponse:%q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user