重設密碼是幾乎所有網站都有的機制,最常見的是透過 email 寄送一個重設密碼的連結,點進去連結以後就可以幫這個帳號設定新的密碼。這個機制雖然常見,卻有一些關於安全性的小細節要注意。
這次要來寫的,是我在今年六月底的時候回報的由重設密碼功能引起的帳號奪取(account takeover)漏洞。
Matters News 是一個去中心化而且應用加密貨幣相關技術的寫作社群平台,我以前曾經寫過一篇《防止 XSS 可能比想像中困難》分享我怎麼找到他們的 XSS 漏洞。
在談這次的漏洞之前,我們先來看看一般重設密碼的功能是如何設計的。
話說,如果你好奇為什麼密碼只能重設,而不是「找回密碼」,可以先參考這一篇:為什麼忘記密碼時只能重設,不把舊密碼告訴我?
# 典型的重設密碼功能
基本上忘記密碼的流程都大同小異,不外乎就是:
- 使用者填入當初註冊帳號時的 email
- 系統寄送重設密碼的連結到第一步的 email
- 使用者點擊信中的連結,前往重設密碼頁面
- 使用者輸入新的密碼,送出表單
- 密碼重設成功,使用者可以利用新的密碼登入
這個流程如果要是安全的,就必須確保:
- 系統寄送信件的目的地,真的是使用者本人的 email
- 重設密碼的連結無法被猜出來
先來談談第一點,有些人會想說:「這不是很基本嗎?我填入 user@example.com,當然是把信件寄送給 user@example.com 啊!」
不不不,這可不一定,有些系統在接收 email 這個參數時,居然可以是一個陣列,所以你可以填入:["victim@example.com", "attacker@example.com"]
,然後 attacker 就會收到 victim 的重設密碼信件!
聽起來很不可思議,但確實發生過:
再來談談第二點,如果重設密碼的連結可以被猜出來,就代表攻擊者可以代替 user 重設密碼。
或更精確地說,重設密碼的 token 不能被猜出來。
舉例來說,如果重設密碼的連結長得像這樣:https://example.com/reset-password?token=user@example.com
,那我就可以幫任何人重設密碼了,顯然很不安全。
所以一般來說,token 都會產生一組 unique id,例如說 UUID v4,長得像這樣:2c59d26a-f99a-425e-bb69-91e7c6ffe54d
,有 128 個 bit,也就是 2^128 種組合,要猜中的可能性微乎其微。
若是產生的 token 強度不夠的話,就會提高暴力破解成功的機率。
不過要特別注意的是儘管如此,有些系統的漏洞在其他地方,例如說寄送 email 時,重設密碼的網址或是 host 是可以控制的!像是只要在 request header 裡面加上 X-Forwarded-Host: abc.com
,重設密碼的連結就會變成:https://abc.com/reset-password?token=...
,使用者收到信以後如果不小心點了連結,這個 token 就會發到攻擊者的伺服器去,他一樣可以利用這個 token 來重設密碼,進行帳號奪取。
這也是有發生過實際案例的:
除了這些以外,其實還有許多小細節要注意,例如說:
- 重設密碼的 token 應該只能使用一次
- 重設密碼的 token 應該要有過期時間
- 如果使用者產生了新的重設密碼 token,舊的應該要廢棄
之所以會有這些限制,也是為了降低暴力破解的可行性。
假設時間無限的話,理論上暴力破解絕對可以猜出 token,因此防止暴力破解的重點有兩個,一個是盡量增加破解所需的時間,讓這個時間大到超過一千年或更久,第二個重點是限制時間,而實際的方法有幾種,例如說:
- 把基數加大,例如說六碼數字的可能性只有一百萬種,但若是換成六碼英文加數字就有 20 億種,要猜測的數量多了 2000 倍,就需要花更多時間
- 把可猜測的時間降低,例如說 300 秒後 token 就會過期,假設可能性有 1 億種,那每秒必須要猜 30 萬種才能保證猜中
接著,我們就來看一下 Matters 的重設密碼機制出了什麼事情。
# Matters 的重設密碼機制
下圖是 Matters 重設密碼的畫面,一樣是填入 email,然後寄一個連結到信箱:
重設密碼的 request 長這樣:
{
"operationName":"SendVerificationCode",
"variables":{
"input":{
"email":"user@example.com",
"type":"password_reset",
"redirectUrl":"https://matters.news/forget?email=user%40example.com"
}
}
}
這是我收到的連結:https://matters.news/forget?email=user%40example.com&code=UYBQ912rhd_9s3TfywZnk1kQl6PCaDjPlXuNX3Df&type=password_reset
從這邊其實我們就可以發現第一個問題,就是看起來收到的連結的前半段是由 redirectUrl
所控制的。如果我們攔截 request 以後修改 redirectUrl
參數,修改成 https://cymetrics.io
,會發現信箱內收到的連結確實變成由 https://cymetrics.io
開頭!
如此一來,就有了前面所說的漏洞,如果使用者點了信中的連結,那我們的 server 就會收到 token,就可以幫使用者重設密碼。
接著我們看一下有沒有暴力破解的問題,token 本身看起來複雜度滿高的,長度是 40 個字,由大小寫英文字母、數字以及底線所組成。
雖然看起來應該不會有問題,但 Matters 是開源的,所以我們可以直接去看一下 SendVerificationCode
是如何實作的,程式碼在這邊:https://github.com/thematters/matters-server/blob/v3.19.0/src/mutations/user/sendVerificationCode.ts
我們關注的是產生 code 的地方,主要是這一段:
// insert record
const { code } = await userService.createVerificationCode({
userId: viewer.id,
email,
type,
strong: !!redirectUrl, // strong random code for link
})
而這個 userService.createVerificationCode
的程式碼則是在這裡:https://github.com/thematters/matters-server/blob/v3.19.0/src/connectors/userService.ts#L1500
createVerificationCode = ({
userId,
email,
type,
strong,
expiredAt,
}: {
userId?: string | null
email: string
type: string
strong?: boolean
expiredAt?: Date
}) => {
const code = strong ? nanoid(40) : _.random(100000, 999999)
return this.baseCreate(
{
uuid: v4(),
userId,
email,
type,
code,
expiredAt:
expiredAt || new Date(Date.now() + VERIFICATION_CODE_EXIPRED_AFTER),
},
'verification_code'
)
}
從這邊我們看到一個重點,就是在系統內 code 的產生分兩種類型,strong 的是 nanoid(40)
,不 strong 的則是 100000~999999 的六碼數字,而這個 strong 的參數是由有沒有傳入 redirectUrl
而決定的。
也就是說,如果我們在建立 reset password 的驗證碼時把 redirectUrl
參數拿掉,code 就會從原本的 40 個字,瞬間降低成六位數的數字!
而驗證碼的過期時間 VERIFICATION_CODE_EXIPRED_AFTER
是五分鐘,也就是 300 秒,900000/300 = 3000,只要我們一秒能發送 3000 個 request 到 server,就能暴力破解 reset password 的 token,進而奪取使用者帳號。
但其實這樣的說法不太精確,因為我們可以發 3000 個,不代表 server 就可以處理 3000 個,所以還要看 server 能接受的 request 數量,而且在這之前,還有另一個限制要繞過。
# Rate limiting
增加暴力破解難度的方法之一就是 rate limiting,許多網站或是 WAF 都有這個功能,能夠阻止短時間內大量的 request。
而 Matters 的 rate limit 是用 nginx 來處理,程式碼在這裡:https://github.com/thematters/matters-server/blob/v3.19.0/.ebextensions/rate-limit-connections.config
limit_req_zone $http_x_forwarded_for zone=application:16m rate=5r/s;
limit_req zone=application burst=20 nodelay;
limit_req_status 429;
limit_conn_status 429;# pass real IP from client to NGINX
real_ip_header X-Forwarded-For;
set_real_ip_from 0.0.0.0/0;server {
# set error page for HTTP code 429
error_page 429 @ratelimit;
location @ratelimit {
return 429 '["Connection Limit Exceeded"]\n';
}listen 80; # 底下省略
}
nginx 的 rate limit 基本上都是以 IP 為主,如果真的想繞過的話可以嘗試看看 IP rotate,一個簡單的方式是去 AWS 上開很多 API gateway 當 proxy,然後你就有了一堆不同的 IP 可以輪流使用。
但這邊還用不上這個技巧,因為在設定中可以看到它使用了 $http_x_forwarded_for
這個參數,如果沒有管理好的話,可以自己傳入 X-Forwarded-For
來偽造任意 IP,藉此繞過 rate limit 的限制。
而 Matters 在這邊顯然沒有設置好,因此 rate limit 算是虛設。
做到這邊,只要我們能夠一秒發送 3000 個 request 就能做出 POC,證明攻擊確實可行,但還有沒有其他的方法,能讓這個數字再小一點呢?
# 同時存在的驗證碼
在開頭的時候有提過重設密碼有一些小細節要注意,像是:
- 重設密碼的 token 應該只能使用一次
- 重設密碼的 token 應該要有過期時間
- 如果使用者產生了新的重設密碼 token,舊的應該要廢棄
而 Matters 前兩點都做了,就是唯獨第三點沒有做好。從程式碼中我們可以看出當新的驗證碼建立時,舊的並不會刪除或是標記為廢棄。
這會有什麼影響呢?我們來算個簡單的數學吧!
驗證碼的組合總共有 900,000 種,我們有 300 秒的時間可以攻擊,如果可以在這段時間內發送 900,000 個 request 而且 server 也有處理,就一定可以猜到重設密碼的驗證碼。
若是我們改成先不要猜,而是先發送 1000 次的重設密碼請求,因為舊的驗證碼依然有效,所以這時我們猜一次,猜中任何一組的機率就是 1000/900000 = 1/900,變成原本的 1000 倍。
猜 1000 次的話,猜中的機率就是「1 - 每次都猜不中的機率」,大約是 1 - (899/900)^1000
= 67%,如果猜到 5000 次的話,猜中的機率就是 1 - (899/900)^5000
= 99.6%。
也就是說,我們只要發送 1000 個重設密碼的請求外加 5000 個確認驗證碼的請求,總共 6000 個請求,就有 99.6% 的機率可以正確猜到至少一組驗證碼!
可以寫個簡單的小程式來驗證我們的機率計算:
const _ = require('lodash')
const rounds = 100000 // 跑十萬輪取平均
const guessRounds = 5000 // 猜 5000 次
const tokenCount = 1000 // 1000 個合法驗證碼
let winCount = 0
for(let r=0; r<rounds; r++) {
let ans = {}
for(let i=0; i<tokenCount; i++) {
ans[_.random(100000, 999999)] = true
}
let isWin = false
for(let i=0; i<guessRounds; i++) {
const guessNumber = _.random(100000, 999999)
if (ans[guessNumber] === true) {
isWin = true
break
}
}
if (isWin) winCount++
}
console.log(winCount*100 / rounds)
// 輸出:99.626,我們算出的機率差不多
原本要發 900,001 個 request 才能有 100% 的機率可以猜中,現在只要犧牲一點點正確性,變成 99.6% 的機率猜中,就可以把 request 數量降低到 6000 次,低了 150 倍!
原本我們在五分鐘內一秒要發 3000 個 request,現在一秒只要 20 個 request 就好(其實這邊也只是大略計算,因為是有順序性的,必須要等 1000 個發送驗證碼的 request 結束後才能開始猜,而這 1000 個可能又會花個幾秒,但這邊為了方便計算先忽略不計,對整體的影響不大)
就因為這個重設密碼的小缺陷,沒有把上一組驗證碼淘汰掉,導致我們可以產生多組驗證碼,大幅降低暴力破解的難度。只要能在五分鐘內發送 6000 個 request,就有 99.6% 的機率可以更改一組帳號的密碼。
由於這是重設密碼功能,所以改完密碼之後你就可以直接用他的身份登入系統,達成帳號奪取,把其他人的帳號都變成是你的。如果想要擴大影響性的話,可以奪取管理員的帳號,就有機會進入管理後台進行更多操作。
# 建議修補方式
第一個要修的地方是重設密碼的小缺陷,在使用者產生新的驗證碼時,舊的應該要被淘汰掉,保證永遠只有最新的一組可以通過驗證,這樣攻擊者猜中的機率就永遠是 1/n,而不會像上面的範例那樣,可以把機率提高 1000 倍或更多倍。
第二個則是驗證碼的產生不該由 redirectUrl
參數來決定,而是應該由驗證碼的 type 來決定,如果是 reset password,就一定要是 strong,這樣子就會用 nanoid(40)
來產生,猜中的機率就變得微乎其微,大幅降低暴力破解的可行性。
第三個是 redirectUrl 不應該從前端傳入,而是直接把網址寫在後端。如果真的要從前端傳入,那後端要做好完善的檢查,確保 redirectUrl 傳入的是合法的路徑,而不是讓攻擊者可以傳入任意網址(不過如果攻擊者能結合 open redirect,又是另外一回事了)
最後一個則是 nginx 的 rate limit 限制不應該用 X-Forwarded-For
來決定,就算真的要用這個,也要確保它的值無法由攻擊者自行傳入。
# 總結
重設密碼機制看似簡單,但其實一不小心還是有可能做出有漏洞的機制,導致攻擊者有機可趁,在 HackTricks 上有個頁面專門在講 reset password 可能會有的問題:Reset/Forgotten Password Bypass,除了這篇提到的以外還有更多的問題,整理得很詳細,很值得參考看看。
如果你以為只有一般的網站會有這種問題,那你就錯了。一名資安研究員 Laxman Muthiyah 發現可以用 concurrent 的方式繞過 Instagram 的 rate limiting,成功發送 200 個 request,並且用 1000 台機器產生出 20 萬個 request,就有 20% 的機率攻擊成功。
只要有 5000 台機器,就能拿下任何帳號。5000 台聽起來很多,成本應該很高對吧?但如果善用 cloud service 的話,他估算可能只需要 150 美金左右即可達成一次攻擊(因為算小時收費,只要開一兩個小時即可)
而他也在去年七月用同樣的手法繞過微軟的 rate limiting,並且拿到 5 萬美金的獎金。
像這種漏洞如果能成功被利用,就能夠直接奪取他人的帳號,影響很大,需要多加注意相關的安全性。看到這裡,大家不妨也檢查一下自家的重設密碼機制是否安全。
最後,這次找到漏洞以後一樣有回報給 Matters,底下是完整時間軸:
2021-06-24
回報漏洞給 Matters2021-06-25
收到 Matters 回覆,確認漏洞存在2021-08-20
Matters 修復部分功能,產生新的驗證碼時會淘汰舊的2021-08-26
Matters 確認漏洞評級為 High,獎金為 150 USD2021-10-28
關心後續修復狀況,確認是否修補完畢2021-11-30
Matters 增強 non-strong 的驗證碼基數2021-12-02
文章初稿完成,與 Matters 確認是否可發布2021-12-21
Matters 確認問題已修復完畢以及可發布文章
标签
推荐
Discussion(login required)