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

令牌与 HMAC · 网络安全入门

服务器怎么知道 Cookie 没被用户改过?

课程目录

为什么需要它

登录之后,服务器会给浏览器一个令牌(通常放在 Cookie 里),之后每次请求都靠它识别你。可浏览器是用户自己的,里面存的东西用户都能改。签名令牌、不可预测的会话 ID 和谨慎的比较方式,才能阻止别人把 role=user 改成 role=admin。

直觉理解

想象信封上的火漆印。信谁都能读,但印章只有寄信人有;印章被拆过或者是假的,一眼就能看出来。

HMAC 就是数据的火漆印:hmac.new(SECRET, data, sha256) 把只有服务器知道的密钥混进数据的哈希里。服务器发出 data|tag。令牌回来时,服务器重新算一遍 tag;用户哪怕改了一个字符,tag 也对不上,而没有密钥就造不出新的有效 tag。

还有两个细节:

  • 比较 tag 要用 hmac.compare_digest:无论第一个还是最后一个字符不同,它花的时间都一样。普通的 == 一遇到不同就停下,这点细微的时间差可能把 tag 一点一点泄露出去。
  • 会话 ID 必须不可预测:用 secrets.token_hex(),不要用 random。random 的设计目标是快和可重现,而不是保密。

定义

HMAC 消息认证码

A hash of the data mixed with a secret key. Anyone can check it only if they know the key, so it proves the data came from the key owner and wasn't changed.
把数据和秘密密钥混在一起算出的哈希。只有知道密钥的人才能验证它,所以它能证明数据出自密钥持有者,并且没被改过。

时序攻击 Timing attack

Learning a secret by measuring how long a program takes, for example a comparison that returns sooner when the first characters are wrong.
通过测量程序运行时间来推测秘密的攻击,比如前几个字符错了就会更早返回的比较。

示例

给令牌签名并验证

动手试试

用户能看到令牌内容,但一改签名就对不上了。

import hmac, hashlib
SECRET = b"server-only-key"

def sign(data):
    tag = hmac.new(SECRET, data.encode(), hashlib.sha256).hexdigest()
    return data + "|" + tag

def verify(token):
    data, tag = token.rsplit("|", 1)
    good = hmac.new(SECRET, data.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(tag, good)

t = sign("user=amy;role=user")
print(t[:40] + "...")
print(verify(t))
print(verify(t.replace("role=user", "role=admin")))
逐步执行
  1. L2只有服务器知道这个密钥。真正的密钥又长又随机。
  2. L5tag 同时取决于密钥和数据的每一个字符。
  3. L9rsplit("|", 1) 只在最后一个竖线处拆开。
  4. L15没动过的令牌:tag 对得上。True
  5. L16被改过的令牌:tag 和数据对不上了。False

为什么 == 会泄露

动手试试

这个比较函数会数自己检查了几个字符才停下。猜对的越多,步数越多,耗时也越长。

def naive_equal(a, b):
    steps = 0
    for x, y in zip(a, b):
        steps += 1
        if x != y:
            return False, steps
    return len(a) == len(b), steps

secret = "7f3a9c"
for guess in ["000000", "7f0000", "7f3a00"]:
    print(guess, naive_equal(secret, guess))
逐步执行
  1. L3zip 把两个字符串的字符一一配对。
  2. L6遇到第一个不同就停下。
  3. L11一开始就错:1 步。000000 (False, 1)
  4. L11前两个字符对了:3 步。测量时间的攻击者就知道前缀猜对了。7f0000 (False, 3)

常见错误

用 random 生成秘密

random 生成的数可以根据之前的输出推算出来。令牌、重置码、会话 ID 都要用 secrets 模块。

用 == 比较秘密

字符串的 == 遇到第一个不同就停。比较 tag、令牌和密码哈希时,用 hmac.compare_digest。

小测验

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

def naive_equal(a, b):
    steps = 0
    for x, y in zip(a, b):
        steps += 1
        if x != y:
            return steps
    return steps

print(naive_equal("abcd", "abzz"), naive_equal("abcd", "abcd"))
  1. A
    3 4
  2. B
    4 4
  3. C
    2 4
查看答案

答案:A

a、b 都对上,第 3 步 c 和 z 不同。完全相同的字符串会比完 4 个字符。

2. 令牌 `user=amy|<tag>` 用 HMAC 签过名。用户把它改成 `user=bob|<tag>`,会怎样?

  1. A服务器接受:tag 还在
  2. B服务器拒绝:tag 和新数据对不上
  3. C用户能从旧 tag 算出新 tag
查看答案

答案:B

tag 是用 user=amy 和密钥算出来的。服务器对 user=bob 重新计算,得到不同的值,于是拒绝。没有密钥,用户造不出正确的 tag。

动手练习

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

  1. 预测输出哪些令牌能通过?

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

  2. 改需求更稳的验证

    改进 verify(token):

    • 用 hmac.compare_digest 代替 == 来比较
    • 令牌里没有 "|" 时返回 False,而不是直接报错
  3. 从零写会过期的令牌

    密码重置链接过一段时间就该失效。写两个函数:

    • make_token(user, expires) 返回 user + "|" + str(expires) + "|" + tag,其中 tag 是对文字 user + "|" + str(expires) 算的 HMAC(和 sign 里一样)。
    • check_token(token, now):如果 tag 有效并且 now 不大于 expires,返回用户名;否则返回 None。

    expires 和 now 都是整数(秒)。用 hmac.compare_digest 比较。

在互动课里练这一课

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

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

本课术语

术语中文英文定义
HMACHMAC 消息认证码A keyed hash that proves data was produced by someone holding the key and wasn't changed.
Session id会话 IDA random value that identifies a logged-in user between requests.
Timing attack时序攻击Learning a secret from how long an operation takes.
secrets modulesecrets 模块Python's module for generating unpredictable tokens.