問題現場:一個「成功」卻收不到信的忘記密碼
某個週一下午,QA 回報:在一個 Flutter + Strapi 架構的 App 上,用測試帳號 testuser@example.com 點「忘記密碼」,畫面跳出 Success: Reset password link sent successfully on your email account.,但信箱(包含垃圾郵件匣)一封信都沒有。
直覺先排除幾個明顯可能:SMTP 設定壞掉、用戶被 block、信件被 Gmail 擋。但真正的答案比這些都有趣 — 這不是 bug,是 Strapi 一個刻意的安全設計,只是 App 前端把它翻譯錯了。
kubectl 診斷:20ms 與 800ms 的兩種人生
進 Strapi pod 撈 log,找 forgot-password 請求:
kubectl logs -n default strapi-prod-xxxxxxxxx-xxxxx --since=6h \
| grep -E 'forgot-password|Reset password URL'
抽出的關鍵幾行:
04:04:52 info: Reset password URL generated: https://example.com/...
04:04:53 http: POST /api/auth/forgot-password (948 ms) 200
05:15:44 http: POST /api/auth/forgot-password (20 ms) 200
05:16:53 info: Reset password URL generated: https://example.com/...
05:16:54 http: POST /api/auth/forgot-password (664 ms) 200
05:17:30 http: POST /api/auth/forgot-password (26 ms) 200
一眼看出兩種節奏:800ms 級別的請求伴隨「Reset password URL generated」log,而 20-30ms 的請求則完全沒有。同一支 API,為什麼耗時差 30 倍?
因為 800ms 那條走完整個流程:查 DB、產 JWT token、寫回 user 欄位、呼叫 SMTP 寄信。20ms 那條只做到「查 DB → 找不到 → 直接回 200」。對不存在的 email,Strapi 選擇安靜地什麼都不做,但仍然回 200 Success。
兩個分支回傳的 HTTP status 與 body 完全一致,差別只在時間 — 這是刻意的設計(稍後解釋原因)。
Email Enumeration 的價值:為什麼一份名單值錢
「找不到就直接回 404 不就好了?」這正是 email enumeration attack 的攻擊入口。
假設 API 會誠實告訴你「此 email 未註冊」,攻擊者可以拿一份幾億筆的外洩 email 清單,逐筆打 /api/auth/forgot-password,從回應差異篩出「這個網站有帳號的人」。這份精選名單的殺傷力,比原本的大雜燴清單高一個數量級。它直接變成兩種攻擊的油料:
Phishing(釣魚攻擊):攻擊者拿著已確認的用戶名單,可以寄出上下文精準的假信 — 「你的 XX App 帳號異常,請立即點此重設密碼」。因為攻擊者已經知道收件人是真用戶,話術可以具體到讓人放下戒心。
Credential Stuffing(撞庫攻擊):許多人跨站重複使用同一組密碼。當 X 網站外洩「email + password」清單,攻擊者會拿去 Y 網站逐一嘗試登入。撞庫效率取決於「清單裡在 Y 網站有帳號的比例」。Enumeration 把這個比例從「全人口」升級到「已註冊用戶」,命中率可能差 10 倍。
攻擊者的工具鏈:不是腳本小子的手工活
以下工具都是 OWASP、Akamai State of the Internet Report、Krebs on Security 等公開資安來源長期追蹤的對象。很多本身有合法的紅隊演練或安全意識訓練用途,只是同樣會被濫用。了解它們的運作是防禦的前提。
Phishing:從 Gophish 到 Evilginx2 的技術演進
Gophish — 可管理的釣魚活動平台
Gophish 是開源的釣魚活動管理系統,原始定位是企業做員工安全意識訓練。有了 enumeration 得到的名單後,攻擊者只要透過 API 灌入目標、套用假信範本、排程發送:
# 匯入目標名單
curl -X POST https://gophish.local/api/groups/ \
-H "Authorization: Bearer API_KEY" \
-d '{"name":"confirmed-users","targets":[
{"email":"victim1@example.com","first_name":"Alice"},
{"email":"victim2@example.com","first_name":"Bob"}
]}'
# 建立 landing page(偽裝的登入頁)
curl -X POST https://gophish.local/api/pages/ \
-d '{"name":"reset-page","html":"<form action=\"/track\">...</form>",
"capture_credentials":true,"capture_passwords":true}'
Gophish 會自動嵌入追蹤 token 到每封信的連結,記錄:誰打開信、誰點了連結、誰輸入帳密。這就是釣魚的工業化:不再是一封一封手工寄信,而是清單進 → dashboard 出。
Evilginx2 — 繞過 2FA 的反向代理
光偷密碼已經過時,現代帳號多半開 2FA。Evilginx2 解決了這件事。它是一個反向代理式釣魚框架:受害者以為自己在與真網站互動,實際上瀏覽器連到攻擊者的代理,代理再即時把請求轉到真網站。整條流量對受害者看起來都正確,包括 TOTP 驗證頁面、SMS 驗證碼畫面,因為它們真的就是真網站回應的內容。
關鍵是:當受害者完成 2FA,真網站會回一個已驗證的 session cookie 給代理。Evilginx2 在中間攔下這個 cookie,攻擊者直接載入到自己的瀏覽器就能登入受害者帳號 — TOTP、SMS、App authenticator 全部失效,因為整個 session 被接管了。
Evilginx2 用「phishlet」定義目標網站的釣魚邏輯(哪些 cookie 要攔、哪些 URL 要 rewrite)。社群有公開的 phishlet repository 涵蓋 Microsoft 365、Google、Okta、LinkedIn 等,幾小時就能架起一個能騙過多因素驗證的 proxy。
這是為什麼 FIDO2 / passkey 比 TOTP 重要 — 它們對特定 domain 做密碼學綁定,MITM proxy 冒不出來。
Phishing-as-a-Service:訂閱制犯罪經濟
更值得注意的趨勢是釣魚工具的 SaaS 化。Caffeine(Mandiant 2022 年曝光)、EvilProxy(Proofpoint 2022)、16shop(Group-IB 與 INTERPOL 2023 合作端掉)都是典型 PhaaS:攻擊者只要刷 $200-$2000 月費,平台提供完整的 landing page 模板、反向代理、受害者 dashboard、反偵測模組(偵測是否來自資安公司 IP 就不顯示釣魚頁)。
攻擊門檻被壓到極低:不再需要寫程式或懂資安,只需要一份目標名單。這回到為什麼 enumeration 這麼重要 — 它是整個犯罪產業鏈最上游的原料。
Credential Stuffing:自動化憑證測試的工業
OpenBullet 2 — Config 驅動的撞庫引擎
OpenBullet 2 是公開的自動化憑證測試框架,同樣有合法的企業內部測試用途。地下論壇流通的是它的「config 檔」 — 描述特定目標網站的登入流程。一個典型 config 邏輯(以 YAML 表示):
# 目標 endpoint
TARGET:
url: https://app.example.com/api/auth/local
method: POST
headers:
Content-Type: application/json
User-Agent: <RANDOM_UA> # 每次輪替
payload: '{"identifier":"<USER>","password":"<PASS>"}'
# 輸入:combolist 每行 email:password
INPUT: combo.txt
# 成功條件
SUCCESS_IF:
- status == 200
- body contains "jwt"
- body contains "user.id"
# 失敗條件(區分 ban vs 密碼錯)
BAN_IF:
- status == 429
- status == 403
FAIL_IF:
- status == 400
- body contains "Invalid"
# 代理輪替 + CAPTCHA bypass
PROXY: residential-pool.txt
CAPTCHA: 2captcha_api_key: <KEY>
# 成功後要做的事(取得個資 → 提高憑證價值)
ON_SUCCESS:
- capture: user.email, user.name, user.creditLastFour
Config 決定這套框架有多危險。成熟的 config 能區分「帳號被鎖」與「密碼錯」、能辨識 CAPTCHA 挑戰並自動丟給 solver、成功後還會抓取帳戶內的個資(姓名、部分信用卡號、餘額)讓憑證在地下市場賣更高價。
Sentry MBA 是更早期的同類工具,至今仍在論壇流通。差別在 UI 年代感更強,但核心機制相同。
撞庫的經濟學:為什麼「看起來不可能」變成日常
撞庫不是「有人很無聊試 100 萬個密碼」,它是有 ROI 計算的生意。拆解成本結構:
CAPTCHA solver:2Captcha、Anti-Captcha、CapSolver 這類服務,用人工 + ML 組合解決 reCAPTCHA / hCaptcha。每 1000 題約 $1 美金(reCAPTCHA v2)到 $3(reCAPTCHA v3),API 回應時間 10-30 秒。
住宅代理(residential proxy):IP 從真實家庭網路來,不是雲端 IP,比較不容易被 rate limit。Bright Data、Oxylabs、Smartproxy 這類服務每 GB 約 $3-$15。一次撞庫請求約 2-5 KB,1 GB 可以跑約 20-50 萬次嘗試。
算一筆帳:跑 100 萬次撞庫(假設只有 30% 觸發 CAPTCHA):
- CAPTCHA 成本:30 萬題 × $1/1000 = $300
- Proxy 成本:100 萬 × 3 KB ÷ 1000 GB × $5 = $15
- 基礎設施(VPS + OpenBullet):$20
- 總成本約 $335
產出:即使命中率只有 0.1%(業界撞庫平均落在 0.1-2%),也能拿到 1000 組有效憑證。地下市場的憑證價格:一般電商帳號 $1-5、串流媒體(Netflix/Disney+)$3-10、金融類 $20-100。即使只賣最低價,ROI 仍然是 3-10 倍。
這解釋了為什麼以下事件會發生:
- Disney+(2019 年 11 月):服務上線 24 小時內,大量帳號就出現在地下論壇以 $3-11 美金販售(BleepingComputer 報導)。Disney+ 本身沒被駭,是攻擊者拿其他服務外洩的 combolist 撞出來的。
- Zoom(2020 年 4 月):超過 50 萬筆帳號在暗網流通(Cyble 報告),同樣是撞庫成果。
- PayPal(2022 年 12 月):官方揭露約 34,942 個帳號被撞庫入侵(PayPal 通知),攻擊者存取了用戶姓名、地址、社會安全號碼等。
這些案例的共同點:被攻擊的公司本身沒有外洩資料。他們只是沒有足夠強的撞庫防禦,而攻擊者已經透過別處的 breach 拿到 combolist。
防禦對應表:攻擊工具 → 反制措施
| 攻擊鏈環節 | 主要工具 | 防禦層 |
|---|---|---|
| 目標名單建立 | 利用 enumeration 篩選 | ✅ Anti-enumeration:API + UX 對回應同樣沉默 |
| 假信發送 | Gophish、PhaaS | ✅ SPF / DKIM / DMARC、郵件來源驗證、用戶教育 |
| 偷 session(繞 2FA) | Evilginx2、EvilProxy | ✅ FIDO2 / Passkey(對 domain 做密碼學綁定,proxy 騙不出來) |
| 自動化登入測試 | OpenBullet、Sentry MBA | ✅ Rate limit + 異常登入偵測 + device fingerprint |
| 已外洩密碼撞庫 | Combolist | ✅ 註冊/改密碼時呼叫 Pwned Passwords API,擋掉已知洩漏的密碼 |
| CAPTCHA 繞過 | 2Captcha、CapSolver | ✅ 行為分析型挑戰(reCAPTCHA v3、Arkose Labs)而非靜態圖形驗證碼 |
Anti-enumeration 是這張表最前面的那一格 — 掐斷這個源頭,後面每一層的攻擊成本都會被顯著拉高。
問題其實在 App 文案
既然 API 層必須保持沉默,前端的文案就不能擅自斷言「信件已寄出」。原本的實作是這樣:
if (response.ok) {
Get.snackbar(
'Success',
'Reset password link sent successfully on your email account.',
);
}
這句話把 HTTP 200 翻譯成「信件已寄出」— 是一個資訊揭露錯誤,把 Strapi 刻意隱藏的資訊(email 是否存在)從 UX 反推出來。如果用戶在此看到 Success 卻收不到信,邏輯上只剩兩個可能:email 不存在或 SMTP 故障。攻擊者同樣能從這個看似正常的回應,配合多個測試帳號的行為差異,重建 enumeration。
正確做法是讓文案配合 API 的曖昧性:
Get.snackbar(
LocalizationService.translate('forgotPasswordInfoTitle'),
LocalizationService.translate('forgotPasswordInfoMessage'),
);
i18n key 的內容改為條件句:
{
"forgotPasswordInfoTitle": "提示",
"forgotPasswordInfoMessage": "若此信箱已註冊,我們將寄送重設密碼連結,請至信箱(包含垃圾郵件匣)查收。"
}
Title 從 Success 改成中性的「提示」,因為 HTTP 200 不等於成功寄出 — 訊息應該對用戶和攻擊者同樣模糊。加上「包含垃圾郵件匣」對真有寄信的情境也更實用。
結語:威脅模型是一條鏈,前後端共同守護
這個 debug 過程最值得記下來的原則是:後端刻意隱藏的資訊,前端不能用 UX 揭露回來。Strapi 用 200 回應與耗時差距壓平了「存在 vs 不存在」的訊號,但只要前端訊息太具體(「link sent to your email」),enumeration 的窗口就又被打開。
Gophish、Evilginx2、OpenBullet、PhaaS 訂閱服務 — 這整套攻擊鏈的威脅模型,建立在「能拿到一份高品質已註冊清單」這個前提上。Anti-enumeration 看起來只是改一行 UI 文案,實際上擋掉的是整個犯罪產業鏈最上游的原料供應。撞庫攻擊 $335 的成本就能產 3-10 倍 ROI 的現實,意味著這不是「會不會被攻擊」的問題,而是「攻擊成本能被拉到多高」。
下次遇到類似的「API 看起來正常、但行為不合預期」的現象,看 log 的時間分佈常常比看 HTTP status 更有線索。20ms 與 800ms 的差距,已經告訴你真相了。
