雜湊函式到底是不是加密
1. 為什麼要關心這個問題?
一個開發者這樣儲存使用者密碼:
hashed = sha256(password)
database.store(hashed)「這很安全,」他們說。「我用的是 SHA-256,一個強大的雜湊函式。」
然後他們的資料庫洩漏了。幾小時內,攻擊者恢復了 80% 的密碼。
哪裡出錯了?
這個開發者把「雜湊」和「安全密碼儲存」混淆了。這兩者不是一回事。SHA-256 是一個雜湊函式,不是密碼儲存解決方案。直接用它來存密碼就像用錘子當螺絲起子——用錯了工具。
2. 定義
雜湊函式接受任意大小的輸入,產生固定大小的輸出(「雜湊值」或「摘要」)。它被設計成單向的:你可以從輸入計算雜湊,但無法從雜湊計算出輸入。
密碼學雜湊函式的關鍵特性:
- 確定性: 相同輸入總是產生相同輸出
- 固定輸出大小: SHA-256 總是輸出 256 位元,無論輸入多大
- 單向性: 計算上不可逆
- 抗碰撞性: 難以找到兩個產生相同雜湊的不同輸入
- 雪崩效應: 輸入的微小變化導致輸出劇烈變化
3. 根本區別
加密:設計上雙向
明文 ──[用金鑰加密]──► 密文 ──[用金鑰解密]──► 明文加密是可逆的。有了金鑰,你總能恢復原始資料。
雜湊:設計上單向
輸入 ──[雜湊函式]──► 雜湊值
↓
(無法返回)雜湊是不可逆的。沒有金鑰。沒有解密。原始資料在數學上被銷毀了——只留下一個指紋。
為什麼會混淆?
兩者都從可讀的輸入產生「亂碼輸出」。但目的完全不同:
| 特性 | 加密 | 雜湊 |
|---|---|---|
| 目的 | 臨時隱藏資料 | 永久建立指紋 |
| 可逆 | 是(有金鑰) | 否 |
| 需要金鑰 | 是 | 否 |
| 輸出大小 | 隨輸入變化 | 固定 |
| 使用場景 | 保護傳輸/儲存中的資料 | 驗證完整性,儲存密碼 |
4. 雜湊函式如何運作
高層流程
┌─────────────────────────────────────────────────────────────┐
│ 1. 填充 │
│ - 添加位元使輸入成為區塊大小的整數倍 │
├─────────────────────────────────────────────────────────────┤
│ 2. 分塊處理 │
│ - 分割成固定大小的區塊 │
│ - 每個區塊通過壓縮函式處理 │
│ - 每個區塊的輸出饋入下一個區塊 │
├─────────────────────────────────────────────────────────────┤
│ 3. 最終化 │
│ - 輸出最終內部狀態作為雜湊值 │
└─────────────────────────────────────────────────────────────┘雪崩效應
這是雜湊對完整性檢查有用的原因:
import hashlib
text1 = "Hello, World!"
text2 = "Hello, World." # 只是把 ! 改成了 .
print(hashlib.sha256(text1.encode()).hexdigest())
# dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
print(hashlib.sha256(text2.encode()).hexdigest())
# f8c3bf62a9aa3e6fc1619c250e48abe7519373d3edf41be62eb5dc45199af2ef一個字元的變化 → 完全不同的雜湊。這使得「猜測」原始輸入變得不可能。
5. 為什麼你無法「解密」雜湊
資訊遺失
雜湊函式將任意長度的輸入壓縮成固定長度的輸出。資訊在數學上遺失了。
"Hello"(5 位元組) → 256 位元雜湊
"戰爭與和平"(3MB) → 256 位元雜湊
所有可能的檔案 → 256 位元雜湊無限的輸入映射到有限的輸出。多個輸入會產生相同的雜湊(碰撞)。你無法逆轉這個過程,因為你不知道使用了無限可能輸入中的哪一個。
沒有金鑰,就沒有解密
加密在沒有金鑰時是安全的,因為找到金鑰在計算上不可行。
雜湊沒有金鑰。沒有什麼可找的。「逆轉」需要反轉一個被設計成不可逆的數學函式。
「破解」雜湊意味著什麼
當我們說雜湊被「破解」時,我們指的是:
- 碰撞攻擊: 找到了兩個具有相同雜湊的不同輸入
- 原像攻擊: 給定一個雜湊,找到某個產生它的輸入(不一定是原始的)
兩者都不意味著「解密」。即使是被破解的雜湊函式也不會變得可逆。
6. MD5:為什麼死而不僵
MD5 是 1991 年設計的。它從 2004 年就被「破解」了。但你仍然到處能看到它。
為什麼 MD5 是破碎的
- 碰撞攻擊是實用的: 你可以建立兩個具有相同 MD5 雜湊的不同檔案
- 選定前綴攻擊有效: 給定任意兩個前綴,你可以向每個追加資料,使結果具有相同的雜湊
- 它太快了: 現代 GPU 每秒 95 億個 MD5 雜湊
為什麼 MD5 死而不僵
# 在生產系統中仍然能看到:
file_checksum = hashlib.md5(file_content).hexdigest() # "只是用於完整性"
cache_key = hashlib.md5(query).hexdigest() # "只是用於快取鍵"人們辯稱:「我不是用它來做安全,只是校驗和。」
問題在於: 需求會變。今天的「只是校驗和」會變成明天的安全控制。而且 MD5 太快了,即使非安全用途也會啟用攻擊。
MD5 實際上沒問題的場景
- 比較你完全控制的檔案
- 封閉系統中的非安全校驗和
- 遺留系統相容性(完全了解風險的情況下)
MD5 不行的場景
- 任何安全敏感的應用
- 面向使用者的檔案驗證
- 密碼雜湊(絕對不行!)
- 數位簽章
- 憑證驗證
7. 密碼儲存:正確的雜湊方式
以下是為什麼 sha256(password) 會失敗:
問題 1:速度
SHA-256 被設計成很快。非常快。
SHA-256: ~85 億次雜湊/秒 (GPU)
bcrypt: ~71,000 次雜湊/秒 (同樣的 GPU)
Argon2: ~1,000 次雜湊/秒 (同樣的 GPU,經過調優)快速雜湊意味著快速破解。一個 8 字元的密碼大約有 6 千萬億種可能。以每秒 85 億次的速度,全部嘗試需要 8 天。
問題 2:沒有鹽
沒有鹽,相同的密碼有相同的雜湊。
資料庫洩漏:
user1: 5e884898da28047d9... ← "password"
user2: 5e884898da28047d9... ← 也是 "password"
user3: 5e884898da28047d9... ← 也是 "password"攻擊者預先計算常見密碼的雜湊(彩虹表)。一次查找,數千個帳戶被攻破。
問題 3:彩虹表
預計算的表,將常見密碼映射到它們的雜湊。僅使用 SHA-256,一個 10GB 的彩虹表可以即時破解大多數弱密碼。
解決方案:密碼雜湊函式
import bcrypt
import argon2
# bcrypt:久經考驗,廣泛支援
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# argon2:密碼雜湊競賽的現代贏家
ph = argon2.PasswordHasher(
time_cost=2,
memory_cost=102400, # 100 MB
parallelism=8
)
hashed = ph.hash(password)這些函式是:
- 故意慢: 可配置的工作因子
- 記憶體密集: (Argon2)需要大量 RAM,擊敗 GPU 攻擊
- 自動加鹽: 即使密碼相同,每個雜湊也是唯一的
正確的密碼儲存流程
註冊:
password → [鹽 + 慢雜湊] → stored_hash
驗證:
input_password + stored_salt → [相同的慢雜湊] → 與 stored_hash 比較8. 雜湊函式選擇指南
| 使用場景 | 推薦 | 避免 |
|---|---|---|
| 密碼儲存 | Argon2id, bcrypt, scrypt | SHA-*, MD5 |
| 檔案完整性 | SHA-256, SHA-3, BLAKE3 | MD5, SHA-1 |
| 數位簽章 | SHA-256, SHA-3 | MD5, SHA-1 |
| HMAC | SHA-256, SHA-3 | MD5 |
| 非安全校驗和 | CRC32, xxHash | (都可以) |
| 內容定址儲存 | SHA-256, BLAKE3 | MD5 |
9. 程式碼範例:正確的密碼處理
import argon2
from argon2 import PasswordHasher, exceptions
# 配置:根據你的伺服器能力調整
ph = PasswordHasher(
time_cost=2, # 迭代次數
memory_cost=65536, # 64 MB 記憶體使用
parallelism=4, # 平行執行緒數
hash_len=32, # 輸出雜湊長度
salt_len=16 # 鹽長度
)
def hash_password(password: str) -> str:
"""雜湊密碼用於儲存。"""
return ph.hash(password)
def verify_password(stored_hash: str, password: str) -> bool:
"""驗證密碼與儲存的雜湊。"""
try:
ph.verify(stored_hash, password)
return True
except exceptions.VerifyMismatchError:
return False
except exceptions.InvalidHashError:
# 雜湊格式無效
return False
def needs_rehash(stored_hash: str) -> bool:
"""檢查密碼是否需要重新雜湊(參數已更改)。"""
return ph.check_needs_rehash(stored_hash)
# 使用
password = "user_password_here"
# 註冊
hashed = hash_password(password)
print(f"儲存的雜湊: {hashed}")
# $argon2id$v=19$m=65536,t=2,p=4$...
# 登入
if verify_password(hashed, password):
print("登入成功")
# 檢查是否應該升級雜湊
if needs_rehash(hashed):
new_hash = hash_password(password)
# 用 new_hash 更新資料庫10. 常見誤區
| 誤區 | 現實 |
|---|---|
| 「有足夠的計算能力就能解密雜湊」 | 不能。雜湊會銷毀資訊。沒有東西可解密。 |
| 「SHA-256 適合用於密碼儲存」 | SHA-256 太快了。使用 bcrypt/Argon2。 |
| 「更長的雜湊 = 更安全」 | 安全性取決於演算法,而不僅僅是長度。SHA-512 不是 SHA-256 的「兩倍安全」。 |
| 「我把密碼雜湊兩次來增加安全性」 | 這沒有幫助,在某些情況下實際上可能降低安全性。 |
| 「MD5 用於非安全目的沒問題」 | 直到需求改變。即使「只是校驗和」也使用 SHA-256。 |
11. 本章小結
三點要記住:
雜湊不是加密。 雜湊函式設計上是單向的。你不能也不應該期望「解密」一個雜湊。沒有金鑰,沒有逆轉,只有指紋。
速度是密碼儲存的敵人。 像 SHA-256 這樣的通用雜湊函式被設計成快速的。像 Argon2 這樣的密碼雜湊函式被設計成慢速的。為工作使用正確的工具。
MD5 和 SHA-1 在安全方面已被棄用。 即使「只是校驗和」,也優先使用 SHA-256 或 BLAKE3。需求會變,你不希望在它們變化時被抓到使用了破碎的密碼學。
12. 下一步
我們已經介紹了加密、雜湊及其區別。但我們忽略了一些關鍵的東西:金鑰和鹽從哪裡來?
在下一篇文章中,我們將探索:隨機數——密碼學系統中最被低估的組件,以及為什麼 rand() 會殺死你的安全性。
