如果你有自製的登入系統,「忘記密碼」功能是不可或缺的。但這功能如果設計不良,非常容易被駭客拿來利用,把別人帳號給「妥協掉(Compromised)」。

最近剛好有個例子,可能和重置密碼有關 - 7-Eleven 的 7pay。

我們來分析一下日本新聞,猜測可能的攻擊手法,並且來討論「重置密碼」這功能到底該注意些什麼。

7pay 發生什麼事?

日本 7-Eleven 今年七月推出電子支付 APP「7pay」,但才剛上線不久就傳出許多使用者帳號被盜用。在日本 CNET 的報導統計使用者損失甚至高達 5,500 萬日幣

雖然現在真正原因還不清楚,但在 7pay 標榜「輕鬆註冊」、「最快只要兩次點擊」即可註冊的方便下,可能為了方便性而犧牲了更多安全性。

猜測攻擊手法

一位日本 IT 記者分析了 7pay APP 的資安問題,他指出這個 APP 缺乏「二階段驗證(2FA)」,並且懷疑駭客是利用已知的密碼清單進行攻擊。

但有使用者指出他們密碼是獨立不重複的,排除了密碼清單攻擊的可能性,真正原因來自 APP 本身漏洞的可能性更大。

重置密碼設計缺陷

報導中還提到了一個漏洞:「重置密碼」設計缺陷,這個缺陷導致攻擊者可以重置別人的密碼:

您可以將密碼重置的郵件發送到另一個信箱地址

在您輸入生日和電話的欄位都是沒問題的,但旁邊有一個神秘的輸入項目:「目的地信箱地址」,您可以將密碼重置的郵件發送到與您註冊的地址不同的地址,我實際嘗試過。

所有必填資訊是「生日、電話號碼、信箱地址」,然後設置「目的地信箱地址」。在 iOS 版本中,您可以在會員註冊時無需填寫生日。在這種情況下,「2019/1/1」會自動輸入。

從以上資訊來看,我只要嘗試用「公開來源情報(OSINT,Open-Source Intelligence)」來蒐集使用者的生日、電話和信箱,我就可以改他的帳號,因為攻擊者可以設定目的地信箱地址來重置密碼

而 iOS 版本會預設生日為「2019/1/1」,所以只需要電話和信箱,就有機會成功「妥協掉」你的帳號。

重置密碼?

這個功能看似簡單,但如果是你會怎麼設計「重置密碼」?

把密碼重置連結寄給使用者,使用者點擊並且重置。這流程跟 7pay 一樣?

之所以會發生資安問題,在於實作上的細節。

重置密碼注意事項

fogot-password

我們把重置密碼流程拆成三個部分:請求階段(第 1 步驟)、驗證階段(第 2 ~ 4 步驟)、授權階段(第 5 ~ 7 步驟)。

並且分成三個角色:註冊時的信箱(Gmail)、服務網站(Steam 平台)和使用者(想買夏日特賣但忘記 Steam 密碼的隔壁同事)。

其中我們需要產生一個密碼重置專用的 Token(驗證使用者本人),做成一個包含 Token 的連結,寄到使用者註冊時(已驗證過)的信箱。

當有人「帶這個 Token」連結到 Steam,Steam 平台可以相信這個人就是該使用者,讓他重置密碼(因為這個 Token 只有使用者才有辦法取得)。

接著來看一下資安注意事項:

  • 只相信看過的足跡
  • 避免請求階段資訊洩漏
  • 請求次數限制(Rate Limit)
  • 通知使用者「已更新密碼」
  • 成功改完密碼後,所有之前的連線(Session)都應該失效

只相信看過的足跡

足跡包含:已驗證的信箱、手機,使用者登入過的 IP、瀏覽器等等。

7pay 的例子,就是這裡沒做好。它讓使用者可以選擇「其他的信箱」,這並無法驗證其他信箱也是使用者所擁有的。

避免請求階段資訊洩漏

在請求階段、重置密碼的頁面,可能會有「資訊洩漏」的問題,像是洩漏該信箱是否被註冊過、帳號列舉(Account Enumeration)、甚至洩漏重置密碼的 Token(這 … 我原本也不太相信)。

可以像 Linode 他們重置密碼的回應,不管你打存在的信箱或不存在的,回應始終如一

linode

請求次數限制(Rate Limit)

一般來說你不可能每 15 分鐘忘一次密碼,限制請求次數(Rate Limit)是為了防止密碼重置 Token 有設計缺陷時的防禦方法(後續會提)。

像是在請求階段,重置密碼的申請次數限制。或是網站裡「修改密碼」功能,每日修改密碼次數上限、最多每 15 分鐘改兩次密碼等。

通知使用者「已更新密碼」

盡我們的義務通知使用者,讓使用者可以發現在非正常情況下的變更,並且回報給我們知道。

成功改完密碼後,所有之前的連線(Session)都應該失效

如果攻擊者駭入你的 Steam 帳號,你換了密碼之後,攻擊者卻沒有被登出,那 … 換密碼幹嘛?

Token 實作注意事項

Token 實作百百種,我們來看要注意的地方:

  • Token 綁定唯一使用者
  • Token 產生方法不會被猜到
  • 防暴力破解 Token
  • Token 有效期限
  • 成功改完密碼後,所有之前的 Token 都應該失效
  • 僅限一次(One-Time-Use Token)

Token 綁定唯一使用者

有天 Bob 忘記 Steam 密碼,他去申請重置密碼並且收信,信裡面重置密碼連結如果格式是:https://xxx/?token=dGhpcyBpcyB0b2tlbiBpZCAxMjM0&user=bob

如果 Bob 手癢把 user=bob 改成 user=eve 會發生什麼事?(Bob 的復仇?)

對於重置密碼 Token,只允許重置唯一且綁定的使用者。

Token 產生方法不會被猜到

像是單純只用 base64(上面 Bob 例子就是這樣)、使用 time 當 Token(像是 md5(time()))等等,這些都是會被猜到的格式。

可以使用安全的亂數,並且長度越長越好。

防暴力破解 Token

有些 Token 不是寄到信箱,是寄簡訊到你的手機。並且為了要讓使用者方便手動輸入,可能會只有 6 位數的數字。

這樣的驗證方法如果沒有防止暴力破解(像是最多嘗試輸入 3 次驗證碼,且請求重置密碼有限制次數),6 位數字很輕易就可以暴搜出來。

Token 有效期限

為了防止忘記密碼誤點或「突然想起密碼」,導致 Token 佔資料庫位置、或增加日後駭客利用的風險,Token 通常需要設定有效期限。一般通常都幾十分鐘或一小時左右就足夠。

成功改完密碼後,所有之前的 Token 都應該失效

前面提到連線(Session)要撤銷,過去所產生的舊 Token 也要失效。

試想一個狀況:攻擊者駭入你的信箱後,立馬用 Steam 忘記密碼產生 10,000 組重置密碼 Token。等你取回自己的信箱和 Steam 帳號後,攻擊者可以用他庫存的 10,000 組 Token 再次把你的 Steam 密碼改掉。

為了防止這樣的狀況,除了像是加上「Token 有效期限」、「請求限制次數」,讓舊 Token 失效會是更直接的方法。

僅限一次(One-Time-Use Token)

單一個 Token 不能重複改兩次密碼,目的也是為了降低利用的風險。

推薦做法

請求階段使用 CAPTCHA、後端判斷請求次數限制

CAPTCHA 預防基本的自動化腳本申請,並且每次申請都返回一樣的內容(上面 Linode 的例子,請使用者去收信)。

但後端要根據申請的信箱是否超過次數限制,來決定是否要產生 Token。(你可以在超過次數限制時寄信告知使用者稍後再試,但不要在網站返回內容)。

JWT Token

可以參考這個網站的教學。

JWT 有很多好處:

  • 無法被算改:因為是用自己金鑰簽章的方式,雖然內容是明文,但只要攻擊者私自篡改就會被我們發現。
  • Token 綁定唯一使用者:你可以在內容填入使用者的信箱來綁定使用者。
  • 防暴力破解 Token:除非攻擊者知道你的金鑰,否則無法造出有效 Token。
  • Token 有效期限:和綁定使用者一樣,只要在內容填入過期時間,就可以依照這時間來判斷 Token 是否有效。

製作 One-Time-Use Token

一般我們用 JWT 產生重置密碼 Token 會像:

var secret = 'b3ce9f8dc790dfeaf27da03540cc0b2ffed662aa';  # our secret key
var payload = {
  email: "bob@bob.com",
  expiredTime: 1234567890,
  type: "ResetPassword"
};
var token = jwt.encode(payload, secret);

但為了讓 Token 可以:

  • 成功改完密碼後,所有之前的 Token 都應該失效
  • 僅限一次(One-Time-Use Token)

我們可以把使用者現在的密碼雜湊值和我們的金鑰一起當金鑰:

// generate token
var secret = 'b3ce9f8dc790dfeaf27da03540cc0b2ffed662aa';  // our secret key
var userHashedPassword = getHashedPassword("bob@bob.com");
var payload = {
  email: "bob@bob.com",
  expiredTime: 1234567890,
  type: "ResetPassword"
};
var token = jwt.encode(payload, secret + userHashedPassword);
// verify token
var secret = 'b3ce9f8dc790dfeaf27da03540cc0b2ffed662aa';  // our secret key
var userHashedPassword = getHashedPassword("bob@bob.com");
var payload = jwt.decode(req.body.token, secret + userHashedPassword);
// decode successfully if token is valid

所以一但 Bob 成功改成新密碼,舊的 Token 都會失效(因為舊的 Token 是用舊的密碼雜湊值簽章的),包含改密碼所使用的 Token 也會失效,所以每個 Token 也只能被使用一次而已。

更多細節

你能做的還有更多,像是前述的「更改密碼後,通知使用者和撤銷存在的連線」、確保 HTTPS 連線(HSTS)、幫使用者確認新密碼和前次密碼是不同的、和 Apple ID 一樣有很難記的忘記密碼問題等等。

當然我們只大概列舉一些常忽略的問題,實際上會因為產品商業需求而有不同。尤其是流程,有時候「方便性」要求極高的產品會犧牲一些安全性。

但至少這篇文章讓大家思考你會需要考慮哪些安全性問題,也許不需要每條都做,但要知道會承擔什麼風險。像是 7pay 事件這樣,一個價值上千萬甚至更多的資安風險。

我個人還有最後一個建議,與其自己存密碼,冒著可能會誤存明文密碼、流程設計缺陷等等問題,不如用「OAuth」簡單省事應該是個不錯的選擇。(雖然 OAuth 也可以攻擊,之後再聊吧)

如果這篇文章有幫助到你,或你還想知道什麼內容,都歡迎持續聯絡我們!