人人都能学编程
English 开始学习
专项 · 网络安全第 3 课 / 共 9 课免费阅读

哈希与加盐 · 网络安全入门

如果用户数据库泄露了,攻击者能看到所有人的密码吗?

课程目录

为什么需要它

数据库总会泄露:程序漏洞、备份被偷、员工疏忽。如果密码是明文存的,所有账号会一起沦陷;而且大家习惯重复用密码,他们的邮箱和银行账户也跟着沦陷。正确地存密码,几乎是每个开发者都必须做对的一件安全工作。

直觉理解

哈希就是一段文字的指纹。同样的文字永远得到同样的指纹,改一点点,指纹就完全不同;而且无法从指纹反推出原文。

所以服务器只存哈希值。登录时,把你输入的密码也算一次哈希,再比较两者。但攻击者还可以猜:把最常见的密码都算出哈希,看看有没有对得上的。这就是字典攻击。

盐 Salt 是一段随机字符串,和每个用户的哈希存在一起,计算哈希前先混进密码里。这样两个用户就算密码相同,哈希也不同:猜中一次不再能攻破所有人,提前算好的哈希表也没用了。真实系统还会用故意设计得很慢的哈希,比如 bcrypt、scrypt 或 Argon2,让每次猜测都要花时间。

定义

哈希函数 Hash function

A function that turns any input into a fixed-length value. It is one-way: you can't compute the input from the output. SHA-256 always gives 64 hex characters.
把任意输入变成固定长度结果的函数。它是单向的:无法从结果算回输入。SHA-256 的结果永远是 64 个十六进制字符。

盐 Salt

A random value stored with each password hash and combined with the password before hashing, so equal passwords get different hashes.
和每个密码哈希存在一起的随机值,计算哈希前先和密码拼在一起,让相同的密码得到不同的哈希。

示例

输入相同,指纹相同

动手试试

每个哈希有 64 个字符,这里只打印前 16 个。

import hashlib

def h(text):
    return hashlib.sha256(text.encode()).hexdigest()

print(h("sunshine")[:16])
print(h("sunshine")[:16])
print(h("Sunshine")[:16])
逐步执行
  1. L4对文字的字节做哈希,并用十六进制数字显示结果。
  2. L7密码相同,哈希就相同。登录靠的就是这一点。
  3. L8只改了一个大写字母,整个哈希都变了。

字典攻击

动手试试

攻击者拿到了泄露的哈希,还有一份常见密码清单。弱密码被猜中,随机密码幸存。

import hashlib

def h(text):
    return hashlib.sha256(text.encode()).hexdigest()

leaked = {"amy": h("dragon"), "bob": h("x9!Lq2#v")}
wordlist = ["123456", "password", "dragon", "qwerty"]
for user, stored in leaked.items():
    found = None
    for guess in wordlist:
        if h(guess) == stored:
            found = guess
    print(user, "->", found)
逐步执行
  1. L6攻击者偷到的:用户名和哈希,没有密码。
  2. L7真实的字典里有上百万个泄露过的密码。
  3. L11把每个猜测算出哈希,再和存储的哈希比较。
  4. L13dragon 在清单里,所以 amy 的密码被猜中了。amy -> dragon
  5. L13bob 的随机密码不在任何清单里。bob -> None

常见错误

只用一个很快的哈希

SHA-256 的设计目标就是快,这反而帮攻击者每秒试几十亿次。存密码要用慢速、带盐的密码哈希(bcrypt、scrypt、Argon2),并且用可信的库。这一课用 SHA-256 只是为了讲清原理。

所有人共用一个盐

如果所有用户共用同一个盐,相同的密码哈希还是相同,猜中一次照样全部攻破。每个用户都要有自己的随机盐。

小测验

1. 这段代码会输出什么?

import hashlib

def h(text):
    return hashlib.sha256(text.encode()).hexdigest()

print(h("salt1" + "pw") == h("salt2" + "pw"))
print(len(h("pw")))
  1. A
    False
    64
  2. B
    True
    64
  3. C
    False
    2
查看答案

答案:A

盐不同,同一个密码的哈希也不同;SHA-256 的十六进制结果永远是 64 个字符。

2. 两个用户都用了密码 `letmein`。如果每个用户都有自己的随机盐,攻击者在泄露的表里会看到什么?

  1. A两个一样的哈希
  2. B两个不同的哈希
  3. C密码原文
查看答案

答案:B

盐不同,哈希就不同。攻击者看不出两人密码一样,只能一个一个地攻击。

动手练习

3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。

  1. 预测输出哈希的长度

    准确写出这段程序的输出。

  2. 改需求加上盐

    store(pw) 存的是不加盐的哈希。修改两个函数,让它们用上盐:

    • store(pw, salt) 返回 salt + "$" + h(salt + pw)
    • check(pw, record) 在 "$" 处把记录拆成盐和哈希,只有 h(salt + pw) 等于存储的哈希时才返回 True。
  3. 从零写攻破弱密码

    盐能防止猜中一次就攻破所有人,但每个弱密码还是能被单独猜出来。写一个函数 crack(leaked, wordlist):

    • leaked 是一个字典:用户 → "salt$hash"
    • 返回字典 用户 → 密码,包含所有密码在 wordlist 里的用户;破解不了的用户不放进去。

    h 函数已经写好了。

在互动课里练这一课

打开互动课,这些例子都能直接改、直接运行,练习题提交马上判题。免费开始,不用注册。

互动课按顺序解锁:前面的课还没学,会先带你到课程地图。

本课术语

术语中文英文定义
Hash哈希A fixed-length, one-way fingerprint of some data.
Salt盐A random value mixed into each password before hashing so equal passwords get different hashes.
Dictionary attack字典攻击Guessing passwords by hashing a list of common passwords and comparing.
Password hash (bcrypt, Argon2)密码哈希(bcrypt、Argon2)A deliberately slow, salted hash designed for storing passwords.