問題現場:一個「成功」卻收不到信的忘記密碼

某個週一下午,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。

Mermaid Diagram

兩個分支回傳的 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 倍。


攻擊者的工具鏈:不是腳本小子的手工活

以下工具都是 OWASPAkamai State of the Internet ReportKrebs 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 被接管了。

Mermaid Diagram

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 solver2Captcha、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 的差距,已經告訴你真相了。