Hash 函式及其重要性
不時會爆出網站的伺服器和資料庫被盜取,考慮到這點,就要確保使用者一些敏感資料(例如密碼)的安全性。今天,我們要學的是 hash 背後的基礎知識,以及如何用它來保護你的 web 應用的密碼。
申明
密碼學是非常複雜的一門學科,我不是這方面的專家,在很多大學和安全機構,在這個領域都有長期的研究。
本文我試圖使事情簡單化,呈現給大家的是一個 web 應用中安全儲存密碼的合理方法。
“Hashing” 做的是什麼?
Hashing 將一段資料(無論長還是短)轉成相對較短的一段資料,例如一個字串或者一個整數。
這是通過使用單向雜湊函式來完成的。“單向” 意味著逆轉它是困難的,或者實際上是不可能的。
加密可以保證資訊的安全性,避免被攔截到被破解。Python 的加密支援包括使用 hashlib 的標準演算法(例如 MD5 和 SHA),根據資訊的內容生成簽名,HMAC 用來驗證資訊在傳送過程中沒有被篡改。
一個通常使用的 hash 函式的例子是 md5(),這也是當前在很多不同語言和系統中比較流行的:
import hashlib data = "Hello World" h = hashlib.md5() h.update(data) print(h.hexdigest()) # b10a8db164e0754105b7a99be72e3fe5
為了計算一個數據塊(這兒是 ASCII 字串)的 MD5
digest()
或者 hexdigest()
函式。本例使用的是 hexdigest()
方法來代替 digest()
,是因為為了更清晰地輸出而對結果進行了格式化。如果你能接受輸出二進位制的摘要值,那麼就用 digest()
。
使用 Hash 函式來儲存密碼
使用者註冊的過程通常是這樣的:
- 使用者填寫登錄檔單,包括密碼這一項
- web 指令碼將所有的資訊儲存在資料庫中
- 然而,密碼在儲存之前需要通過 hash 函式進行轉化
- 最原始版本的密碼並沒有儲存在任何地方,因此從技術上講它消失了
使用者登入的過程:
- 使用者輸入使用者名稱和密碼
- 指令碼用同樣的 hash 函式來轉化密碼
- 指令碼找到記錄在資料庫中的使用者資訊,讀取儲存 hash 之後的密碼
- 比較兩者的值,如果匹配了就完成了登入
注意原始密碼不會儲存在任何地方!那麼如果資料庫被盜,那麼使用者的登入資訊不會被盜,是嗎?答案是“根據情況來定”。讓我們看看一些潛在的問題:
問題1:Hash 衝突
當兩個不同的輸入資料產生相同的 Hash 結果時,這就發生了 Hash 衝突。發生的概率依賴於你所使用的函式。
如果利用呢?
作為例子,我使用一些老的指令碼,它們使用 crc32()
來 Hash 密碼。這個函式會產生 32 位整數的結果,這意味著僅僅有2^32 (i.e. 4,294,967,296)
種結果。
讓我們來 hash 一個密碼:
import binascii result = binascii.crc32('supersecretpassword') print(result) #323322056
現在,我們假設有人盜取了資料庫,有了 hash 值。我們也許並不能將 323322056
轉成 supersecretpassword
,然而我們能用一個簡單的指令碼,來找到另一個密碼可以轉化為相同的 hash 值:
import binascii,base64 i = 0 while True: if binascii.crc32(base64.encodestring(bytes(i,))) == 323322056: print(base64.encodestring(i)) i += 1
這可能需要執行好一會,但最終會返回一個字串。我們可以用返回的字串來代替 supersecretpassword
,也同樣能登入進入那個使用者的賬戶。
舉例來說,在我電腦執行那個指令碼一會之後,得到字串 MTIxMjY5MTAwNg==
,讓我們測試一下:
import binascii print(binascii.crc32("supersecretpassword")) #323322056 print(binascii.crc32("MTIxMjY5MTAwNg==")) #323322056
如何避免呢?
現在,一個強大的家庭 PC 機就可以用來每秒鐘執行一個雜湊函式十億次之多,那麼我們需要一個產生非常大範圍數的雜湊函式。
舉例來說,md5()
可能就比較適合,因為它產生 128 位雜湊值,也就是 340,282,366,920,938,463,463,374,607,431,768,211,456 可能的結果。通過遍歷找到衝突不可能的,然而有些人仍然這樣做(參考這裡)。
Sha1
是一個更好的替代方案,它會產生甚至長達 160 位的 hash 值。
問題2:彩虹表
甚至我們解決了衝突的問題,我們還不能確保安全。
彩虹表(rainbow table)是通過計算一些常用的單詞和它們的組合的 hash 值而建立的。這些表有多達上百萬或上億項。
舉例來說,你可以遍歷一個字典,為每個單詞產生一個 hash 值。你也可以將它們進行組合,也為組合的單詞產生 hash 值。這還沒完,你甚至可以以數字插入單詞的開始、結尾、中間,將它們也存入表中。
考慮到現在儲存系統非常廉價,可以產生和使用上 G 量級的彩虹表。
如何利用呢?
讓我們想象一下,一個大的資料庫被盜,裡面有一千萬的密碼雜湊值。在彩虹表中搜索與資料庫中密碼雜湊值的匹配是件相當簡單的事,不是所有密碼都能找到,但也不是都找不到!它們中的一些肯定可以找到!
如何避免呢?
我們嘗試新增鹽化(salt)字串來解決,下面是個例子:
import hashlib password = "EasyPassword" print(hashlib.sha1(password).hexdigest()) # ff166c2477f864d609ca8111680bfa387eb4e509 salt = "f#@V)Hu^%Hgfds" print(hashlib.sha1(salt + password).hexdigest()) # 3e7edaceb96becaf69ae7e73073812ea136188e2
我們做的很簡單,在 hash 密碼之前將“鹽化”字串與使用者密碼連線,這樣很顯然 hash 的結果和之前建立的彩虹表沒有一個匹配。但是,我們還不夠安全!
問題3:彩虹表問題(續)
記住,在資料庫被盜之後,還可以重建彩虹表。
如何利用呢?
即使使用了“鹽化”字串,仍然有可能隨著資料庫被盜而破解。他們所要做的是重新產生新的彩虹表,但這次他們會連線“鹽化”字串到每個密碼上。
舉例來說,通常彩虹表中 easypassword
可能存在,但在新的彩虹表中,也存在 f#@V)Hu^%Hgfdseasypassword
這樣的密碼。當他們將上千萬條盜來的經過鹽化的雜湊值與這張新彩虹表比較時,他們也會能找到一些相同的匹配。
如何避免呢?
我們使用唯一的 “salt” 替代,每個使用者都不一樣。
一種備選 salt 是從資料庫中取得使用者的 ID:
hashlib.sha1(userid + password).hexdigest()
基於的假設是使用者的 ID 號永遠不會改變,一般這都是成立的。
我們也可以為每個使用者產生一個隨機字串,把它作為這個唯一的“鹽化”字串。但是我們需要保證要將這個唯一的“鹽化”字串儲存在使用者記錄的某個位置。
import hashlib, os def unique_salt(): return hashlib.sha1(os.urandom(10)).hexdigest()[:22] salt = unique_salt() password = "" # str or int hash = hashlib.sha1(salt + str(password)).hexdigest() print(hash) # 37dec03d2761122819f8708e6d5c8392ee02b40d
這種方法有效預防了使用彩虹表破解,因為現在每一個密碼都經過不同的值鹽化過,攻擊者需要生成一千萬個獨立的彩虹表,這實際上是不現實的。
問題4:Hash 速度
大多 Hash 函式在設計時都注重速度,因為它們常用於計算大資料集和檔案的 checksum 值,來檢查資料的完整性。
如何利用呢?
就像我之前提到的,一個現代 PC 機帶有強大的 GPU(或者顯示卡)可以完成每秒鐘上千次的 hash 運算。這種方法,他們可以使用暴力攻擊法,嘗試每個可能的密碼。
你可能認為需要不少於 8 位長度的密碼可能避免暴力破解,讓我們看下面的分析來決定是否真的能避免:
- 如果密碼包含小寫字母、大寫字母以及數字,也就是有
62 (26+26+10)
種可能的字元。 - 一個 8 位長的字串就有
62^8
可能的組合,比 218 萬億略小一點。 - 以每秒十億的 hash 速率,大約在 60 個小時內就可以破解。
如果是 6 位長的密碼,這也相當普遍,只需要在 1 分鐘之內就可以破解。
如果要求 9 位或 10 位長度的密碼,這樣就會讓你的使用者體驗非常不好。
如何避免呢?
使用一個低速的 hash 函式。
假設你是用一個 hash 函式,在同樣的硬體下,每秒鐘只能進行 100 萬次 hash 運算,而不是 10 億次,暴力破解將會花費比以前多出 1000 倍的時間。那麼 60 個小時將會變成將近 7 年!
一種你可以實現的方法是:
import hashlib def my_hash(password, salt): hash = hashlib.sha1(salt + password).hexdigest() for i in range(1000): hash = hashlib.sha1(hash).hexdigest() return hash print(my_hash("12345", "f#@V)Hu^%Hgfds"))
或者,你可以使用支援 “開銷引數”(例如 )的演算法。在 Python 中,可以利用 庫。
import bcrypt def my_hash(password): return bcrypt.hashpw(password, bcrypt.gensalt(10)) print(my_hash("atdk")) #$2a$10$WNhGOdVhoZrrKgwxGa2VIuzfAvm9oFWZF9PIVtLIoU5LQOVGLuLrq
注意輸出:
- 第一個值是
$2a
,表明我們使用的是 BLOWFISH 演算法。 - 這種情形下第二個值是
$10
,是“開銷引數”。是執行迭代的次數以 2 為對數的結果,它將會迭代(10 => 2^10 = 1024)
次。這個數值可以在 4 到 31 範圍內變化。
讓我們執行例子:
import bcrypt, os, hashlib def my_hash(password, unique_salt): return bcrypt.hashpw(password, bcrypt.gensalt(10) + unique_salt) def unique_salt(): return hashlib.sha1(os.urandom(10)).hexdigest()[:22] password = "verysecret" print(my_hash(password, unique_salt())) # $2a$10$aHx0q.FE/tGvGWzlm6yePemYx9SAsBP2iSiy/uFx7pyjpy980Hita
結果包括演算法($2a),開銷引數($10),使用的 22 位 salt,剩下的是計算的 hash 值。讓我們測試一下:
import bcrypt, os, hashlib # assume this was pulled from the database hash = "$2a$10$6XDaX/3kNby0jI9Ih/Re7.478DOMZK9OnA2mTxKUP0My.39N.jdky" # assume this is the password the user entered to log back in password = "verysecret" def check_password(hash, password): salt = hash[:29] new_hash = bcrypt.hashpw(password, salt) return hash == new_hash if check_password(hash, password): print("Access Granted") else: print("Access Denied")
當我們執行時,我們看到輸出 "Access Granted!"。
整合所有的問題
如果考慮到上面的所有問題,根據我們目前所學的,寫一個實用類:
import bcrypt, os, hashlib class PassHash(): def unique_salt(self): return hashlib.sha1(os.urandom(10)).hexdigest()[:22] def hash(self, password): return bcrypt.hashpw(password, bcrypt.gensalt(10) + self.unique_salt()) def check_password(self, hash, password): full_salt = hash[:29] new_hash = bcrypt.hashpw(password, full_salt) return hash == new_hash obj = PassHash() a = obj.hash("12345") print(a) # $2a$10$gBSbmXKanQJOTSabtX4wfOE2RT2mKDFbCY6r7cqCJSk2YPGjIDrou b = obj.check_password(a, "12345") print(b) # True
現在,我們可以在我們的表單中使用該類來 hash 我們密碼,確保安全性。
結論
這種 hash 密碼的方法對大多 web 應用已經足夠了。別忘記你還可以要求你的使用者使用更強的密碼,通過強制最小密碼長度,組合字元、數字和特殊字元等方法。