Project

General

Profile

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>

Back