不時會爆出網站的服務器和數據庫被盜取,考慮到這點,就要確保用戶一些敏感數據(例如密碼)的安全性。今天,我們要學的是 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
哈希值或摘要,首先需要創建一個 hash 對象,然后添加數據,再調用 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
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"))
或者,你可以使用支持 “開銷參數”(例如 BLOWFISH)的算法。在 Python 中,可以利用 py-crypt
庫。
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 應用已經足夠了。別忘記你還可以要求你的用戶使用更強的密碼,通過強制最小密碼長度,組合字符、數字和特殊字符等方法。