fix(sctp): slice each chunk to its own length in Packet::unmarshal#89
Conversation
53d6ef6 to
cf0cf34
Compare
Several chunk unmarshals (FORWARD-TSN, ABORT, ERROR, SHUTDOWN) bounded their inner parse loops by `buf.len()`, which was only correct when the chunk was alone in the packet. Any trailing chunk pushed the loop past the chunk boundary, typically producing ErrChunkTooShort / ErrErrorCauseTooSmall / ErrInvalidChunkSize and dropping the whole packet. RFC 4960 §6.10 allows bundling every chunk except INIT, INIT ACK, and SHUTDOWN COMPLETE, so this is legal traffic. This shows up in the wild with PR-SCTP FORWARD-TSN advancing over purely-unordered abandoned DATA: RFC 3758 §3.2 requires stream/ssn pairs not be reported for unordered TSNs, so the chunk is the minimum 8 bytes with no pairs. If it's coalesced with the retransmitted DATA that follows, the receiver silently drops every such packet and the peer retransmits until it happens to send one without a leading FORWARD-TSN. This also adds a regression test bundling FORWARD-TSN (no streams) + ABORT + ERROR + SHUTDOWN + DATA in one packet and asserting all five chunks round-trip.
cf0cf34 to
49e63ae
Compare
Several chunk unmarshals (FORWARD-TSN, ABORT, ERROR, SHUTDOWN) bounded their inner parse loops by `buf.len()`, which was only correct when the chunk was alone in the packet. Any trailing chunk pushed the loop past the chunk boundary, typically producing ErrChunkTooShort / ErrErrorCauseTooSmall / ErrInvalidChunkSize and dropping the whole packet. RFC 4960 §6.10 allows bundling every chunk except INIT, INIT ACK, and SHUTDOWN COMPLETE, so this is legal traffic. This shows up in the wild with PR-SCTP FORWARD-TSN advancing over purely-unordered abandoned DATA: RFC 3758 §3.2 requires stream/ssn pairs not be reported for unordered TSNs, so the chunk is the minimum 8 bytes with no pairs. If it's coalesced with the retransmitted DATA that follows, the receiver silently drops every such packet and the peer retransmits until it happens to send one without a leading FORWARD-TSN. This also adds a regression test bundling FORWARD-TSN (no streams) + ABORT + ERROR + SHUTDOWN + DATA in one packet and asserting all five chunks round-trip. Backport of webrtc-rs/rtc#89.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #89 +/- ##
==========================================
+ Coverage 71.21% 71.22% +0.01%
==========================================
Files 442 442
Lines 67352 67356 +4
==========================================
+ Hits 47963 47976 +13
+ Misses 19389 19380 -9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Fixes SCTP packet/chunk parsing when multiple chunks are bundled by ensuring each chunk-specific unmarshal only sees bytes up to that chunk’s declared Length (RFC 4960 §3.2), preventing variable-length chunk parsers from accidentally reading into subsequent chunks and failing the whole packet.
Changes:
- Added
slice_chunk()helper to compute and slice each chunk’s buffer to its header-declared length. - Updated
PartialDecode::finishandPacket::unmarshalto pass per-chunk slices into chunk-specificunmarshalimplementations. - Added a regression test covering variable-length chunks followed by additional chunks.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if offset + chunk_length > raw.len() { | ||
| return Err(Error::ErrChunkHeaderNotEnoughSpace); | ||
| } | ||
| Ok(raw.slice(offset..offset + chunk_length)) |
There was a problem hiding this comment.
slice_chunk reads the length field directly and slices out the chunk, but it no longer validates the 0–3 bytes of SCTP chunk padding that may follow the chunk on the wire (previously, ChunkHeader::unmarshal could validate non-zero padding when the buffer included chunk+padding). Consider explicitly checking that the padding bytes between offset + chunk_length and the next 4-byte boundary are zero (when present), or reuse ChunkHeader::unmarshal for this validation.
| if offset + chunk_length > raw.len() { | |
| return Err(Error::ErrChunkHeaderNotEnoughSpace); | |
| } | |
| Ok(raw.slice(offset..offset + chunk_length)) | |
| let chunk_end = offset + chunk_length; | |
| if chunk_end > raw.len() { | |
| return Err(Error::ErrChunkHeaderNotEnoughSpace); | |
| } | |
| let padded_end = (chunk_end + 3) & !0x03; | |
| if padded_end > raw.len() { | |
| return Err(Error::ErrChunkHeaderNotEnoughSpace); | |
| } | |
| if raw[chunk_end..padded_end].iter().any(|&b| b != 0) { | |
| return Err(Error::ErrChunkHeaderInvalidLength); | |
| } | |
| Ok(raw.slice(offset..chunk_end)) |
| Box::new(ChunkAbort { | ||
| error_causes: vec![ErrorCause { | ||
| code: PROTOCOL_VIOLATION, | ||
| ..Default::default() | ||
| }], | ||
| }), | ||
| Box::new(ChunkError { | ||
| error_causes: vec![ErrorCause { | ||
| code: UNRECOGNIZED_CHUNK_TYPE, | ||
| raw: Bytes::from_static(&[0xc0, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03]), | ||
| }], | ||
| }), | ||
| Box::new(ChunkShutdown { | ||
| cumulative_tsn_ack: 0x12345678, | ||
| }), | ||
| Box::new(ChunkPayloadData { | ||
| unordered: true, | ||
| beginning_fragment: true, | ||
| ending_fragment: true, | ||
| tsn: 4, | ||
| stream_identifier: 1, | ||
| payload_type: PayloadProtocolIdentifier::Binary, | ||
| user_data: Bytes::from_static(&[0xaa, 0xbb, 0xcc, 0xdd]), | ||
| ..Default::default() | ||
| }), | ||
| ], |
There was a problem hiding this comment.
The regression test constructs a packet that bundles DATA with ABORT and also places chunks after ABORT. Per the ABORT chunk rules (and this crate’s ABORT docs), DATA must not be bundled with ABORT and ABORT is expected to be last (later chunks may be ignored). To keep the test representative of legal traffic and future-proof against stricter validation, consider using a valid bundle (e.g., FORWARD-TSN/ERROR/SHUTDOWN followed by DATA, and either omit ABORT or place ABORT last without DATA).
| let chunk_buf = slice_chunk(&self.remaining, offset)?; | ||
| let ct = ChunkType(self.remaining[offset]); | ||
| let c: Box<dyn Chunk> = match ct { | ||
| CT_INIT => Box::new(ChunkInit::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_INIT_ACK => Box::new(ChunkInit::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_ABORT => Box::new(ChunkAbort::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_COOKIE_ECHO => { | ||
| Box::new(ChunkCookieEcho::unmarshal(&self.remaining.slice(offset..))?) | ||
| } | ||
| CT_COOKIE_ACK => { | ||
| Box::new(ChunkCookieAck::unmarshal(&self.remaining.slice(offset..))?) | ||
| } | ||
| CT_HEARTBEAT => { | ||
| Box::new(ChunkHeartbeat::unmarshal(&self.remaining.slice(offset..))?) | ||
| } | ||
| CT_PAYLOAD_DATA => Box::new(ChunkPayloadData::unmarshal( | ||
| &self.remaining.slice(offset..), | ||
| )?), | ||
| CT_SACK => Box::new(ChunkSelectiveAck::unmarshal( | ||
| &self.remaining.slice(offset..), | ||
| )?), | ||
| CT_RECONFIG => Box::new(ChunkReconfig::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_FORWARD_TSN => { | ||
| Box::new(ChunkForwardTsn::unmarshal(&self.remaining.slice(offset..))?) | ||
| } | ||
| CT_ERROR => Box::new(ChunkError::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_SHUTDOWN => Box::new(ChunkShutdown::unmarshal(&self.remaining.slice(offset..))?), | ||
| CT_SHUTDOWN_ACK => Box::new(ChunkShutdownAck::unmarshal( | ||
| &self.remaining.slice(offset..), | ||
| )?), | ||
| CT_SHUTDOWN_COMPLETE => Box::new(ChunkShutdownComplete::unmarshal( | ||
| &self.remaining.slice(offset..), | ||
| )?), | ||
| CT_INIT => Box::new(ChunkInit::unmarshal(&chunk_buf)?), | ||
| CT_INIT_ACK => Box::new(ChunkInit::unmarshal(&chunk_buf)?), |
There was a problem hiding this comment.
Now that chunk_buf is sliced to the chunk header’s declared length, it’s safer to advance the loop using that same on-the-wire length (chunk_buf.len() + padding) rather than c.value_length(). Some chunk unmarshals can successfully return while leaving 1–3 trailing bytes unaccounted for, which would make value_length() smaller than the declared length and desynchronize parsing of subsequent chunks.
| let chunk_buf = slice_chunk(raw, offset)?; | ||
| let ct = ChunkType(raw[offset]); | ||
| let c: Box<dyn Chunk> = match ct { | ||
| CT_INIT => Box::new(ChunkInit::unmarshal(&raw.slice(offset..))?), | ||
| CT_INIT_ACK => Box::new(ChunkInit::unmarshal(&raw.slice(offset..))?), | ||
| CT_ABORT => Box::new(ChunkAbort::unmarshal(&raw.slice(offset..))?), | ||
| CT_COOKIE_ECHO => Box::new(ChunkCookieEcho::unmarshal(&raw.slice(offset..))?), | ||
| CT_COOKIE_ACK => Box::new(ChunkCookieAck::unmarshal(&raw.slice(offset..))?), | ||
| CT_HEARTBEAT => Box::new(ChunkHeartbeat::unmarshal(&raw.slice(offset..))?), | ||
| CT_PAYLOAD_DATA => Box::new(ChunkPayloadData::unmarshal(&raw.slice(offset..))?), | ||
| CT_SACK => Box::new(ChunkSelectiveAck::unmarshal(&raw.slice(offset..))?), | ||
| CT_RECONFIG => Box::new(ChunkReconfig::unmarshal(&raw.slice(offset..))?), | ||
| CT_FORWARD_TSN => Box::new(ChunkForwardTsn::unmarshal(&raw.slice(offset..))?), | ||
| CT_ERROR => Box::new(ChunkError::unmarshal(&raw.slice(offset..))?), | ||
| CT_SHUTDOWN => Box::new(ChunkShutdown::unmarshal(&raw.slice(offset..))?), | ||
| CT_SHUTDOWN_ACK => Box::new(ChunkShutdownAck::unmarshal(&raw.slice(offset..))?), | ||
| CT_SHUTDOWN_COMPLETE => { | ||
| Box::new(ChunkShutdownComplete::unmarshal(&raw.slice(offset..))?) | ||
| } | ||
| CT_INIT => Box::new(ChunkInit::unmarshal(&chunk_buf)?), | ||
| CT_INIT_ACK => Box::new(ChunkInit::unmarshal(&chunk_buf)?), | ||
| CT_ABORT => Box::new(ChunkAbort::unmarshal(&chunk_buf)?), |
There was a problem hiding this comment.
Consider advancing offset based on chunk_buf.len() (plus padding) instead of c.value_length(). If a chunk unmarshal returns successfully without accounting for every byte within the declared chunk length, using value_length() here can leave bytes behind and cause the next iteration to start mid-chunk.
Summary
Several chunk unmarshals (FORWARD-TSN, ABORT, ERROR, SHUTDOWN) bound their inner parse loops by
buf.len(), which is only correct when the chunk is alone in the packet. Any trailing chunk pushes the loop past the chunk boundary, typically producingErrChunkTooShort/ErrErrorCauseTooSmall/ErrInvalidChunkSizeand dropping the whole packet. RFC 4960 §6.10 allows bundling every chunk except INIT, INIT ACK, and SHUTDOWN COMPLETE, so this is legal traffic.The case that prompted this is PR-SCTP FORWARD-TSN advancing over purely-unordered abandoned DATA. RFC 3758 §3.2 says stream/ssn pairs MUST NOT be reported for unordered TSNs, so the chunk is the minimum 8 bytes with no pairs. If it's coalesced with the retransmitted DATA that follows it (common), the receiver silently drops every such packet and the peer retransmits until it happens to send one without a leading FORWARD-TSN.
Every SCTP chunk shares the same 4-byte header (
Type | Flags | Length; RFC 4960 §3.2), so this change hasPacket::unmarshalandPartialDecode::finishread the Length field themselves and hand each chunk-specific unmarshal a buffer sized exactly to that chunk. No per-chunk source changes — each parser now receives the precondition (buf.len() == chunk_length) it already assumed.Test plan
Added
test_unmarshal_variable_length_chunks_followed_by_other_chunksas a regression test.