安全危机:为什么需要常数时间比较?
说实话,我第一次意识到「比较函数也能出安全问题」,是在一次代码审查中。同事写了个密码校验函数,看起来干干净净,逻辑也没毛病。但我用示波器一量——嗯,响应时间跟密码长度和匹配位置强相关。那一刻我才明白,时序攻击不是理论玩具,是真能捅娄子的。
时序攻击的原理
先看一个最典型的「错误示范」。假设你写了个字符串比较函数,逐字节对比:
int insecure_compare(const char *a, const char *b, size_t len) {
for (size_t i = 0; i < len; i++) {
if (a[i] != b[i]) {
return 0; // 一旦发现不匹配,立刻返回
}
}
return 1;
}
这个函数有什么问题?执行时间取决于第一个不匹配的位置。如果第一个字节就错了,函数几乎瞬间返回;如果前三个字节都对,第四个错了,它就要多跑三次循环。
攻击者可以怎么做?我见过一个真实案例:攻击者向服务器发送精心构造的令牌,每次只改一个字节,然后精确测量响应时间。统计下来,匹配字节越多,响应越慢。用几千次测量,就能逐位「猜」出完整密钥。
核心问题:分支语句(if/else)和提前返回,让执行时间与秘密数据产生相关性。攻击者利用这种相关性,就能反向推导出秘密。
真实案例:从学术到实战
时序攻击不是纸上谈兵。我整理了几个影响深远的案例:
| 时间 | 目标 | 攻击方式 | 后果 |
|---|---|---|---|
| 1996 | RSA 私钥 | 测量模幂运算时间 | 恢复完整私钥 |
| 2005 | OpenSSL HMAC | 逐字节时序分析 | 伪造合法签名 |
| 2013 | Lucky13 攻击 | TLS CBC 填充校验 | 解密加密流量 |
| 2018 | SSH 密码校验 | 网络延迟统计 | 远程密码破解 |
你看,从 1996 年到现在,时序攻击从未消失。而且攻击精度越来越高——早期需要本地测量,现在通过网络延迟统计都能做到。
为什么常规优化会帮倒忙?
你可能觉得:「我写代码时注意点不就行了?」但问题在于,编译器和 CPU 也会「好心办坏事」。
举个例子,你写了个常数时间比较:
int constant_time_compare(const char *a, const char *b, size_t len) {
int result = 0;
for (size_t i = 0; i < len; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
看起来没问题?但编译器可能把它优化成:
- 循环展开后引入分支
- 把
result == 0改成条件跳转 - 甚至直接替换成
memcmp——而memcmp本身就不是常数时间
避坑指南:我曾经在 ARM Cortex-M 上写过一个常数时间比较,结果 -O2 优化后,编译器把 XOR 累加改成了短路求值。要不是用逻辑分析仪抓波形,根本发现不了。所以,不要相信「看起来常数时间」的代码,要验证实际机器码。
常数时间比较的核心原则
要写出真正安全的比较函数,必须遵守三条铁律:
- 无数据依赖分支:循环次数固定,不提前退出
- 无数据依赖内存访问:不根据秘密数据决定读哪个地址
- 无数据依赖指令选择:不因数据不同走不同 ALU 路径
说白了,就是让执行路径完全独立于输入值。攻击者测量一万次,每次耗时都一样——他就没法猜了。
本章知识体系
下面这张图概括了时序攻击的完整链路,以及常数时间比较的防御位置:
我的建议:学完本章后,你可以去翻翻自己项目里的密码校验、令牌验证、签名比对代码。我敢打赌,至少一半用的是 memcmp 或逐字节比较——嗯,这些都需要改成常数时间版本。
小结
时序攻击的本质,是利用执行时间与秘密数据的相关性。防御手段就是常数时间比较——让所有输入走相同的执行路径,花相同的时间。接下来的章节,我会带你一步步写出真正安全的比较函数,并验证它是否真的「常数时间」。