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

问题从哪里来

我在给支付宝支付系统开发外部 API 时,需要为接口加上 Bearer Token 认证。最直观的做法是用收到的 Token 和环境变量中配置的 API Key 做字符串比对,匹配就放行,不匹配就返回 401。

刚写完时觉得这没什么问题 —— 密钥存在环境变量里,攻击者猜不到,逻辑也简单。直到查阅相关资料,才发现字符串直接比对本身就是一个攻击面。

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

真正卡住的是哪一步

问题出在大多数编程语言的字符串比对实现上。以 JavaScript 为例,=== 运算符在逐字符比对时,一旦发现不匹配就立即返回 false。这意味着不同位置的匹配失败会有不同的执行耗时:

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

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

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

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

  1. 尝试 a********** → 响应很快 → 第一位不是 a
  2. 尝试 s********** → 响应稍慢 → 第一位是 s
  3. 尝试 sk********* → 响应更快了 → 第二位不是 k,重新试

反复试探几百次就能暴力猜出完整密钥。这不是理论攻击,有大量真实案例证明这类攻击在毫秒级延迟测量下是可行的。

我最后怎么处理

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 改回 === —— 看起来像是优化,实际上是退化。