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 哈希值或摘要,首先需要創建一個 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

注意輸出:

  1. 第一個值是 $2a,表明我們使用的是 BLOWFISH 算法。
  2. 這種情形下第二個值是 $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 應用已經足夠了。別忘記你還可以要求你的用戶使用更強的密碼,通過強制最小密碼長度,組合字符、數字和特殊字符等方法。


編譯自:http://pypix.com/python/hash-functions/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章