Actions
Bug #8816
open
SB
VJ
nfs: large attacker-controlled memory allocation parsing NFSv4 LAYOUTGET reply
Bug #8816:
nfs: large attacker-controlled memory allocation parsing NFSv4 LAYOUTGET reply
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## 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`.
VJ Updated by Victor Julien about 1 month ago
- Subject changed from NFSv4 LAYOUTGET reply: inverted size check allows large attacker-controlled Vec pre-allocation to nfs: NFSv4 LAYOUTGET reply: inverted size check allows large attacker-controlled Vec pre-allocation
SB Updated by Shivani Bhardwaj about 1 month ago
- Subject changed from nfs: NFSv4 LAYOUTGET reply: inverted size check allows large attacker-controlled Vec pre-allocation to nfs: large attacker-controlled memory allocation parsing NFSv4 LAYOUTGET reply
VJ Updated by Victor Julien about 1 month ago
- Status changed from New to In Progress
- Assignee changed from OISF Dev to Victor Julien
OT Updated by OISF Ticketbot 22 days ago
- Subtask #8934 added
OT Updated by OISF Ticketbot 22 days ago
- Label deleted (
Needs backport to 8.0)
PA Updated by Philippe Antoine 18 days ago · Edited
I think this just a bug/optimization and not a security issue :
the memory allocation does not live long :
let (i, file_handles) = count(nfs4_parse_handle, fh_handles as usize)(i)?;
count does the allocation, then tries to parse every element.
If there are not enough bytes for them (as intended), the memory is immediately freed
So, I do not see a way to get a OOM for Suricata because these 100MBytes consumption peaks are too short too be synced and combined
PA Updated by Philippe Antoine 11 days ago
- Severity set to LOW
Setting to LOW severity for now, but I think this should be downgraded further to optimization
VJ Updated by Victor Julien 1 day ago
- Tracker changed from Security to Bug
- Private changed from Yes to No
- Severity deleted (
LOW) - Disclosure Date deleted (
11/02/2026) - GHSA deleted (
GHSA-p38h-39j7-hffp)
Considering this to be a regular bug.
Actions