别让“效率”出卖了你的密钥:深入剖析常数时间字符串比较

在日常的 Java 开发中,当我们需要比较两个字符串是否相等时,几乎所有人都会下意识地敲出 a.equals(b)。从性能和业务逻辑的角度来看,这无可厚非。但在涉及高安全性场景(如校验 API Token、Session ID、HMAC 签名或密码哈希)时,这个习惯却可能悄悄为你敞开一扇名为计时攻击(Timing Attack)的后门。

今天,我们通过一段只有十几行的 Java 代码,来聊聊为什么“高效”有时候不仅不是好事,反而会成为致命的安全漏洞。

经典的“尽早返回”陷阱

要理解计时攻击,我们首先要看看普通的 String.equals() 是怎么工作的。在底层,标准的字符串比较逻辑通常是这样的:

  1. 先比较两个字符串的长度,不同则直接返回 false
  2. 从第一个字符开始逐个比较。
  3. 一旦发现不匹配的字符,立即停止循环并返回 false(Fail-fast 机制)。
  4. 如果全部循环完都匹配,返回 true

这种“尽早返回”的设计在性能上是极致的。但在黑客眼里,执行时间的长短,成了一台泄露密码的“测谎仪”

假设服务器有一段正确的 Token 是 ABCDEFG

  • 当攻击者尝试 BBCDEFG 时,在第 1 个字符(B vs A)就失败了,耗时 1 毫秒。
  • 当攻击者尝试 AACDEFG 时,在第 2 个字符(A vs B)才失败,耗时 2 毫秒。

通过极其微小的时间差,攻击者根本不需要盲猜整个字符串。他们只需要像破解保险箱的密码锁一样,逐位听取“咔哒”声,就能以线性的时间复杂度(几十次尝试)将系统密码完全“爆破”出来。

并非杞人忧天:Remote Timing Attacks are Practical

你可能会觉得:“这太理论化了吧?服务器部署在云端,网络延迟(Jitter)动辄几十毫秒,这种微秒级的代码执行差异,怎么可能在网络传输中被测量出来?”

早年间,安全界也是这么认为的,直到 2003 年,斯坦福大学的 David Brumley 和 Dan Boneh 发表了一篇震惊业界的会议论文:《Remote Timing Attacks are Practical》(远程计时攻击是切实可行的)

在这篇论文中,作者成功通过校园网、共享主机不同进程和虚拟机多个环境,对一台运行着 OpenSSL 的服务器针对 RSA 解密操作发起了计时攻击。即使网络中存在巨大的延迟和波动,通过发送大量的请求并利用统计学方法(如过滤掉异常值、计算均值和方差),他们成功提取了服务器端用于解密的 RSA 私钥。

这篇论文彻底打破了“网络延迟能掩盖计时特征”的幻想。它残酷地向开发者证明:只要你的代码执行时间随秘密数据的不同而变化,攻击者就能通过统计分析在远程把秘密偷走。

拆解防御代码:safeEqual 函数

为了防止这种攻击,我们就需要让“字符串比较”这个动作无论成功还是失败,耗费的时间都是恒定的(Constant-Time)。这就引出了我们今天要分析的防御代码:

Java

private static boolean safeEqual(String a, String b) {
    // 1. 长度校验
    if (a.length() != b.length()) {
        return false;
    }

    int equal = 0;
    // 2. 拒绝尽早返回,强制遍历整个字符串
    for (int i = 0; i < a.length(); i++) {
        // 3. 位运算的巧妙运用
        equal |= a.charAt(i) ^ b.charAt(i);
    }
    
    // 4. 最终判定
    return equal == 0;
}

这段代码虽然短小,但每一行都充满了安全考量。它是如何化解计时攻击的?

1. 为什么保留了长度比较的“尽早返回”?

你可能注意到了,代码一开始如果长度不等,依然会立刻返回。这不是会泄露字符串长度吗?

没错,它确实会泄露长度。但在密码学的实际应用中,这段代码通常用于比较 哈希值(如 SHA-256)固定长度的 Token。对于这些数据,它们的长度本身就是公开已知的定值(例如 SHA-256 永远是 64 个十六进制字符)。因此,通过长度筛选不仅能防止 NullPointerException 或越界异常,也不会泄露实质性的敏感信息。

2. 铁面无私的 for 循环

String.equals() 不同,这里的 for 循环内部没有任何 if 判断和 break 语句。无论两个字符串是在第 1 位不同,还是最后 1 位不同,它都会老老实实地把整个循环跑完。这就从根本上抹平了因为“尽早返回”造成的时间差。

3. 位运算魔法:异或(^)与按位或(|

由于不能在循环里使用 if (a.charAt(i) != b.charAt(i))(因为分支预测机制本身也可能带来微小的时间差异),代码采用了极其底层的位运算:

  • a.charAt(i) ^ b.charAt(i)(异或):如果两个字符相同,异或结果为 0;如果不同,结果为非 0
  • equal |= ...(按位或并赋值):变量 equal 初始值为 0。只要在这漫长的循环中,出现了哪怕一次字符不匹配(即异或结果不为 0),equal 的某些二进制位就会被置为 1。一旦被置为 1,后续无论怎么跟 0 进行按位或运算,它都永远回不到 0 了。

4. 最终裁决

当循环结束时,我们只需要看看 equal 还是不是最初的那个 0。如果是,说明一路走来没有任何差异;如果不是,说明中间至少混入了一个不同的字符。

总结

在安全领域,直觉往往是靠不住的。写业务逻辑时,我们总是追求“天下武功,唯快不破”,尽一切可能剪枝优化;但在写安全逻辑时,我们反而要学会“绕远路”,强迫程序去做无用功,以此来掩盖真实的执行轨迹。

下次在代码里校验密码、Token 或签名时,请回想一下《Remote Timing Attacks are Practical》带来的教训。不要直接使用 ==.equals(),使用像本文中 safeEqual 这样的常数时间比较算法(或者直接调用 Java 官方提供的 MessageDigest.isEqual())。

多费的那几微秒 CPU 时间,可能会帮你省下百万级别的安全事故公关费。