编程 防重放窗口:8192 − 64 = 8128,内核却接受 8129 个 counter

2026-11-14 00:04:50

防重放窗口:8192 − 64 = 8128,内核却接受 8129 个 counter

三个常量决定窗口大小

防重放窗口不是查文档抄来的一个数字。它由位图总位数、冗余机器字和 counter 起始语义三者共同决定。RFC 4303 §3.4.3 描述的 ESP 防重放窗口里,32 和 64 是历史默认值,不是通用推荐。以 Linux 内核的 WireGuard 实现为例:

/* drivers/net/wireguard/messages.h */
enum counter_values {
    COUNTER_BITS_TOTAL     = 8192,
    COUNTER_REDUNDANT_BITS = BITS_PER_LONG,
    COUNTER_WINDOW_SIZE    = COUNTER_BITS_TOTAL - COUNTER_REDUNDANT_BITS
};

64 位机器上 COUNTER_WINDOW_SIZE = 8128;32 位机器上 BITS_PER_LONG = 32,同一份配置算出 8160。所以“窗口多大”取决于编译架构,而不是抄一个数。

位图为什么多出一个机器字

RFC 6479 用环形位图代替整块左移:窗口前移时不清整个位图,只清顶部落到的那些机器字。问题出在一次前移正好跨进新字,而新字里还存着仍在窗口内的 counter 的位——清字会把这些有效位一起擦掉。所以位图必须比有效窗口多至少一个机器字的余量。COUNTER_REDUNDANT_BITS = BITS_PER_LONG 就是这份余量:8192 位是 128 个字,有效窗口 8128 位是 127 个字。

wireguard-go 用的是同一套结构:ringBlocks = 1 << 7、windowSize = (ringBlocks - 1) * 64 = 8128。两边数字一致不是巧合。代码在 replay/replay.go。

8129:+1 偏移换来的额外 counter

内核的 counter_validate() 去掉锁和统计后是这样:

if (counter->counter >= REJECT_AFTER_MESSAGES + 1 ||
    their_counter >= REJECT_AFTER_MESSAGES)
        goto out;          /* 计数空间用尽 */

++their_counter;           /* 让 counter 0 也能用位图表示 */

if ((COUNTER_WINDOW_SIZE + their_counter) < counter->counter)
        goto out;          /* 太旧,落在窗口左边之外 */

index = their_counter >> ilog2(BITS_PER_LONG);
if (their_counter > counter->counter) { /* 前移窗口,清掉跨过的字 */ }
ret = !test_and_set_bit(their_counter & (BITS_PER_LONG - 1),
                        &counter->backtrack[index]);

counter->counter 存的是“已见最高 counter + 1”,++their_counter 又给输入加了 1。两次偏移把 0 让出来,使 counter 0 也能用位图表示。副作用是丢弃条件变成 their_counter < counter - W,也就是接受区间为 [highest - W, highest],一共 W + 1 个值。

内核自测就是这么用的。selftest/counter.c 里:

T_INIT;
for (i = COUNTER_WINDOW_SIZE + 1; i-- > 0; )
        T(i, true);       /* 从 8128 一路接受到 0,全部通过 */
T_INIT;
for (i = COUNTER_WINDOW_SIZE + 2; i-- > 1; )
        T(i, true);
T(0, false);              /* 从 8129 起手,0 就落到窗外了 */

边界算术一句话可以验:

// highest:已见最高 counter;c:待判 counter;W:COUNTER_WINDOW_SIZE
func inWindow(highest, c, W uint64) bool {
    return W+(c+1) >= highest+1
}
// inWindow(8128, 0, 8128) == true   0 仍在窗内
// inWindow(8129, 0, 8128) == false  窗口推到 8129 后,0 出窗

所以“窗口 8128”实际跟踪 8129 个 counter。实现如果只接受 8128 个,边界用例会先挂,而这类 off-by-one 在正常流量里不会暴露。

还有一个容易忽略的常量:REJECT_AFTER_MESSAGES = U64_MAX - COUNTER_WINDOW_SIZE - 1。counter 见底就整把 keypair 作废,不回绕。真正先到的是 REKEY_AFTER_MESSAGES = 1 << 60,正常链路在耗尽计数前早就换过密钥。

合法包被判重放的三种情况

一、在途包数超过窗口

窗口覆盖的是“同一 counter 空间里同时在飞的最高与最低 counter 之差”,而不是包速率。一个 sans-io 实现把默认窗口从 2048 提到 8128,注释写得很直接:500 ms RTT、100 Mbit/s 的链路上,在途大约 4400 个包,2048 的窗口会把合法但迟到的包判成重放。

这条链路还有一个反直觉的地方:窗口大小完全由接收侧决定,从不上线,改它不影响互通。所以对端用什么窗口不是能协商的参数,只能按自己最差的那条路径估。

二、counter 空间被复用

窗口状态如果按“发送方”建键,而发送方的 counter 在新连接、新进程、重启后又从 0 开始,接收侧就会把所有新包判成重放——旧窗口的最高位还停在上个会话的高处。修法不是调大窗口,而是把 epoch 放进键里:要么每把会话密钥一个新窗口(WireGuard 的窗口是 per-keypair 的,天然规避),要么键写成 (sender, epoch),epoch 持久递增,重启后从 epoch << shift 之上开始编号。用墙上时钟做 epoch 是错的,时钟回拨会直接退回旧区间。

三、先查窗口,后验签

窗口一旦被未经验证的包推进,攻击者只要发一个伪造的高 counter 包,就能把窗口顶到很高,后续合法包全部落到窗外——不用破解任何密码,纯 DoS。

内核的顺序是对的:wg_packet_rx_poll() 只在 PACKET_STATE_CRYPTED 分支里调 counter_validate(),也就是 AEAD 解密验签成功之后。有些实现会提供只读的 check() 预判,用来在昂贵校验前先丢掉明显重复的包,但真正的置位必须等到认证成功。

那条容易被误读的日志

窗口拒绝时内核打的是:

wg0: Packet has invalid nonce 42 (max 8170)

文案写 “invalid nonce”,容易被当成 nonce 复用或长度问题,其实是 counter 落在窗口外。打开 dynamic debug 才能看到:

sudo modprobe wireguard
echo 'module wireguard +p' | sudo tee /sys/kernel/debug/dynamic_debug/control
dmesg -w | grep -i wireguard

(max N) 就是当前的 counter->counter。拿它和报文里的 counter 相减,差值 ≥ COUNTER_WINDOW_SIZE 就是太旧,小于才是真的重复。

落地判据

  • 窗口 ≥ 2 × 单条最差路径估算的在途包数,别照抄 64。
  • 接受区间写成闭区间 [highest-W, highest],共 W+1 个值;单测两端都要覆盖,照 selftest/counter.c 的用例抄一遍最省事。
  • 位图永远比有效窗口多一个机器字,按机器字粒度清位。
  • 置位只在认证成功后做,预判接口保持只读。
  • 窗口状态的键里带发送方 epoch;重启不能回到旧 counter 区间。
  • 用 invalid nonce 这类日志名反推语义容易掉坑,先看代码里的比较方向。

项目信息

复制全文 生成海报 重放窗口 WireGuard IPsec 防重放 位图 Go

推荐文章

程序员茄子在线接单