ZDI-CAN-33774: ksmbd Protocol Dispatch Race, 28 Heap Bytes Walk Out the Door

Every Linux machine running ksmbd with a guest share has 28 bytes of kernel heap memory waiting to walk out the door. No credentials. No RDMA hardware. One TCP connection to port 445 is enough. The attacker sends a burst of SMB1 packets followed by a single SMB2 ECHO, and the kernel copies whatever was sitting next to the request buffer in the SLAB allocator into the response and sends it back over the wire: pointers, KASAN redzone markers, fragments of other connections’ data, anything that happened to be in kmalloc-64 at the time.

I found this vulnerability (ZDI-CAN-33774, CVSS 9.0) while studying the ksmbd protocol negotiation path after my work on ZDI-CAN-33549. The same subsystem, same maintainer, same CVSS score, but a completely different bug class: this time it is a TOCTOU race condition in the protocol handler dispatch, not an integer underflow in the RDMA path.

Background: How ksmbd Dispatches Protocol Handlers

ksmbd is the in-kernel SMB server for Linux, an alternative to the userspace Samba daemon. It implements both SMB1 (the legacy protocol) and SMB2/3 (the modern protocol) and needs to handle clients that start with an SMB1 multi-protocol negotiate before upgrading to SMB2. This means the server must be able to switch protocol handlers mid-connection.

The protocol handlers live in a dispatch table called conn->ops. This struct contains function pointers for every protocol-specific operation: building response headers, checking sessions, verifying signatures, parsing commands. There are two tables: smb1_server_ops (6 function pointers, handles 35+ byte SMB1 PDUs) and smb2_server_ops (full set, expects 64+ byte SMB2 headers).

The negotiation flow works like this: a connection thread sits in ksmbd_conn_handler_loop() reading PDUs off the socket. For each PDU, it calls queue_ksmbd_work(), which does two things in sequence:

  1. Calls ksmbd_init_smb_server(conn) to inspect the PDU’s protocol magic (\xFF SMB for SMB1, \xFE SMB for SMB2) and set conn->ops to the matching dispatch table
  2. Allocates a work item, binds work->request_buf to the PDU buffer, and queues it for a kworker thread to process

A separate kworker thread picks up the work item later and calls conn->ops->init_rsp_hdr(work) to build the response header, then conn->ops->check_user_session(work) to validate the session.

Here is the fundamental problem: the request buffer is saved per-work, but conn->ops is read from the shared connection struct at dequeue time. If another PDU arrives and changes conn->ops between enqueue and dequeue, the kworker runs the wrong handler on the wrong buffer. A 40-byte SMB1 buffer gets processed by an SMB2 handler that expects at least 64 bytes.

Why kmalloc-64 Matters

Understanding the SLAB allocator layout makes the leak concrete. The kernel’s SLUB allocator groups small allocations into per-size caches: kmalloc-8, kmalloc-16, kmalloc-32, kmalloc-64, kmalloc-128, and so on. A 40-byte allocation lands in kmalloc-64 because 64 is the next power-of-two bucket. The object gets the first 40 bytes; the remaining 24 bytes of the 64-byte slot are padding.

With KASAN enabled, those 24 padding bytes are poisoned with 0xFC (the SLUB_RED_ACTIVE marker). Without KASAN, they are whatever the previous occupant of that slot left behind. The 4 bytes at offset 64..67 belong to the next kmalloc-64 object entirely, a completely separate kernel allocation that could be holding a file descriptor table, a network buffer, or a work item from another subsystem.

When init_smb2_rsp_hdr() reads offsets 40 through 67, it reads: 24 bytes of same-slot padding (or KASAN redzone) plus 4 bytes from the neighboring object. All 28 bytes are copied into the SMB2 error response and sent to the attacker. On a production kernel without KASAN, those “padding” bytes are stale data from a recently freed object: potentially function pointers that reveal the kernel text base, heap addresses that map the SLAB layout, or fragments of other users’ SMB session data.

The Vulnerable Code

In fs/smb/server/server.c, queue_ksmbd_work():

1
2
3
4
ksmbd_init_smb_server(conn);           // (1) sets conn->ops based on THIS PDU's magic
work->request_buf = conn->request_buf; // (2) buffer saved per-work
// ...
ksmbd_queue_work(work); // (3) queued for kworker -- ops NOT saved per-work

In the kworker, __handle_ksmbd_work():

1
2
conn->ops->allocate_rsp_buf(work);     // (4) reads conn->ops NOW, not when queued
conn->ops->init_rsp_hdr(work); // (5) THE SINK: reads past SMB1 buffer

The ops selection in smb_common.c:

1
2
3
4
5
// smb_common.c:419-426 - init_smb1_server()
conn->ops = &smb1_server_ops; // 6 function pointers, expects >=35 byte buffers

// smb2ops.c:278-297 - init_smb3_11_server()
conn->ops = &smb2_server_ops; // full set, expects >=64 byte buffers (SMB2 header)

When init_smb2_rsp_hdr() runs on an SMB1 buffer, it reads fields that do not exist in the allocation:

1
2
3
4
// smb2pdu.c:647-661
rsp_hdr->Id.SyncId.TreeId = rcv_hdr->Id.SyncId.TreeId; // buf offset 40..43
rsp_hdr->SessionId = rcv_hdr->SessionId; // buf offset 44..51
memcpy(rsp_hdr->Signature, rcv_hdr->Signature, 16); // buf offset 52..67

A 35-byte SMB1 PDU allocates pdu_size + 4 + 1 = 40 bytes (connection.c:511-512) in kmalloc-64. The SMB2 handler reads up to offset 67. That is 28 bytes past the 40-byte allocation boundary, into the SLAB redzone and adjacent objects.

The Trigger: Not a Tight Instruction Race

This is not a nanosecond-precision race between two CPU cores fighting over a cacheline. The window is wide and the trigger is reliable.

One write(2) call delivers a single TCP segment containing 64 short SMB1 PDUs (35 bytes each, RFC1002-framed to 39 bytes on the wire) followed by one SMB2 ECHO (68 bytes, framed to 72). The total payload is about 2.5 KB. The connection thread reads all of them from the kernel’s TCP receive buffer without blocking on the socket, because they all arrived in one segment. It queues the 64 SMB1 works with conn->ops = smb1_server_ops, then processes the ECHO. The ECHO triggers init_smb3_11_server(), which stores conn->ops = smb2_server_ops.

At this point, every SMB1 work still sitting in the workqueue will execute init_smb2_rsp_hdr(). The connection thread has already moved on, but the kworkers have not caught up yet. The window is the entire time between “last SMB1 work queued” and “all kworkers have read conn->ops”, which can be milliseconds.

In testing, 30-40% of the burst consistently triggers the confusion. Over 500 iterations with 64-PDU bursts, the PoC produces roughly 6000 leaks per connection.

Two details keep the trigger clean:

  • The SMB1 PDUs use command byte 0x00, not 0x72 (NEGOTIATE). ksmbd_verify_smb_message() returns -EINVAL at smb_common.c:171 before incrementing outstanding_credits, so the connection does not strangle itself on the credit limit. The server sees “bad command, skip it” and moves on.
  • The SMB2 ECHO (command 0x000d) is one of three session-free opcodes (NEGOTIATE, SESSION_SETUP, ECHO). It does not require a valid session, so it processes cleanly even on a fresh connection and triggers the ops flip.

Lab Topology and Traffic Flow

The PoC runs inside a QEMU VM alongside ksmbd. The traffic is real TCP over the VM’s network stack, hitting the same kernel code path a remote attacker would:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──────────────────────── QEMU VM (kernel 7.3-rc3 + KASAN) ─────────────────┐
│ │
│ opsflip (userspace) ksmbd (in-kernel, port 445) │
│ ┌──────────────┐ TCP :445 ┌────────────────────────────┐ │
│ │ 64× SMB1 PDU │───────────────►│ queue_ksmbd_work() ×64 │ │
│ │ (35B each) │ │ conn->ops = smb1 │ │
│ │ │ │ │ │
│ │ 1× SMB2 ECHO │───────────────►│ ksmbd_init_smb_server() │ │
│ │ (68B) │ │ conn->ops = smb2 ← FLIP │ │
│ │ │ │ │ │
│ │ │ 77B error │ kworker: init_smb2_rsp_hdr │ │
│ │ │◄───────────────│ reads buf[40..67] (OOB) │ │
│ │ │ 28 heap bytes │ copies into SMB2 reply │ │
│ └──────────────┘ └────────────────────────────┘ │
│ │ │ │
│ │ counts LEAKS │ logs to dmesg │
│ ▼ ▼ │
│ poc.sh / verify.sh "BUG: KASAN: slab-out-of-bounds │
│ (wire oracle: any in init_smb2_rsp_hdr" │
│ 77B reply = leak) │
└───────────────────────────────────────────────────────────────────────────┘

The PoC binary (opsflip) itself only needs a TCP socket, so it works remotely too. The verify script reads dmesg for KASAN evidence, which requires running on the target.

Walking the Exploit

Step 1: Connect. The attacker opens a raw TCP connection to port 445. No SMB negotiate handshake, no session setup, no authentication. The connection thread starts with conn->need_neg = true, which means every PDU triggers a call to ksmbd_init_smb_server() to re-select the protocol handler.

Step 2: Build the burst. 64 SMB1 PDUs of 35 bytes each. The protocol magic is \xFF S M B, command 0x00, PidHigh set to 0x0002 (which maps to SMB2_LOGOFF_HE when the buffer is misread as an SMB2 header). This command choice is deliberate: it makes smb2_check_user_session() return -EIO immediately (the connection is not authenticated), which triggers the error response path that carries the leaked bytes.

Step 3: Append the flip trigger. One 68-byte SMB2 ECHO (\xFE S M B, StructureSize 64, command 0x000d). This is a legal SMB2 PDU that passes the pdu_size >= 66 check at connection.c:549. It exists only to make the connection thread run init_smb3_11_server() and overwrite conn->ops.

Step 4: Send everything in one write(2). The kernel’s TCP stack delivers the entire payload to the ksmbd connection thread’s socket buffer. The thread parses PDU by PDU: queue work for SMB1 #1, queue work for SMB1 #2, …, queue work for SMB1 #64, then hit the ECHO and flip conn->ops. All 64 SMB1 works are now in the workqueue with the wrong handler.

Step 5: The kworkers process the confusion. Each confused SMB1 work runs init_smb2_rsp_hdr(). The function copies rcv_hdr->Id.SyncId.TreeId from buffer offset 40 (4 bytes past the allocation), rcv_hdr->SessionId from offset 44 (8 bytes), and rcv_hdr->Signature from offset 52 (16 bytes). All 28 bytes come from whatever lives in the adjacent SLAB memory.

Step 6: The leak walks out. The confused work fails smb2_check_user_session() with -EIO (the connection has no valid session) and init_smb2_rsp_hdr() copies the response header (including the leaked fields) into a 77-byte SMB2 error response. The response is sent back to the attacker over TCP. The attacker reads the SessionId and Signature fields from the response to extract the 24 leaked bytes (plus 4 from TreeId).

An important oracle: a work that was NOT confused (stays with SMB1 handlers) triggers set_smb1_rsp_status(), which sets send_no_response = true. The server sends nothing back. So any 77-byte SMB2 error reply on this connection is proof that the flip happened and the OOB read occurred. The attacker does not need KASAN or dmesg access to confirm exploitation; the response itself is the evidence.

What Comes Back in the Reply

The 77-byte SMB2 error response has a fixed structure: 4-byte RFC1002 length, 64-byte SMB2 header, 9-byte error body. The leaked bytes sit in three header fields:

  • TreeId (4 bytes at header offset 36): maps to buffer offset 40..43, the first 4 bytes past the allocation
  • SessionId (8 bytes at header offset 40): maps to buffer offset 44..51
  • Signature (16 bytes at header offset 48): maps to buffer offset 52..67, extending 3 bytes into the next SLAB object

On a KASAN kernel, these fields contain 0x00 (in-object padding) and 0xFC (redzone poison). On a production kernel, they contain whatever the SLAB allocator left in those bytes: stale heap pointers from freed objects, fragments of recently deallocated network buffers, partial contents of other connections’ request data. Each trigger attempt reads a different snapshot of the heap neighborhood, because the SLAB allocator recycles slots and different objects occupy the adjacent positions at different times.

The Crash (Proof)

KASAN catches the out-of-bounds read in init_smb2_rsp_hdr:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BUG: KASAN: slab-out-of-bounds in init_smb2_rsp_hdr+0x6bd/0x790
/root/linux/fs/smb/server/smb2pdu.c:661
Read of size 4 at addr ffff8880222c2ba8 by task kworker/2:1/61

CPU: 2 UID: 0 PID: 61 Comm: kworker/2:1 Not tainted 7.3.0-rc3 #3
Workqueue: ksmbd-io handle_ksmbd_work
Call Trace:
<TASK>
dump_stack_lvl+0x100/0x190
print_report+0x139/0x4ad
kasan_report+0xe4/0x1d0
init_smb2_rsp_hdr+0x6bd/0x790
handle_ksmbd_work+0x1b1/0x1ff0
process_one_work+0xaf7/0x1b30
worker_thread+0x5eb/0xe50

Allocated by task 9549:
__kvmalloc_node_noprof+0x352/0x9a0
ksmbd_conn_handler_loop+0x67b/0x1260

The buggy address belongs to the object at ffff8880222c2b80
which belongs to the cache kmalloc-64 of size 64
The buggy address is located 0 bytes to the right of
allocated 40-byte region [ffff8880222c2b80, ffff8880222c2ba8)

The allocation is 40 bytes in kmalloc-64. The read at ffff8880222c2ba8 is exactly at the boundary (0 bytes to the right of the 40-byte region), confirming the 28-byte overread into the SLAB redzone and neighboring objects.

The leaked bytes show up on the wire. Here are actual samples from the PoC:

1
2
OPSFLIP:exploit:LEAK mid=00000003 sess=ffff8880222c2ba8 sig=00000000000000000000fc00fc00fc00
OPSFLIP:exploit:LEAK mid=00000019 sess=0000000000000000 sig=00fcfcfcfcfcfcfcfcfcfcfcfcfcfcfc

The 0xfc bytes are KASAN’s slab redzone poison (SLUB_RED_ACTIVE). On a kernel without KASAN, those bytes would contain live heap data from adjacent allocations: function pointers, buffer contents from other connections, freed object metadata. Each attempt reads a different slice of whatever the allocator placed next to the request buffer.

Negative Control

Same payload on a patched kernel (commit 68f410faedaf):

1
2
3
4
OPSFLIP:exploit:child=0 sent=32000 replies=0 LEAKS=0 nonzero=0
OPSFLIP:exploit:child=1 sent=32000 replies=0 LEAKS=0 nonzero=0
$ dmesg | grep -c "BUG: KASAN"
0

Zero leaks. Zero KASAN reports. The wait_event() drains all queued work before reading the next PDU, preventing the ops flip entirely. The patched connection thread blocks until req_running drops to zero after each negotiation-phase PDU.

Impact

This is a pre-auth kernel heap information disclosure with a path to worse.

The attacker needs only TCP connectivity to port 445 and a guest share on the target (a common default). Zero credentials are required. Each trigger attempt leaks 28 bytes of kernel heap. Over thousands of iterations (the PoC runs 32,000 per connection by default), the attacker can:

  • Map heap layout: leak kmalloc-64 SLAB object addresses, defeating kernel ASLR
  • Read kernel pointers: function pointers from freed objects reveal kernel text base
  • Harvest cross-connection data: other sessions’ metadata, partial SMB buffers, authentication tokens sitting in adjacent SLAB objects

The comparison with ZDI-CAN-33549 is worth making explicit. That bug needed RDMA hardware (InfiniBand, RoCE, or soft-RoCE) and SMB2 authentication to reach the vulnerable code path. This one needs nothing but TCP. ZDI-CAN-33549 wrote attacker-controlled data past the buffer (OOB write, heap corruption, kernel crash). This one reads kernel data past the buffer and sends it to the attacker (OOB read, information disclosure). In a multi-stage attack, you would use ZDI-CAN-33774 first to leak heap layout and defeat KASLR, then use ZDI-CAN-33549 (or the documented OOB write path in this bug) to achieve controlled corruption. Together they cover the full read-then-write primitive that kernel exploitation chains typically require.

The vulnerability report also documents two additional paths from the same race:

  1. OOB write via smb2_set_info -> parse_sec_desc: if the confused work reaches the SET_INFO handler instead of failing at check_user_session, the attacker’s data is written past the buffer. This PoC does not exercise this path.
  2. NULL dereference via conn->ops->is_sign_req: smb1_server_ops does not define is_sign_req. If conn->ops flips back to SMB1 between check_user_session (server.c:202) and the is_sign_req check (server.c:210), the kworker calls a NULL function pointer and oopses. This is a denial of service.
Claim Status Evidence
Race in conn->ops dispatch Proven KASAN slab-out-of-bounds READ in init_smb2_rsp_hdr
28-byte heap OOB read Proven Read at ffff8880222c2ba8, 0 bytes right of 40-byte kmalloc-64
Info leak to attacker on wire Proven Non-zero heap bytes in SMB2 error reply SessionId+Signature
Pre-auth exploitation Proven No credentials used; guest share access only
OOB write path Documented Via smb2_set_info -> parse_sec_desc (not exercised)
NULL deref (DoS) Documented conn->ops->is_sign_req NULL when SMB1 ops loaded mid-handler
RCE Not proven Info leak + OOB write path demonstrates mechanism; RCE theoretical

Detection

Network (Suricata): The exploit sends rapid SMB1/SMB2 protocol interleaving on a single connection, which never happens in legitimate SMB negotiation. A legitimate client completes SMB1 negotiation before switching to SMB2 and never interleaves the two. A detection rule is in the repository.

Host (kernel logs): Look for KASAN reports in init_smb2_rsp_hdr:

1
dmesg | grep -A5 "slab-out-of-bounds in init_smb2_rsp_hdr"

A Sigma rule for kernel log detection is in the repository.

The Fix

Commit 68f410faedaf by Namjae Jeon adds 5 lines to queue_ksmbd_work():

1
2
3
4
5
bool wait_for_neg = ksmbd_conn_new(conn) || ksmbd_conn_need_negotiate(conn);
// ... existing ksmbd_queue_work(work) ...
if (wait_for_neg)
wait_event(conn->req_running_q,
atomic_read(&conn->req_running) == 0);

When the connection is new or still negotiating, the thread waits for all queued work to complete before reading the next PDU. This ensures conn->ops cannot be flipped while earlier works are still in-flight. After negotiation completes and need_neg is cleared, PDUs pipeline normally with no performance impact.

The fix correctly targets the negotiation window: it serializes only the first few PDUs on a connection (where the protocol switch happens) and returns to full pipelining once the protocol is established. The performance cost is minimal because the wait_event() only fires while need_neg is true, which lasts for exactly the first one or two PDUs on every connection.

An alternative fix would be to save conn->ops per-work-item at enqueue time, so each kworker uses the ops table that was current when its request was queued. That approach would be more general (it would prevent any future TOCTOU through conn->ops) but would require changes to every callsite in __handle_ksmbd_work(). The chosen fix is simpler and narrower: it eliminates the race by preventing the scenario where the connection thread can modify conn->ops while work items from a previous protocol are still in flight.

The root cause is a design pattern that appears in other network subsystems too: a per-connection dispatch table modified by the receive path while workers from earlier receives are still active. Any time a server supports protocol upgrades or multi-protocol negotiation with a shared handler table and asynchronous work queues, this class of bug is possible.

The fix is in the ksmbd maintainer tree but has not reached mainline as of October 24, 2026.

Timeline

Date Event
2026-09-30 Namjae Jeon commits fix (68f410faedaf) in ksmbd maintainer tree
2026-10-21 ZDI discloses ZDI-CAN-33774 to public LKML (no patch in mainline)
2026-10-22 Greg KH responds: “And again, no patch, and a public list?”
2026-10-22 Namjae Jeon confirms fix exists in his tree
2026-10-24 This write-up and PoC published

Reproduction

Readers can verify every claim in this post:

1
2
3
4
5
6
7
8
9
10
git clone https://github.com/al4n4n/ZDI-CAN-33774.git
cd ZDI-CAN-33774

# Inside a QEMU VM with ksmbd + KASAN kernel (7.3-rc3 or earlier):
bash setup.sh
bash verify.sh 10.0.2.15

# Or trigger manually:
./poc/opsflip -h 10.0.2.15 -m exploit -C 4 -I 500 -B 64
dmesg | grep -A20 "BUG: KASAN"