1. 程式人生 > >Hash 函式及其重要性

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

 是一個更好的替代方案,它會產生甚至長達 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

注意輸出:

  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 應用已經足夠了。別忘記你還可以要求你的使用者使用更強的密碼,通過強制最小密碼長度,組合字元、數字和特殊字元等方法。