saead/downlink: replay gate disarms itself on session start and high-… - #314
Open
fderepas wants to merge 1 commit into
Open
saead/downlink: replay gate disarms itself on session start and high-…#314fderepas wants to merge 1 commit into
fderepas wants to merge 1 commit into
Conversation
…water mark can move down
trond-snekvik
self-requested a review
August 11, 2026 14:17
trond-snekvik
left a comment
Collaborator
There was a problem hiding this comment.
The issue identified in this PR is correct, but the fix only addresses one consequence of the bug, and not the bug itself.
I'm working on a fix to the core issue itself, but there are a few corner cases to cover here. I'll make a separate PR, and we can decide whether we want to do this as an additional safe guard or whether the issue can be closed.
trond-snekvik
added a commit
that referenced
this pull request
Sep 4, 2026
As reported in #314, the downlink replay protection can be disarmed by pushing an invalid downlink session, as the SESSION_VALID flag gets cleared at the start of each session, which disables the `is_valid_downlink` check. The fix #314 only addresses the invalid sequence number decrement. This bug also disables replay protection for sessions that follow a session with only corrupted pouches, though which has to be addressed separately. We also reinitialize the session for every received pouch, which resets the session pouch ID, preventing the pouch ID replay check from working correctly. This patch addresses the core issue that causes the bug addressed in #314, and is intended to supersede #314. It additionally adds a check for the block size log parameter, and adds a `server.has_seqnum` flag that replaces the validation check for `server.seqnum` the `SESSION_VALID` flag previously covered. Note: As replay protection is not implemented on the server side, the session replay protection defect does not affect any active deployments, but all the while this code exists in this repo, it needs to be correct. Signed-off-by: Trond Snekvik <trond.snekvik@canonical.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
…water mark can move down
Summary
The SAEAD downlink replay protection can be disarmed and rolled backward by an
unauthenticated network attacker in the shipped default configuration. Two
mechanisms combine:
is_valid_downlink()accepts unconditionally whenever theSESSION_VALIDbit is clear (
src/saead/downlink.c:41-45), andSESSION_VALID(downlink.c:122), which isre-armed only after a block successfully decrypts (
downlink.c:169), whiledownlink.c:176is unconditional —server.seqnum = seqnum— so a decrypt with a stale seqnum moves the markdown, and
server.seqnumis never reset anywhere.A passive-recorder + active-dropper attacker can therefore lower
server.seqnumand re-open a replay window it had already closed. This PR guards the advance to
be monotonic (the necessary local fix) and describes the additional change needed
to keep the gate armed (the root cause).
Severity: High — replay of authenticated downlink sessions by an unauthenticated
network attacker, in the default
CONFIG_POUCH_ENCRYPTION_SAEADbuild.The three facts (current tree, HEAD
0d749f5)(1) The gate short-circuits to ACCEPT when disarmed —
src/saead/downlink.c:(2) Every session start disarms the gate —
downlink.c:122:SESSION_VALIDis re-set only after a block decrypts (downlink.c:169);session_endclears only
SESSION_ACTIVE, not re-arming the gate. So any session start whoseblocks never decrypt leaves the gate disarmed.
(3) The advance is unconditional and never reset —
saead_downlink_block_decrypt,downlink.c:173-177:server.seqnumhas exactly this one writer and no reset in the tree.seqnumarrives in the cleartext, unauthenticated CBOR pouch header.
The attack
Device booted (
server.seqnum = 0); attacker recorded genuine sessions atseq=50and
seq=100:server.seqnum/ VALIDseq=50, block decryptsseq=100, block decryptsseq=101header passes the gate; attacker drops the block:122disarmedseq=50:41-45accept; recorded ct verifies;:176writesseq=100(already consumed at step 2)100 > 50→ ACCEPTEDStep 3 needs no key material — a fabricated header with any
seq > server.seqnumis accepted (session-key generation never fails on an attacker-chosen id), and
simply dropping one genuine block suffices. The value written at
:176isconstrained to seqnums for which the attacker holds genuine recorded ciphertext
(the write is downstream of
psa_aead_decryptsucceeding) — i.e. exactly a replay,which is what the counter exists to stop.
Collateral: the pouch-id replay guard is dead code
saead_downlink_pouch_start(downlink.c:140) guards onSESSION_HAS_POUCH && id <= server.pouch_id. Its only caller (crypto_saead.c:91)runs immediately after
saead_downlink_session_start(crypto_saead.c:80), whichcleared
SESSION_HAS_POUCHatdownlink.c:122. The guard'sSESSION_HAS_POUCHterm is therefore always false, so the pouch-id replay protection never fires on
any path. Same root cause (state cleared at session start, checked before any
decrypt re-arms it).
Confirmation
This is a protocol-logic defect, not a memory-safety bug — so AddressSanitizer is
not the detector. The confirmation is a behavioral witness (
W7a-witness.c) thattranscribes the gate state machine (
is_valid_downlink, the:122disarm, the:176advance) and replays the sequence above. It is compiled under-fsanitize=addressspecifically to demonstrate that the attack produces nomemory error — which is precisely why fuzzers and sanitizer-based tests do not
catch it:
ASan reports nothing (memory-clean); the violation is the non-monotone high-water
mark and the re-accepted replay. Rebuilt with
-DFIX(the §1 guard below) the samerun reports
W7A-NO-DOWNGRADEand step 5 becomesREJECT.The fix
Primary — make the advance monotonic (necessary, local, behaviour-preserving on
the armed path):
On the intended armed-gate path the
is_valid_downlinkcheck already forcedseq > server.seqnum, so this is behaviour-preserving there; off it, astale/replayed id can no longer roll the mark backward.
This guard is necessary but NOT sufficient. With it, step 4's replay is still
accepted and delivered to the application — only the persistent widening (step 5)
is prevented. The root cause is (1)+(2): the gate is disarmed on every session
start. A complete fix must additionally stop clearing
SESSION_VALIDon sessionstart, or make the sequential-seqnum check in
is_valid_downlinkconsultserver.seqnumunconditionally rather than only when a prior session wasvalidated — so that
seq <= server.seqnumis rejected even on the"no previous session" branch. The pouch-id guard (collateral above) should likewise
be evaluated before the session-start clear, not after.
How this was found
Deductive proof of
src/saead/downlink.cagainst a Lean model of thereplay direction (
Direction.advance = if highWater < seq then seq else highWater).The pristine C advance is unconditional, so it matches the model only on the domain
seq > server.seqnum— a call-graph assumption (that decrypt runs only after anarmed accept gate) that the witness refutes: session start disarms the gate on
the ordinary path, so decrypt can run with
seq <= server.seqnum. The primary guardabove turns the write into
server.seqnum = max(server.seqnum, seq), matching themodel on the whole domain and making the monotonicity property a local,
single-function post-condition (no cross-function assumption).