//nefariousplan

CVE-2026-31717: ksmbd's Durable Handle Was Not an Authorization, It Was a Credential

pattern

cve

proof of concept

The CVE description for CVE-2026-31717 says any authenticated user can hijack an orphaned ksmbd durable handle by predicting the persistent ID. That sentence describes the front door. It does not describe what the attacker walks into.

After the hijack, every read and write the attacker performs against the file goes through the original opener's credentials. The kernel evaluates POSIX permissions against the victim, not the caller, because the file's f_cred is the credential snapshot taken when the victim opened the handle. The handle is not an authorization to act with the attacker's authority. The handle is the victim's authority, in struct form, transferred by 64-bit integer.

The CVE description for CVE-2026-31717 says any authenticated user can hijack an orphaned ksmbd durable handle by predicting the persistent ID. That sentence describes the front door. It does not describe what the attacker walks into.

After the hijack, every read and write the attacker performs against the file goes through the original opener's credentials. The kernel evaluates POSIX permissions against the victim, not the caller, because the file's f_cred is the credential snapshot taken when the victim opened the handle. The handle is not an authorization to act with the attacker's authority. The handle is the victim's authority, in struct form, transferred by 64-bit integer.

The persistent_id is a 64-bit integer allocated from zero

ksmbd allocates persistent IDs in fs/smb/server/vfs_cache.c via:

fd = idr_alloc_cyclic(durable_idr, fp, 0, INT_MAX - 1, GFP_KERNEL);

idr_alloc_cyclic starts at zero, increments by one, and wraps at INT_MAX - 1. The IDs are global across every session on the server. There is no per-tenant namespace. There is no salt. There is no entropy.

The PoC's default brute-force range is --pid-start 0 --pid-end 64. That is not a placeholder. On a freshly-booted ksmbd server, the first file opened with a v1 durable-handle context has persistent_id=1. The second has persistent_id=2. An OpenWrt router that has been up for a week and has had thirty files opened with DHnQ contexts has thirty live IDs in the visible range. The brute force is the for-loop, not the cryptography.

This is the front door. It matters because the lock on the door is the integer.

smb2_check_durable_oplock has a fall-through branch

When a reconnect arrives carrying a DHnC create context, smb2_open() (in fs/smb/server/smb2pdu.c) routes the request through parse_durable_handle_context(), which looks up the orphaned handle by persistent_id alone:

fp = ksmbd_lookup_durable_fd(persistent_fid);

If the handle exists, smb2_open() then calls smb2_check_durable_oplock(). The function's job, before the patch, was:

if (opinfo->is_lease == false) {
    if (lctx) {
        pr_err("create context include lease\n");
        ret = -EBADF;
        goto out;
    }
    if (opinfo->level != SMB2_OPLOCK_LEVEL_BATCH) {
        pr_err("oplock level is not equal to SMB2_OPLOCK_LEVEL_BATCH\n");
        ret = -EBADF;
    }
    goto out;
}

The lease branch (taken when the original handle was opened with a lease context) does compare the ClientGUID, the lease key, and the filename. The goto out skips that branch entirely. For a non-lease handle, the only enforcement is "is this still a batch oplock", which is a sanity check on the handle's state, not a check on who is reconnecting.

The fall-through is by design. The function was written for a world where lease keys carry the identity proof. For non-lease batch oplocks, the function had no answer to "is this the same caller". It accepted the persistent_id as the answer.

After the check passes, ksmbd_reopen_durable_fd() rebinds fp->conn and fp->tcon to the attacker's session. The handle is now operationally the attacker's. The bytes to read and write travel through the attacker's TCP socket.

The credentials do not.

kernel_read uses fp->filp->f_cred

Once the attacker holds the volatile_id of the rebound handle, they issue an SMB2 READ. ksmbd's ksmbd_vfs_read() calls:

nbytes = kernel_read(fp->filp, rbuf, count, pos);

fp->filp is the struct file allocated when the original opener called vfs_open(). The struct file carries f_cred, a snapshot of the opener's credentials taken at open time. Linux's VFS evaluates DAC against f_cred, not against current_cred(). This is the Linux file-descriptor model: a struct file encodes the authority of the process that opened it, and that authority survives any subsequent handoff of the descriptor itself. SCM_RIGHTS, fork, dup, every legitimate file-descriptor passing primitive in Linux works because of this rule.

ksmbd's bug is that it became one more such primitive without anyone noticing. It hands the attacker's session a struct file whose f_cred is the victim's. The kernel asks "may uid 1000 read this file, which is owned by uid 1000 in mode 0600?" The kernel answers yes. The bytes return down the attacker's connection.

The PoC's acl-bypass mode brackets the proof. It runs an ordinary SMB2 CREATE against the same file as the attacker before the hijack: the kernel returns STATUS_ACCESS_DENIED, because the attacker is uid 1001 and the file is mode 0600 owned by uid 1000. After the hijack, the same ordinary CREATE still returns STATUS_ACCESS_DENIED. Only the DHnC path works, because only the DHnC path carries the borrowed f_cred. The PoC author logs the proof in two lines:

[+] CONTROL pre-exploit:  normal CREATE -> STATUS_ACCESS_DENIED
[!!] HIJACKED handle at persistent_id=N
[!!] READ as attacker: b'TOP SECRET\n'  (POSIX 0600 bypassed)
[!!] WROTE 24 bytes as attacker  (POSIX 0600 bypassed)
[+] CONTROL post-exploit: normal CREATE -> STATUS_ACCESS_DENIED

The file's POSIX mode is unchanged. The attacker's UID is unchanged. The only thing that changed is which struct file the attacker is holding.

DH2C is gated by an unknowable secret; DHnC is gated by an integer

There are two flavors of durable-handle reconnect in SMB3. DH2C (v2) is the modern path; DHnC (v1) is the legacy path. Both call smb2_check_durable_oplock(). Both routed through the same fall-through.

DH2C's create context carries a 128-bit CreateGuid. parse_durable_handle_context() for DH2C does:

if (memcmp(persistent_id, fp->create_guid, SMB2_CREATE_GUID_SIZE))
    return -EBADF;

128 bits of attacker-unknown state. Not bruteforceable. A v2-only attacker has no path.

DHnC carries no GUID. Its only attacker-supplied identifier is the 64-bit persistent_id. The PoC's contribution is that v1 is still in the protocol, still served by ksmbd by default once durable handles are enabled, and still accepts a sequential integer as proof. The kernel SMB client (cifs.ko) defaults to DH2C when it can. That is incidental. The server has to honor whatever the client sends. Clients on the network can choose to send DHnC. Attackers will choose DHnC.

The fix did not have to address DH2C separately. The owner check the patch added at the top of smb2_check_durable_oplock() runs before either branch:

+	if (ksmbd_vfs_compare_durable_owner(fp, user) == false) {
+		ksmbd_debug(SMB, "Durable handle reconnect failed: owner mismatch\n");
+		ret = -EBADF;
+		goto out;
+	}
+
 	if (opinfo->is_lease == false) {

ksmbd_vfs_compare_durable_owner() is six lines: UID match, GID match, account-name strcmp. A new struct (durable_owner: uid, gid, name) is captured on the file when it becomes orphaned, and the comparison runs on every reconnect. That is the whole functional change.

MS-SMB2 named the requirement; the patch quotes it

The fix is commit 49110a8ce654, six files, +102/-16 lines. The commit message names the source of the requirement:

According to MS-SMB2, the server MUST verify that the SecurityContext of the reconnect request matches the SecurityContext associated with the existing open.

MS-SMB2 is Microsoft's SMB2/3 protocol specification. The SecurityContext-match requirement appears in MS-SMB2 §3.3.5.9.7 (Handling the SMB2_CREATE_DURABLE_HANDLE_RECONNECT Create Context) and §3.3.5.9.12 (Handling the SMB2_CREATE_DURABLE_HANDLE_RECONNECT_V2 Create Context). The spec is normative. ksmbd is an implementation of the spec. The maintainer's commit message acknowledges it: the fix is not a clever defense, the fix is reading the spec the implementation was already supposed to satisfy.

The Fixes: tag in the same commit:

Fixes: c8efcc786146 ("ksmbd: add support for durable handles v1/v2")

That commit landed on 2024-03-12. Durable-handle support shipped with this bug. It shipped in 6.12. It shipped in 6.13. It shipped in every release for two years until April 2026, when an outside researcher reported it to [email protected]. The maintainer (Namjae Jeon, Samsung) wrote the original feature and wrote the fix. Both commits are in the same hand. The owner field that needed to exist is one struct field and one strcmp.

When the handle becomes orphaned

ksmbd marks a durable handle orphaned (fp->conn = NULL) on any session teardown: clean SMB2 LOGOFF, FIN, or TCP-level RST. A laptop closing its lid is an orphan event. A WiFi roam between APs is an orphan event. A VPN flap, a Docker container restart with a CIFS bind-mount, a Linux client whose NIC goes down, a killed Office process mid-edit. Any client whose session terminates without explicitly closing the file leaves a durable handle in global_ft with the original opener's f_cred still attached, waiting for the durable scavenger to time it out.

The deployments where this matters are exactly the ones where durable handles were enabled to make those events tolerable. durable handles = no is the ksmbd-tools default; admins flip it on for the workloads where laptop-class reconnect events are common. OpenWrt ships ksmbd as the default SMB server package, with durable handles a flippable option in LuCI. TrueNAS Scale 24.10 experimentally supports ksmbd, and the workflows that drive operators toward it are the same ones (Office over flaky WiFi) that produce orphans. Any Samsung-heritage embedded NAS, any Linux fileserver where the admin chose ksmbd over Samba for performance, runs the same code.

The window between orphan and scavenger reap is the exploit window. The window between feature ship and patch was two years.

Existence Is Authorization, in the kernel

The shape is the one we named recently in n8n's chat WebSocket: a handler given a caller-supplied identifier checks that the named resource exists and treats the existence check as the authorization decision. The identifier is enumerable. The resource carries no per-call credential. Anyone who can guess or enumerate the identifier becomes the authorized caller.

This is the Existence Is Authorization pattern. In n8n the identifier was an executionId, a Postgres autoincrement primary key. In ksmbd the identifier is a persistent_id, a kernel idr_alloc_cyclic handle. In n8n the function was checkIfExecutionExists, which loaded only the row and not the per-call credential. In ksmbd the function is ksmbd_lookup_durable_fd, which loaded the file and not the original opener's identity. The patches are structurally identical: the data needed to make an authorization decision was not on the row in the first place; the fix added the field, then added the check.

It is worth marking where the two cases diverge. n8n is JavaScript userspace and the missing credential was a chat-session token loaded from the same row the lookup already touched. ksmbd is C kernel code and the missing credential was a UID/GID/name triple that was never stored on the row at all; the patch had to add the storage, in a new struct durable_owner carved out of ksmbd_file. The producer-side gap is wider in the kernel case. The consumer-side gap is the same.

The pattern crosses every layer of the stack we look at. JavaScript HTTP handlers sequentially auto-incrementing primary keys. Kernel SMB servers handing back file descriptors by integer. The shape is the same shape, and the question to ask of any handler that takes a caller-supplied identifier and looks something up is: what credential does the lookup return that proves the caller is the right caller? If the answer is "none, but the identifier is hard to guess", the identifier is the credential, and you are one enumerable allocator away from this CVE.

PoC: TurtleARM/CVE-2026-31717-KSMBD-Exploit

ksmbd accepted a 64-bit integer as proof of identity for two years because the function that decided who you were had no field to store who you were.