Conversation
…rrive out of order A DTLS 1.2 endpoint looked for the next handshake message only at the head of its receive queue, and took a ChangeCipherSpec only when it was the first unprocessed record. When the records of one datagram arrive out of order (for example the client's final flight with Finished before ChangeCipherSpec and the handshake messages reversed), the head of the queue held a record that could not be processed yet: an epoch 1 Finished waiting for its keys, or a later handshake message. The handshake stalled, and every retransmission of the peer's flight then triggered an immediate resend of our own flight. Look up the next handshake message by its message_seq anywhere in the receive queue and keep later messages buffered (RFC 6347 §4.2.2). Let records of the next epoch wait for the ChangeCipherSpec without blocking it, and mark queued copies of messages that were already processed as handled so they neither block the next message nor pin their datagram in the queue. Add a regression test that reverses the records of the client's final flight, with and without duplicated datagrams, and bounds the datagrams each side sends.
Every datagram carrying a duplicate of the peer's previous flight (a ClientKeyExchange or ServerHelloDone in DTLS 1.2, a ClientHello or Finished in DTLS 1.3) made the endpoint resend its whole current flight at once. Two endpoints that both wait, for example on a stalled handshake, then bounce their flights back and forth at network speed until the handshake deadline, with no backoff. Retransmission stays driven by the flight timer with exponential backoff (RFC 6347 §4.2.4, RFC 9147 §5.8.1). A duplicate may trigger one early resend per timer period; the budget is restored when a new flight begins or the timer fires. Once the timers are stopped after the final flight, each retransmission of the peer's last flight is still answered, as RFC 6347 §4.2.4 requires; the peer's own timer paces those. Add a regression test that delivers bursts of duplicate flights to a waiting server and a waiting client.
|
Hi @z3thon! Thanks for this. DTLS 1.2 is flight oriented so, resends are supposed to retransmit entire flights. What I believe you found is a state poisoning bug + a resend flood scenario, that needs fixing, but not exactly like in this PR. A UDP packet can contain multiple DTLS messages that are numbered by If a datagram with What you found is that |
|
Alternative take in #169 |
Summary
When the records of one DTLS 1.2 datagram arrive out of order (for example, the client's final flight with
FinishedbeforeChangeCipherSpec), the receiving endpoint can stall: it only looks for the next handshake message at the head of its receive queue, and it only accepts aChangeCipherSpecwhen that is the first unprocessed record. The handshake then cannot finish. Every retransmission of the peer's flight carries a duplicate of a message the endpoint already processed, and each such duplicate makes it resend its own whole flight at once. The peer treats that resend the same way, so both sides bounce flights back and forth at network speed, with no backoff, until the handshake deadline. This PR buffers out-of-order handshake messages bymessage_seq(RFC 6347 §4.2.2) and limits duplicate-triggered resends to one per retransmission timer period (RFC 6347 §4.2.4). Each fix has a regression test.Impact
Reproduction
Clone dimpl and check out this PR's branch:
git clone https://github.com/algesten/dimpl cd dimpl git fetch origin pull/168/head:dtls-reorder-fix git checkout dtls-reorder-fixPut back the unfixed sources from
main, keeping the new tests:Run the two new tests:
cargo test --test dtls12 -- reversed_final_flight duplicate_flights_resend --nocaptureBoth fail on the unfixed code:
The handshake never completes (
connected after None). In the 3 simulated seconds, each side sends about 100 datagrams. They start when the client's 1 s retransmission timer fires, and then go one per 10 ms simulation step: one per round trip. On a real network that is one per round trip, until the handshake deadline.Restore the fix and run the same command again:
git checkout HEAD -- src cargo test --test dtls12 -- reversed_final_flight duplicate_flights_resend --nocaptureBoth pass:
The full suite (
cargo test),cargo clippy --all-targets -- -D warningsandcargo fmt --all -- --checkpass on the branch.The first test,
dtls12_reversed_final_flight_completes_without_resend_storm, runs two dimpl endpoints over an in-memory channel. It reverses the records of the client's first datagram that carriesChangeCipherSpec(soFinishedarrives first, and the handshake messages arrive in reverse order). It runs with and without duplicating every datagram, and with three configurations (no cookie, cookie, MTU 300). It asserts that the handshake completes before the first retransmission timer, and that each side sends at most 30 datagrams in 3 s. The second test,dtls12_duplicate_flights_resend_at_most_once_per_timer_period, sends bursts of 20 duplicate flights to a waiting server and a waiting client. It asserts that each burst triggers one resend per timer period.Root cause
All line numbers are on
main(98f893d). The same code is at the lines in brackets in 0.7.4.What happens, step by step (from the server's debug log in the test above):
Finished(epoch 1),ChangeCipherSpec,CertificateVerify,ClientKeyExchange,Certificate.Engine::has_complete_handshake_with_seq(src/dtls12/engine.rs:622[0.7.4: 604]) only looks at the first unhandled handshake in the queue. It returnsfalseunless that handshake has the expectedmessage_seq(:640[622]). Here the first one isCertificateVerifyand the server expectsCertificate, so nothing is processed. (Engine::next_record,:706[688], has the same head-of-queue rule forChangeCipherSpec. It takes only the first unhandled record (:711[693]), which here is the epoch 1Finishedthat cannot be decrypted yet.)CertificateandClientKeyExchangefrom that copy. ForCertificateVerify,has_complete_handshake_with_seqfinds the new copy and returnstrue. Thennext_handshake(:673[655]) passesHandshake::defragment(src/dtls12/message/handshake.rs:150, same in both) an iterator over every later handshake in the queue. The records between the two copies (ChangeCipherSpecand the undecryptedFinished) carry no parsed handshakes, so the staleCertificateVerifyfrom the reversed copy comes next in that iterator.defragmentappends every following handshake with the same type andmessage_seq(:170). It concatenates both copies and fails with "Fragment length mismatch" (:189). Nothing is marked handled, so every later attempt fails the same way. The server is stuck waiting forCertificateVerify.ClientKeyExchange.Engine::insert_incoming_handshakeresends the server's whole flight at once for every such datagram (src/dtls12/engine.rs:286[283]). That resend carries a duplicateServerHelloDone, which makes the client resend its final flight at once, and so on. The two endpoints bounce flights at round-trip speed until the handshake deadline. DTLS 1.3 has the same per-duplicate resend (src/dtls13/engine.rs:393[390]).The fix
message_seqanywhere in the receive queue (complete_handshake_fragments). Its fragments are collected in offset order, and duplicate fragments are skipped.defragmentgets exactly the fragments that make up the message, never a second copy. Later messages stay queued until it is their turn. Records of an epoch whose keys are not in place yet are skipped, both for this lookup and fornext_record, so an earlyFinishedwaits for theChangeCipherSpecinstead of blocking it. Queued copies of messages that were already processed are marked handled (discard_stale_handshakes), so they neither block the next message nor pin their datagram in the receive queue.