如果你有自製的登入系統,「忘記密碼」功能是不可或缺的。但這功能如果設計不良,非常容易被駭客拿來利用,把別人帳號給「妥協掉(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 一樣?
之所以會發生資安問題,在於實作上的細節。
重置密碼注意事項
我們把重置密碼流程拆成三個部分:請求階段(第 1 步驟)、驗證階段(第 2 ~ 4 步驟)、授權階段(第 5 ~ 7 步驟)。
並且分成三個角色:註冊時的信箱(Gmail)、服務網站(Steam 平台)和使用者(想買夏日特賣但忘記 Steam 密碼的隔壁同事)。
其中我們需要產生一個密碼重置專用的 Token(驗證使用者本人),做成一個包含 Token 的連結,寄到使用者註冊時(已驗證過)的信箱。
當有人「帶這個 Token」連結到 Steam,Steam 平台可以相信這個人就是該使用者,讓他重置密碼(因為這個 Token 只有使用者才有辦法取得)。
接著來看一下資安注意事項:
- 只相信看過的足跡
- 避免請求階段資訊洩漏
- 請求次數限制(Rate Limit)
- 通知使用者「已更新密碼」
- 成功改完密碼後,所有之前的連線(Session)都應該失效
只相信看過的足跡
足跡包含:已驗證的信箱、手機,使用者登入過的 IP、瀏覽器等等。
7pay 的例子,就是這裡沒做好。它讓使用者可以選擇「其他的信箱」,這並無法驗證其他信箱也是使用者所擁有的。
避免請求階段資訊洩漏
在請求階段、重置密碼的頁面,可能會有「資訊洩漏」的問題,像是洩漏該信箱是否被註冊過、帳號列舉(Account Enumeration)、甚至洩漏重置密碼的 Token(這 … 我原本也不太相信)。
可以像 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 也可以攻擊,之後再聊吧)
如果這篇文章有幫助到你,或你還想知道什麼內容,都歡迎持續聯絡我們!