Bug #8852
Updated by Jason Ish 23 days ago
Reported by Communications Security Establishment (CSE): <pre> ## Summary The SMB1 `NEGOTIATE_PROTOCOL` request parser builds a `Vec<&[u8]>` of dialect strings using an unbounded `many1` combinator over the entire remainder of the SMB record. Because each iteration can consume as little as a single `0x00` byte while pushing a 16-byte fat-pointer slice into the result vector, an attacker who sends one ~16 MiB SMB1 NEGOTIATE request filled with NUL bytes forces a transient ~256 MiB heap allocation (≈384 MiB peak during the final `Vec` doubling) from 16 MiB of wire data — a 16–24× amplification per flow. No configuration guard limits the element count: the only upstream caps are the 24-bit NBSS length and the optional `stream-depth` setting, which defaults to unlimited. Multiple concurrent flows multiply the effect linearly and can drive the sensor to OOM-abort. ## Affected Piece of Code - **File:** `rust/src/smb/smb1_records.rs` - **Function / Location:** `parse_smb1_negotiate_protocol_record()` ~L209-218 - **Subsystem:** rust-smb — SMB1/SMB2/SMB3 parser, DCERPC-over-SMB, file extraction ```rust rust/src/smb/smb1_records.rs:204-218 204 #[derive(Debug, PartialEq, Eq)] 205 pub struct Smb1NegotiateProtocolRecord<'a> { 206 pub dialects: Vec<&'a [u8]>, 207 } 208 209 pub fn parse_smb1_negotiate_protocol_record( 210 i: &[u8], 211 ) -> IResult<&[u8], Smb1NegotiateProtocolRecord<'_>> { 212 let (i, _wtc) = le_u8.parse(i)?; 213 let (i, _bcc) = le_u16.parse(i)?; 214 // dialects is a list of [1 byte buffer format][string][0 terminator] 215 let (i, dialects) = many1(complete(take_until_and_consume(b"\0"))).parse(i)?; 216 let record = Smb1NegotiateProtocolRecord { dialects }; 217 Ok((i, record)) 218 } ``` ## The Bug `parse_smb1_negotiate_protocol_record()` reads the one-byte `wct` and two-byte `bcc` (ByteCount) fields but discards both, then hands the **entire remaining slice** to `many1(complete(take_until_and_consume(b"\0")))`. The helper `take_until_and_consume` (`rust/src/common.rs:73-81`) returns everything up to the next `0x00` and consumes that terminator; when the very first byte is `0x00` it returns an empty `&[u8]` and advances exactly one byte. `many1` therefore happily loops once per NUL byte, and on each iteration pushes a `&[u8]` fat pointer (16 bytes on x86_64: 8-byte data pointer + 8-byte length) into the result `Vec`. Nothing in the combinator chain bounds the number of iterations. The input slice handed to this function is `r.data`, the post-header remainder of the SMB record, whose size is bounded only by the 24-bit NBSS length field (max 0x00FFFFFF = 16,777,215 bytes). After subtracting the 32-byte SMB1 header and the 3 bytes of `wct`/`bcc`, an attacker controls up to 16,777,180 bytes of dialect payload. Filling that region with `0x00` yields 16,777,180 loop iterations and a `Vec<&[u8]>` whose backing store grows (by power-of-two doubling) to 16,777,216 entries × 16 B = **256 MiB**. During the final doubling realloc both the old 128 MiB and new 256 MiB buffers coexist, giving a peak of ≈384 MiB — all triggered by ~16 MiB on the wire. The downstream consumer in `rust/src/smb/smb1.rs:474-510` iterates this `Vec` and copies each non-empty entry into a second `Vec<Vec<u8>>`; because every entry is empty, that second vector stays empty and an `SMBEvent::NegotiateMalformedDialects` event is raised. The persistent transaction state therefore does not grow — the problem is purely the **transient 256 MiB allocation inside the parser**, which lives until `pr` goes out of scope after the match arm returns. There is also a non-trivial CPU cost: ~16.7 million nom combinator iterations per request. **Network-to-sink call chain.** A network attacker sends a single TCP stream to port 445 (or any port the protocol-detection engine identifies as SMB). The app-layer engine dispatches it as follows: 1. `SCRegisterSmbParser()` registers `parse_ts = smb_parse_request_tcp` (`rust/src/smb/smb.rs:2857`). 2. `smb_parse_request_tcp()` (`smb.rs:2470-2491`) → `SMBState::parse_tcp_data_ts()` (`smb.rs:1551`). 3. `parse_nbss_record()` (`rust/src/smb/nbss_records.rs:66-77`) reads the 4-byte NBSS header with `message_type=0x00` and a 24-bit length up to `0x00FFFFFF`. Until that many payload bytes are available it returns `Incomplete`; `parse_tcp_data_ts()` falls into the `Incomplete` arm (`smb.rs:1772-1792`), calls `parse_tcp_data_ts_partial()` — which only special-cases `SMB1 WRITE_ANDX` and returns `0` for NEGOTIATE (`smb.rs:1414-1446`) — and returns `AppLayerResult::incomplete()`. The C app-layer then buffers the full ~16 MiB record. This succeeds because `SMB_CONFIG_DEFAULT_STREAM_DEPTH = 0` (`smb.rs:85`) is passed to `SCAppLayerParserSetStreamDepth` (`smb.rs:2928`), i.e. `app-layer.protocols.smb.stream-depth` is unlimited by default. 4. Once the full record is buffered, `parse_nbss_record()` succeeds. `parse_smb_version()` sees `0xFF` (`smb.rs:1626`), so `parse_smb_record()` (`smb1_records.rs:981-1012`) consumes the 32-byte SMB1 header and stores the remaining ~16 MiB in `r.data` via `rest()`. 5. `flags` bit `0x80` is clear, so `smb_record.is_request()` is true and `smb1_request_record()` (`smb1.rs:642-668`) is invoked → `smb1_request_record_one()` with `command = 0x72` hits the `SMB1_COMMAND_NEGOTIATE_PROTOCOL` arm (`smb1.rs:474`) → `parse_smb1_negotiate_protocol_record(r.data)` (`smb1_records.rs:209-218`). 6. The unbounded `many1` loop runs ~16.7 million times and allocates ~256 MiB. The only guards in this entire chain are the 24-bit NBSS length cap and the optional `stream-depth` config; neither limits the **element count** of the dialect vector. Vulnerability class: **network-reachable unbounded allocation (DoS)**. ## Reproduction Results No special Suricata configuration or rule is required; the default `suricata.yaml` with the SMB parser enabled (default) reproduces the issue because `app-layer.protocols.smb.stream-depth` defaults to `0` (unlimited). **Crafted TCP payload to port 445 (client→server direction), total 4 + 16,777,215 = 16,777,219 bytes:** 1. NBSS header (4 bytes): ``` 00 FF FF FF ; type=0x00 SESSION_MESSAGE, length=0x00FFFFFF (16,777,215) ``` 2. SMB1 header (32 bytes): ``` FF 53 4D 42 ; \xFF 'S' 'M' 'B' 72 ; command = 0x72 SMB_COM_NEGOTIATE 00 00 00 00 ; nt_status 00 ; flags (bit 0x80 clear ⇒ request) 00 00 ; flags2 00 00 ; pid_high 00 00 00 00 00 00 00 00 ; signature 00 00 ; reserved 00 00 ; tree_id 00 00 ; process_id 00 00 ; user_id 00 00 ; multiplex_id ``` 3. NEGOTIATE body — `wct`/`bcc` then all-zero filler (16,777,215 − 32 = 16,777,183 bytes): ``` 00 ; wct = 0 (ignored) 00 00 ; bcc = 0 (ignored) 00 × 16,777,180 ; 16,777,180 NUL bytes ⇒ 16,777,180 empty "dialects" ``` 4. Send this as a normal TCP stream. It may be split across many segments; the SMB app-layer will keep returning `AppLayerResult::incomplete` until all 16,777,219 bytes are buffered, then parse the record in a single call. **Expected behaviour inside Suricata:** `parse_smb1_negotiate_protocol_record()` loops 16,777,180 times, building a `Vec<&[u8]>` whose backing allocation reaches 256 MiB (capacity rounds to 16,777,216 entries × 16 B). The downstream loop at `smb1.rs:480-490` then iterates 16.7 M times, but every slice is empty so the second `Vec<Vec<u8>>` stays empty; an `SMBEvent::NegotiateMalformedDialects` event is raised. The 256 MiB is freed when `pr` goes out of scope after the match arm returns. Opening *N* concurrent TCP flows multiplies the transient heap by *N*. **Python one-liner to generate the payload file for tcpreplay/netcat:** ``` python3 -c 'import sys,struct; sys.stdout.buffer.write(b"\x00\xff\xff\xff" + b"\xffSMB" + b"\x72" + b"\x00"*4 + b"\x00" + b"\x00"*2 + b"\x00"*2 + b"\x00"*8 + b"\x00"*2 + b"\x00"*2 + b"\x00"*2 + b"\x00"*2 + b"\x00"*2 + b"\x00"*16777183)' > smb1_neg_dos.bin # then: nc <sensor-monitored-host> 445 < smb1_neg_dos.bin (or wrap in a pcap and replay) ``` This trigger is **analytically derived** from source review of the call chain above; it has not been executed against a live sensor in this audit, but every step of the path was verified in source and there is no conditional that would reject the payload before the vulnerable combinator runs. ## Severity **MEDIUM** — Resource-exhaustion / DoS. A single 16 MiB request causes a transient ~256 MiB heap allocation (≈384 MiB peak during the `Vec` growth realloc) inside the worker thread, plus ~16.7 million nom combinator iterations of CPU work; the allocation is freed after the handler returns. Multiple concurrent flows (one per worker thread, or rapid repetition on a single thread) can drive the process to OOM and abort, taking the IDS/IPS offline. There is no information leak or RCE; Rust's global allocator aborts on allocation failure rather than corrupting memory. Because the downstream `Vec<Vec<u8>>` stays empty, there is no persistent state growth — the impact is bounded to transient heap and CPU pressure during parsing. ## Suggested Fix Bound the dialect list both by the on-wire ByteCount field and by a hard element cap. Real SMB1 clients send fewer than ~20 dialects, so a cap of 256 is generous. Replace the parser body with: ```rust pub fn parse_smb1_negotiate_protocol_record( i: &[u8], ) -> IResult<&[u8], Smb1NegotiateProtocolRecord<'_>> { let (i, _wct) = le_u8.parse(i)?; let (i, bcc) = le_u16.parse(i)?; // honour ByteCount instead of consuming the whole record remainder let (i, payload) = take(bcc as usize).parse(i)?; // hard cap on number of dialect entries to prevent Vec blow-up let (_, dialects) = many_m_n(1, 256, complete(take_until_and_consume(b"\0"))).parse(payload)?; Ok((i, Smb1NegotiateProtocolRecord { dialects })) } ``` This limits the parsed body to 64 KiB (`bcc` is `u16`) and the `Vec` to ≤256 entries (≤4 KiB of fat pointers), eliminating the amplification entirely. Optionally also set an `SMBEvent::NegotiateMalformedDialects` event when `bcc` exceeds a sane threshold (e.g. 4 KiB) so that the oversize attempt remains visible to detection rules even though the parser no longer over-allocates. </pre>