Project

General

Profile

Actions

Security #8860

open
SB VJ

nfs: NFSv4 SECINFO_NO_NAME reply parser returns un-advanced cursor causing compound desync / detection bypass

Security #8860: nfs: NFSv4 SECINFO_NO_NAME reply parser returns un-advanced cursor causing compound desync / detection bypass

Added by Shivani Bhardwaj 23 days ago. Updated about 14 hours ago.

Status:
In Review
Priority:
Normal
Assignee:
Target version:
Affected Versions:
Label:
CVE:
Git IDs:
Severity:
LOW
Disclosure Date:

Description

Reported by Communications Security Establishment (CSE):

## Summary

The NFSv4 `SECINFO_NO_NAME` reply parser in Suricata's Rust NFS implementation correctly walks the security-flavor array using a local cursor, but then returns the *original* (pre-walk) input slice to its caller. Because this parser is invoked from within nom's `count()` combinator while iterating the operations of a COMPOUND reply, every byte of the flavor array is left in the stream and is immediately re-interpreted as the next compound operation. An attacker who controls the server→client direction can use this to desynchronise Suricata from the real NFS server: the genuine trailing operations (e.g. a READ carrying file content) are either dropped with a `MalformedData` event or replaced by phantom operations decoded from the flavor bytes, allowing file-content and `nfs_*` keyword inspection to be bypassed. There is no memory-safety or availability impact.

## Affected Piece of Code

- **File:** `rust/src/nfs/nfs4_records.rs`
- **Function / Location:** `nfs4_res_secinfo_no_name()` ~L976-987
- **Subsystem:** rust-nfs-rpc — NFS/RPC and Kerberos parsers

```rust
/home/omuser/claude/suricata/rust/src/nfs/nfs4_records.rs
 976  fn nfs4_res_secinfo_no_name(i: &[u8]) -> IResult<&[u8], Nfs4ResponseContent<'_>> {
 977      let (i, status) = be_u32(i)?;
 978      let (i, flavors_cnt) = be_u32(i)?;
 979      // do not use nom's count as it allocates a Vector first
 980      // which results in oom if flavors_cnt is really big, bigger than i.len()
 981      let mut i2 = i;
 982      for _n in 0..flavors_cnt {
 983          let (i3, _flavor) = nfs4_parse_flavors(i2)?;
 984          i2 = i3;
 985      }
 986      Ok((i, Nfs4ResponseContent::SecInfoNoName(status)))
 987  }
...
1216          NFSPROC4_SECINFO_NO_NAME => nfs4_res_secinfo_no_name(i)?,
...
1243      let (i, commands) = count(nfs4_res_compound_command, ops_cnt as usize)(i)?;
```

## The Bug

`nfs4_res_secinfo_no_name()` was rewritten to avoid nom's allocating `count()` combinator (the comment at L979-980 explains the OOM concern). The rewrite introduces a separate mutable cursor `i2` at L981 and advances it through each flavor entry in the `for` loop at L982-985. However, the final `Ok(...)` at L986 returns `i` — the slice as it stood immediately after reading `flavors_cnt` at L978 — rather than the consumed cursor `i2`. The net effect is that the function *validates* the flavor array but does not *consume* it: every byte of the array is handed back to the caller as unparsed input.

This matters because the function is not called at the tail of a record. It is one arm of `nfs4_res_compound_command()` (L1216), which is itself driven by `count(nfs4_res_compound_command, ops_cnt)` inside `parse_nfs4_response_compound()` (L1243). `count()` feeds the residual slice from each iteration straight into the next. After a `SECINFO_NO_NAME` op returns, the next iteration of `count()` therefore begins at the first byte of the flavor array instead of at the next real opcode. The first 4 bytes of the flavor array — typically an RPC auth flavor such as `AUTH_SYS` (1) or `RPCSEC_GSS` (6) — are read by `be_u32` as the next NFSv4 opcode.

Two outcomes are possible, both attacker-selectable:

1. **Hard desync → drop.** If the aliased "opcode" matches no arm of the `match` in `nfs4_res_compound_command()` (e.g. flavor `AUTH_SYS` = 1), nom returns `Err(ErrorKind::Switch)`. `parse_nfs4_response_compound()` fails, and `process_reply_record_v4()` (nfs4.rs:436-438) sets `NFSEvent::MalformedData` and returns. Every subsequent op in the COMPOUND — including any READ payload — is discarded before reaching `compound_response()` / `process_read_record()` / filestore / `file.data` inspection.
2. **Soft desync → phantom ops.** If the attacker chooses flavor bytes that alias a valid opcode (e.g. `flavor_type = 22` = `NFSPROC4_PUTFH`), Suricata happily parses a phantom op list out of the flavor bytes and whatever follows. The op list Suricata sees no longer corresponds to what the real NFS server parses; the genuine trailing ops slide past uninspected, and no anomaly event is raised at all (the leftover tail is silently dropped by `Ok((_, rd))` at nfs4.rs:429).

No existing check prevents this: the flavor loop's result is simply discarded, and the outer `count()` has no way to know bytes were skipped.

**Call chain (TCP, to-client direction):** the C app-layer dispatches to `nfs_parse_response()` (`rust/src/nfs/nfs.rs:1992`, registered in `SCRegisterNfsParser()` at `nfs.rs:2325`) → `NFSState::parse_tcp_data_tc()` (`nfs.rs:1768`) → `parse_rpc_reply()` then `process_reply_record()` (`nfs.rs:1846`) → for `xidmap.progver == 4` calls `process_reply_record_v4()` (`nfs.rs:1134` → `nfs4.rs:403`) → for `xidmap.procedure == NFSPROC4_COMPOUND` calls `parse_nfs4_response_compound()` (`nfs4.rs:428` → `nfs4_records.rs:1235`) → `count(nfs4_res_compound_command, ops_cnt)` (`nfs4_records.rs:1243`) → `nfs4_res_compound_command()` (`nfs4_records.rs:1187`) → for opcode 52 calls `nfs4_res_secinfo_no_name()` (`nfs4_records.rs:1216` → L976). The UDP path is identical via `nfs_parse_response_udp()` → `parse_udp_tc()`.

**Required field values to reach the defect:** a prior to-server RPC CALL with the same XID, `prog = 100003`, `vers = 4`, `proc = 1` must have been observed so that the xidmap entry exists with `progver = 4` / `procedure = COMPOUND`. The to-client RPC REPLY must have `msg_type = 1`, `reply_state = 0` (MSG_ACCEPTED), `accept_state = 0` (SUCCESS), and a COMPOUND body with `ops_cnt ≥ 2` whose first relevant op has `opcode = 0x00000034` (SECINFO_NO_NAME), `status = 0`, `flavors_cnt ≥ 1`, followed by at least one further real op such as READ.

This is a network-reachable **logic-bypass** vulnerability (parser/IDS desynchronisation), not a memory-safety bug.

## Reproduction Results

**Prerequisites:** default `suricata.yaml` with `app-layer.protocols.nfs.enabled: yes` (the default). No detection rule is required to *reach* the bug; to *observe* the bypass, load a rule that should match the hidden READ payload, for example `alert nfs any any -> any any (msg:"NFS READ EVIL"; nfs_procedure:25; filestore; sid:1;)` or any `file.data`/`content` rule targeting the string `EVILDATA`.

**Step 1 — TCP three-way handshake** between client *C* and server *S* on port 2049.

**Step 2 — C → S, RPC CALL** (populates the xidmap with `progver = 4`, `procedure = COMPOUND`). TCP payload hex:

```
80 00 00 30                              # RPC-over-TCP record marker, last frag, len=48
de ad be ef                              # XID
00 00 00 00                              # msg_type = CALL
00 00 00 02                              # rpcvers = 2
00 01 86 a3                              # prog = 100003 (NFS)
00 00 00 04                              # vers = 4
00 00 00 01                              # proc = 1 (COMPOUND)
00 00 00 00 00 00 00 00                  # cred: AUTH_NONE, len 0
00 00 00 00 00 00 00 00                  # verf: AUTH_NONE, len 0
00 00 00 00                              # COMPOUND tag_len = 0
00 00 00 01                              # minorversion = 1
00 00 00 00                              # ops_cnt = 0  (body content irrelevant; xidmap already stored)
```

**Step 3 — S → C, RPC REPLY** containing the trigger. TCP payload hex:

```
80 00 00 50                              # record marker, last frag, len=80
de ad be ef                              # XID  (matches step 2)
00 00 00 01                              # msg_type = REPLY
00 00 00 00                              # reply_state = MSG_ACCEPTED
00 00 00 00 00 00 00 00                  # verifier: AUTH_NONE, len 0
00 00 00 00                              # accept_state = SUCCESS
# ---- NFSv4 COMPOUND reply (prog_data) ----
00 00 00 00                              # compound status = NFS4_OK
00 00 00 00                              # tag_len = 0
00 00 00 02                              # ops_cnt = 2
# op[0] SECINFO_NO_NAME
00 00 00 34                              # opcode 52
00 00 00 00                              # status = NFS4_OK
00 00 00 01                              # flavors_cnt = 1
00 00 00 01                              # flavor[0] = AUTH_SYS (1)   <-- cursor is left HERE by the bug
# op[1] READ  (real on-wire op the server/endpoint will process)
00 00 00 19                              # opcode 25 (READ)
00 00 00 00                              # status = NFS4_OK
00 00 00 01                              # eof = true
00 00 00 08                              # count = 8
45 56 49 4c 44 41 54 41                  # data = "EVILDATA" 
```

**Step 4 — Observed vs. expected behaviour.**

*Expected (without the bug):* op[0] is parsed as `SecInfoNoName`, op[1] is parsed as `Read`, `compound_response()` invokes `process_read_record()`, and the 8-byte payload `"EVILDATA"` is exposed to file extraction and content inspection; the test rule fires.

*Actual (with the bug):* after op[0] returns, `count()` re-enters `nfs4_res_compound_command()` with input starting at `00 00 00 01`. `cmd = 1` matches no arm of the opcode `match`, so nom yields `Err(ErrorKind::Switch)`. `parse_nfs4_response_compound()` fails, `nfs4.rs:436-438` sets `NFSEvent::MalformedData`, and the function returns. The READ op and its `"EVILDATA"` payload are never inspected; the test rule does not fire.

*Variant (silent bypass):* replace `flavor[0]` with `00 00 00 16` (= 22, `NFSPROC4_PUTFH`). Suricata then parses a phantom `PUTFH(status = 0x19)` as op[1] and reaches `Ok((_, rd))` at `nfs4.rs:429`, silently discarding the trailing real READ without raising `MalformedData`.

The packets above can be wrapped in a pcap (Ether/IP/TCP) and replayed with `suricata -r trigger.pcap`.

**Status of this trigger:** the byte sequence above is **analytically derived** from source-code review of the call chain and field constraints listed in *The Bug*; it has **not** been executed against a live Suricata build during this audit. Each constraint was traced to a concrete check in the source (xidmap lookup, `progver == 4`, `procedure == NFSPROC4_COMPOUND`, opcode dispatch, `count()` iteration), so confidence in reachability is high, but runtime confirmation is still pending.

## Severity

**LOW** — Detection / inspection bypass.

The desync causes Suricata to either (a) abort parsing of the COMPOUND reply with `NFSEvent::MalformedData`, dropping all subsequent ops in that PDU (READ file content, GETFH handles, etc.) before they reach `compound_response()` / `process_read_record()` / filestore / `file.data` and `nfs_*` keyword inspection, or (b) parse attacker-controlled phantom ops while the real ops slide past uninspected.

There is **no memory-safety impact**: the code is safe Rust using bounds-checked nom combinators, and the loop only borrows sub-slices of the input. There is **no crash or DoS**: the outer RPC record framing (`cur_i = &cur_i[rec_size..]` at `nfs.rs:1859`) keeps the TCP byte stream in sync regardless of what happens inside the COMPOUND parser, so subsequent RPC records are still located correctly.

Impact is further limited because:

- The attacker must control the **server → client** direction (a malicious or compromised NFS server, or an on-path injector).
- RFC 5661-compliant NFSv4.1 clients reject COMPOUND replies whose result-op list does not mirror the request-op list, so it is non-trivial to construct a reply that both triggers the desync in Suricata *and* delivers hidden data that the endpoint will actually consume.
- In typical real-world traffic, `SECINFO_NO_NAME` appears as the **last** op of its COMPOUND (e.g. `SEQUENCE; PUTROOTFH; SECINFO_NO_NAME`). In that case the leftover flavor bytes are simply the tail of `prog_data` and are silently discarded by `Ok((_, rd))` at `nfs4.rs:429`, with no observable effect on detection — the bug is latent for benign traffic.

## Suggested Fix

Return the advanced cursor `i2` instead of `i` so that the flavor array is actually consumed from the input stream:

```diff
--- a/rust/src/nfs/nfs4_records.rs
+++ b/rust/src/nfs/nfs4_records.rs
@@ -981,9 +981,9 @@ fn nfs4_res_secinfo_no_name(i: &[u8]) -> IResult<&[u8], Nfs4ResponseContent<'_>>
     let mut i2 = i;
     for _n in 0..flavors_cnt {
         let (i3, _flavor) = nfs4_parse_flavors(i2)?;
         i2 = i3;
     }
-    Ok((i, Nfs4ResponseContent::SecInfoNoName(status)))
+    Ok((i2, Nfs4ResponseContent::SecInfoNoName(status)))
 }
```

Optionally, also gate the flavor loop on `status == NFS4_OK`, matching RFC 5661 §18.45 where the `SECINFO_NO_NAME4res` union only contains the `SECINFO4resok` result list on success. The cursor fix alone, however, fully resolves the desync described here.

Subtasks 1 (1 open0 closed)

Security #8924: nfs: NFSv4 SECINFO_NO_NAME reply parser returns un-advanced cursor causing compound desync / detection bypass (8.0.x backport)AssignedVictor JulienActions

VJ Updated by Victor Julien 23 days ago Actions #1

  • Subject changed from NFSv4 SECINFO_NO_NAME reply parser returns un-advanced cursor causing compound desync / detection bypass to nfs: NFSv4 SECINFO_NO_NAME reply parser returns un-advanced cursor causing compound desync / detection bypass

VJ Updated by Victor Julien 22 days ago Actions #2

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

JI Updated by Jason Ish 8 days ago Actions #3

  • GHSA set to GHSA-cx5h-c976-qrpm

JI Updated by Jason Ish 7 days ago Actions #4

  • 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 7 days ago Actions #5

  • Subtask #8924 added

OT Updated by OISF Ticketbot 7 days ago Actions #6

  • Label deleted (Needs backport to 8.0)

PA Updated by Philippe Antoine 2 days ago Actions #7

I would say this is a regular BUG, or a LOW severity like #8750

VJ Updated by Victor Julien 1 day ago Actions #8

  • Private changed from Yes to No
  • Severity set to LOW
Actions

Also available in: PDF Atom