
校验接口签名最常见的一段代码长这样:把客户端传来的 token 和本地存的比一下,一样就放行。为了防时序攻击,比较换成 crypto.timingSafeEqual。上线之后偶发 500,堆栈指向这一行,错误是 RangeError。参数没问题,token 也没问题,问题在长度。
现象:长度不同时不是返回 false
timingSafeEqual 的入参是两个 Buffer,长度不等时它不返回 false,而是直接抛错。报文里的 token 长度由客户端决定,随便发个短一点的串就能稳稳触发一次 500。
== 2. 长度不同:不返回 false,直接抛 RangeError ==
'abc'(len 3) 与 'abcd'(len 4) : 抛 RangeError — Input buffers must have the same byte length
'abc'(len 3) 与 'ab'(len 2) : 抛 RangeError — Input buffers must have the same byte length
''(len 0) 与 'a'(len 1) : 抛 RangeError — Input buffers must have the same byte length
== 3. 逐字节提前返回的写法,耗时随匹配前缀增长 ==
三种长度都不满足,连空串对单字符也是同样一条 RangeError,message 是 Input buffers must have the same byte length。这条错误信息把长度不等这件事直接写在了异常里。
影响面:异常本身就是一个信号
值得记一笔的是这个设计带来的连带效果。攻击者拿不到具体长度,但能稳定区分「长度对」和「长度不对」两种响应:前者进到比较逻辑,后者可能变成 500 或者一段不同的耗时。这跟常量时间比较想解决的问题正好相反,等于在真正的比较之前先泄露一条长度判据。
影响有多大:把耗时量出来
朴素写法是逐字节比,遇到第一个不同的字节就返回。匹配的前缀越长,比较的字节数越多,耗时也越长。同一台机器上跑二十万次取平均,前缀长度从 0 加到 64,每次耗时从 7 ns 涨到 44 ns;换成常量时间比较,全程平在 40 ns 左右。
== 3. 逐字节提前返回的写法,耗时随匹配前缀增长 ==
匹配前缀长度 naive 每次耗时ns timingSafeEqual 每次耗时ns
0 6.99 51.76
8 13.86 44.67
16 13.80 41.18
32 23.03 40.42
48 34.29 40.11
63 43.52 40.02
64 44.41 40.75
(sink=404000,防止 JIT 把循环整个消掉)
这一列把「为什么要用常量时间比较」量了出来。差 37 ns 听起来很小,但它是可重复的、随前缀单调变化的信号,几次采样就能分出高低,不需要单次精确到纳秒。
定位:三种写法各自的边界
== 4. 常见的三种写法,各自的行为 ==
写法一 无长度判断 checkNoGuard('a', 64 长目标) : 抛 RangeError
写法二 先比长度 checkThrow('a', 64 长目标) : false
写法三 先比长度再补一段定长哈希(长度不再泄露):见下面的摘录
sha256 摘要长度 'a' 与 64 长目标 : 32 / 32
对摘要做 timingSafeEqual('a', target) : false
== 5. 长度检查本身会不会被读出来 ==
不带长度判断直接调用,长度不等就是那次 RangeError;先比长度再比内容,异常没了,但从比较里返回 false 的那一条路径仍然带着长度信息。剩下一种写法是两边各算一次定长摘要,再对摘要做常量时间比较:摘要长度固定 32 字节,长度差被抹平,比较的对象不再是原始 token。
改了什么:按长度分档
摘要那条路要引入一次哈希,对高频校验的接口是多出来的开销。更省的做法是把「长度不等」和「内容不等」合成同一种返回,让调用方拿到统一的 false。
import crypto from 'node:crypto';
function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
if (ab.length !== bb.length) {
// 仍然走一次定长比较,让两条路径的耗时接近
crypto.timingSafeEqual(ab, ab);
return false;
}
return crypto.timingSafeEqual(ab, bb);
}
把可疑的短串先和自身比一次,两条分支停下来时手上都做过同样量级的计算。代价是多一次哈希级别的比较,换来的是长度不再从耗时上读得出来。
防复发
这段代码的坑不在于难写,而在于它的失败方式是抛异常,而异常在接口层通常被统一处理成 500。上一个版本没出问题,是因为入参刚好都是定长;换了一个来源、token 变成变长之后才暴露。
可以做两件事。一是把 token 长度写进约定,在解析阶段就拒掉长度不对的请求,别让它走进比较函数;二是给这条路径单独加一条断言,长度不等时返回统一结果,并且在日志里记一次计数,异常量突然上升能第一眼看出来。至于攻击者到底能不能从这个信号里推出有效 token,本机没有条件复现真实网络下的抖动,这一段我没有实测,上面量到的是同一进程内的耗时差。