侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 172 篇文章
  • 累计收到 7 条评论

长度差一个字节,timingSafeEqual 直接抛错

2026-10-11 / 0 评论 / 2 阅读

长度差一个字节,timingSafeEqual 直接抛错

校验接口签名最常见的一段代码长这样:把客户端传来的 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,本机没有条件复现真实网络下的抖动,这一段我没有实测,上面量到的是同一进程内的耗时差。

    🤞 分享