Bearer Token 校验的时序攻击漏洞与修复

问题从哪里来

我给支付宝支付系统开发外部 API 时,需要为接口加 Bearer Token 认证。

最直观的做法:用收到的 Token 和环境变量里的 API Key 做字符串比对。匹配就放行,不匹配就返回 401。

刚写完觉得没问题 —— 密钥存在环境变量,攻击者猜不到,逻辑也简单。

直到查阅资料,才发现字符串直接比对本身就是一个攻击面。

这个漏洞叫 时序攻击(Timing Attack)。它和密码学无关,靠的是响应时间的差异。

真正卡住的是哪一步

问题出在大多数语言的字符串比对实现上。

以 JavaScript 为例,=== 逐字符比对,一旦发现不匹配就立即返回 false。不同位置的匹配失败,耗时不同:

// 假设正确 Token 是 "sk-abc123xyz"

// 攻击者传入 "000000000000"
// === 比对第一个字符就失败,耗时 ~0.1ms

// 攻击者传入 "sk-0000000000"
// === 比对到第三个字符才失败,耗时 ~0.3ms

攻击者通过测量响应时间,逐位猜测密钥:

flowchart TD
    A[传 a**********] -->|响应快| B[第一位不是 a]
    A2[传 s**********] -->|响应稍慢| C[第一位是 s]
    C --> D[传 sk*********]
    D -->|响应更快| E[第二位不是 k,重试]
    E --> F[反复几百次猜出完整密钥]

这不是理论攻击。毫秒级延迟测量下,这类攻击有大量真实案例。

我最后怎么处理

Node.js 的 crypto 模块提供 timingSafeEqual,一个常量时间比对函数。

原理很简单:无论哪一位不同,都遍历完整长度。耗时只和 Buffer 长度有关,和内容无关。

修复后的验证逻辑:

const crypto = require('crypto');

function verifyBearer(token) {
const expected = process.env.API_KEY;
if (!token || !expected) return false;

const tokenBuf = Buffer.from(token);
const expectedBuf = Buffer.from(expected);

// 长度不同也走常量时间比对,不让攻击者推测长度
if (tokenBuf.length !== expectedBuf.length) {
// 用已知值和自己比对,保证耗时和正常路径一致
crypto.timingSafeEqual(expectedBuf, expectedBuf);
return false;
}

return crypto.timingSafeEqual(tokenBuf, expectedBuf);
}

三个关键点:

  • 用 Buffer 而不是字符串timingSafeEqual 接受 Buffer 参数。
  • 长度比较不能短路:长度不同就立刻返回,攻击者能推测密钥长度。这里用 expectedBuf 自己比对自己,消耗等量时间。
  • 强制常量时间:即使长度不匹配,也执行一次比对。

再加两层额外防护:

  1. 速率限制:对同一 IP 限频,增加暴力破解时间成本。
  2. 日志监控:记录 401 频率,频繁失败的 IP 自动进黑名单。

这件事留下的经验

第一,字符串比对和安全比对是两回事。

业务代码里用 === 没问题。但安全敏感上下文(Token、签名、密码校验)里,任何一次直接比对都是在暴露信息。

第二,安全漏洞不一定是逻辑错误。

这个漏洞没有认证逻辑错误,问题在比对算法的物理特性(执行时间)。这类侧信道攻击容易被忽视,因为开发者习惯从」输入输出正确性」验证代码,不会想到」时间也是输出」。

第三,修复成本很低,遗忘成本很高。

核心只改两行代码。但不修,每个暴露 API 的应用都是潜在入口。类似场景还有:API 签名校验、Webhook HMAC 验证、密码哈希比较 —— 比对逻辑都应该走常量时间。

第四,给代码加文档标注。

我在修复处加注释说明这是安全敏感代码,并记录修复方案。防止以后」代码清理」时把 timingSafeEqual 改回 === —— 看起来像优化,实际是退化。

参考资料