安全危机:为什么需要常数时间比较?

说实话,我第一次意识到「比较函数也能出安全问题」,是在一次代码审查中。同事写了个密码校验函数,看起来干干净净,逻辑也没毛病。但我用示波器一量——嗯,响应时间跟密码长度和匹配位置强相关。那一刻我才明白,时序攻击不是理论玩具,是真能捅娄子的。

时序攻击的原理

先看一个最典型的「错误示范」。假设你写了个字符串比较函数,逐字节对比:

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 累加改成了短路求值。要不是用逻辑分析仪抓波形,根本发现不了。所以,不要相信「看起来常数时间」的代码,要验证实际机器码。

常数时间比较的核心原则

要写出真正安全的比较函数,必须遵守三条铁律:

  1. 无数据依赖分支:循环次数固定,不提前退出
  2. 无数据依赖内存访问:不根据秘密数据决定读哪个地址
  3. 无数据依赖指令选择:不因数据不同走不同 ALU 路径

说白了,就是让执行路径完全独立于输入值。攻击者测量一万次,每次耗时都一样——他就没法猜了。

本章知识体系

下面这张图概括了时序攻击的完整链路,以及常数时间比较的防御位置:

时序攻击链路与常数时间防御 攻击者 测量响应时间 目标比较函数 含分支?提前返回? 执行时间随数据变化 常数时间执行 统计推断秘密 无法推断秘密 关键洞察 攻击者不需要破解整个 秘密,只需逐位缩小 猜测范围。 防御目标 让所有输入路径的 执行时间完全一致 消除时间相关性

我的建议:学完本章后,你可以去翻翻自己项目里的密码校验、令牌验证、签名比对代码。我敢打赌,至少一半用的是 memcmp 或逐字节比较——嗯,这些都需要改成常数时间版本。

小结

时序攻击的本质,是利用执行时间与秘密数据的相关性。防御手段就是常数时间比较——让所有输入走相同的执行路径,花相同的时间。接下来的章节,我会带你一步步写出真正安全的比较函数,并验证它是否真的「常数时间」。