3. 朴素实现:Python中字符串/字节数组的朴素比较函数及其漏洞
好,咱们直接进入正题。上一章聊了什么是常数时间比较,这一章我们来看看——如果不这么做,会出什么事。
说白了,大多数程序员第一次写比较函数时,都会写出一个「朴素版本」。这个版本在功能上完全正确,但在安全上……嗯,漏洞百出。我当年在做一个支付签名校验模块时,就因为这个栽过跟头。
3.1 什么是朴素比较?
朴素比较,就是咱们最直观的写法:逐字节比较,一旦发现不同就立刻返回 False。
你想想看,这种写法在逻辑上完全没问题。两个字符串,从头比到尾,遇到第一个不一样的字符就停。效率还高呢——平均情况下比完全比较快得多。
但问题就出在这个「快」字上。
3.2 Python 中的典型朴素实现
先看一段代码。这是我在代码审查中见过最多的一种写法:
def naive_compare(a: str, b: str) -> bool:
if len(a) != len(b):
return False
for i in range(len(a)):
if a[i] != b[i]:
return False
return True
看起来人畜无害,对吧?
再来看一个更常见的——直接用 Python 内置的 == 运算符:
def builtin_compare(a: str, b: str) -> bool:
return a == b
嗯,这个更简洁。但本质上,== 在 CPython 内部也是逐字节比较,遇到差异就提前返回。所以它同样存在侧信道问题。
3.3 漏洞在哪里?——时间侧信道攻击
为什么会这样?我们来拆解一下朴素比较的执行过程。
假设攻击者想猜一个 8 位的 API 密钥。他不需要一次猜对,只需要逐位试探。
- 攻击者先提交
"aaaaaaaa",服务器比较第一位'a'vs'a'—— 相等,继续比较第二位。 - 第二位
'a'vs 真实密钥的第二位 —— 不等,立即返回False。 - 攻击者测量响应时间:比完全失败的情况多了一点点。
- 于是他知道:第一位猜对了。
重复这个过程,每一位只需要最多 256 次尝试(ASCII 范围)。8 位密钥,总共 8 × 256 = 2048 次请求就能完整还原。
暴力破解需要 256⁸ ≈ 1.8 × 10¹⁹ 次。你看,差了 15 个数量级。
核心问题:朴素比较的执行时间与「首次出现差异的位置」成正比。攻击者可以利用这个时间差异,逐位推断出完整内容。
3.4 时间差异到底有多大?
你可能觉得:「就几个纳秒的差异,网络延迟早就淹没了吧?」
我以前也这么想。直到我在项目中实际测量了一次。
| 比较场景 | 平均耗时(本地) | 平均耗时(局域网) | 可测量性 |
|---|---|---|---|
| 第一位就失败 | ~50 ns | ~0.3 ms | 容易 |
| 第五位失败 | ~250 ns | ~0.5 ms | 可测量 |
| 全部匹配 | ~800 ns | ~1.0 ms | 明显 |
注意看,即使是局域网环境,差异也在百微秒级别。现代攻击工具可以轻松分辨。我见过一个 PoC,在 1000 次采样后,时间差异的统计置信度超过 99.9%。
警告:不要以为「我的场景没那么敏感」。我曾经在一个 IoT 固件签名校验中用了朴素比较,结果被安全团队用树莓派 + 网线直连的方式,在 3 分钟内还原了 32 字节的签名密钥。嗯,那次复盘会开得很难受。
3.5 字节数组(bytes)的情况一样糟
Python 中 bytes 类型的比较同样存在这个问题:
def naive_bytes_compare(a: bytes, b: bytes) -> bool:
if len(a) != len(b):
return False
for i in range(len(a)):
if a[i] != b[i]:
return False
return True
# 或者直接用 ==
result = (a == b) # 同样有侧信道风险
底层原理完全一样。CPython 的 bytes 比较实现也是短路逻辑,逐字节比较直到发现差异。
3.6 朴素比较的「罪状」总结
我把常见的问题列出来,方便你对照检查:
- 提前返回:一旦发现不匹配就立即退出,这是时间侧信道的根源。
- 长度泄露:先比较长度,长度不同直接返回。攻击者可以知道目标值的精确长度。
- 内置运算符:
==、!=、startswith()、endswith()等都有同样问题。 - 哈希比较:
hmac.compare_digest是安全的,但hashlib.sha256(...).digest() == expected不安全。
小技巧:如果你在代码审查中看到 == 用于比较密钥、令牌、签名,直接标记为安全漏洞。我个人的经验是——99% 的情况下,开发者只是不知道有常数时间比较这回事。
3.7 朴素比较的「正确」使用场景
当然,朴素比较也不是一无是处。它快,而且简单。在以下场景中完全可以用:
- 比较用户输入的密码(密码本身应该用哈希存储,比较的是哈希值)
- 比较非敏感的业务数据
- 内部调试代码
- 性能敏感的、不涉及安全校验的逻辑
关键是要分清楚:什么时候比的是「数据」,什么时候比的是「秘密」。
3.8 本章知识结构图
下面这张图帮你理清朴素比较的问题全貌:
3.9 小结
朴素比较的问题,说白了就一句话:它把「比较过程」变成了「信息泄露通道」。
你写的每一行提前返回的代码,都在给攻击者递刀子。我见过太多项目,功能跑得飞起,安全评审时一测一个准。原因无他——大家都觉得「比较两个字符串能有什么问题」。