Skip to content

fix(crypto): commit replay window only after AEAD verification#60

Merged
vxfemboy merged 1 commit into
mainfrom
fix/replay-window-before-auth
Jul 13, 2026
Merged

fix(crypto): commit replay window only after AEAD verification#60
vxfemboy merged 1 commit into
mainfrom
fix/replay-window-before-auth

Conversation

@vxfemboy

Copy link
Copy Markdown
Member

Summary

The anti-replay window advanced its high-water mark before AEAD verification, so a forged frame carrying an arbitrary counter could slide the window forward and starve the session of legitimate traffic — an off-path, session-killing DoS. This reorders the receive path to WireGuard's actual behaviour: check (read-only) → AEAD verify → commit.

The bug

ReplayWindow::check_and_set did check + mutate atomically, and Session::open/open_into called it before open_in_place:

if !self.replay.check_and_set(counter) { return Err(Replay); } // advances `latest` here
let plain = self.recv_key.open_in_place(...)?;                  // ...even if this fails

When counter > latest, check_and_set unconditionally sets latest = counter and rewrites the bitmap. A forged frame at, say, latest + 1_000_000 therefore slides the window far forward and wipes it. Subsequent legitimate packets then hit diff = latest - counter ≥ 64 and are rejected as too old.

Exploitability: on the default raw-UDP path the counter is on the wire in the clear, so an off-path attacker who can inject a UDP packet to the peer's socket controls it. AEAD rejects the forgery, but the window has already moved. (The obf_psk path is unaffected — the counter rides inside the keystream-XOR envelope, so controlling it requires the obf key.)

This is a DoS surface only — not a confidentiality or integrity break. Forged frames still never decrypt.

The fix

Split ReplayWindow::check_and_set into:

  • check(&self, counter) -> bool — read-only; would this counter be accepted?
  • commit(&mut self, counter) — advance/record; caller guarantees check (and AEAD) passed first.

open/open_into now check() before AEAD and commit() only after it verifies. check_and_set is retained as a #[cfg(test)] helper for the sliding-window unit tests. Single-threaded &mut self receive path, so there's no check→commit TOCTOU.

Testing

  • Two new regression tests (forged_frame_does_not_advance_replay_window and its open_into twin) inject a forged far-future counter and assert legitimate frames still open afterwards. Both fail on main, pass here.
  • The wire bytes are provably unchanged: both snow byte-identity KATs (seal_is_byte_identical_across_a_reference_session, session_seal_is_byte_identical_to_snow_write_message_both_directions) still pass.
  • Full yip-crypto suite (20 tests) green; clippy clean; yipd builds.

Credit

Surfaced while re-auditing the receive path in response to public scrutiny of the nonce construction (the nonce itself is the standard Noise §11.4 / RFC 8439 construction and is correct as-is — this is the separate, real issue that audit turned up).

The anti-replay window advanced its high-water mark before AEAD
verification: a forged frame carrying an arbitrary large counter slid
latest forward and wiped the bitmap, so subsequent legitimate packets
were rejected as too-old — an off-path session-killing DoS (the counter
is in the clear on the raw path). Split ReplayWindow::check_and_set into
a read-only check() and a mutating commit(); open()/open_into() now
check() before AEAD and commit() only after it verifies, matching
WireGuard's post-decrypt window advance. AEAD wire bytes unchanged (both
snow KATs still pass).
@vxfemboy
vxfemboy merged commit 6776a7a into main Jul 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant