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:
- Calls
ksmbd_init_smb_server(conn)to inspect the PDU’s protocol magic (\xFF SMBfor SMB1,\xFE SMBfor SMB2) and setconn->opsto the matching dispatch table - Allocates a work item, binds
work->request_bufto 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 | ksmbd_init_smb_server(conn); // (1) sets conn->ops based on THIS PDU's magic |
In the kworker, __handle_ksmbd_work():
1 | conn->ops->allocate_rsp_buf(work); // (4) reads conn->ops NOW, not when queued |
The ops selection in smb_common.c:
1 | // smb_common.c:419-426 - init_smb1_server() |
When init_smb2_rsp_hdr() runs on an SMB1 buffer, it reads fields that do not exist in the allocation:
1 | // smb2pdu.c:647-661 |
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-EINVALat smb_common.c:171 before incrementingoutstanding_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 | ┌──────────────────────── QEMU VM (kernel 7.3-rc3 + KASAN) ─────────────────┐ |
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 | BUG: KASAN: slab-out-of-bounds in init_smb2_rsp_hdr+0x6bd/0x790 |
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 | OPSFLIP:exploit:LEAK mid=00000003 sess=ffff8880222c2ba8 sig=00000000000000000000fc00fc00fc00 |
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 | OPSFLIP:exploit:child=0 sent=32000 replies=0 LEAKS=0 nonzero=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-64SLAB 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:
- OOB write via
smb2_set_info->parse_sec_desc: if the confused work reaches the SET_INFO handler instead of failing atcheck_user_session, the attacker’s data is written past the buffer. This PoC does not exercise this path. - NULL dereference via
conn->ops->is_sign_req:smb1_server_opsdoes not defineis_sign_req. Ifconn->opsflips back to SMB1 betweencheck_user_session(server.c:202) and theis_sign_reqcheck (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 | bool wait_for_neg = ksmbd_conn_new(conn) || ksmbd_conn_need_negotiate(conn); |
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 | git clone https://github.com/al4n4n/ZDI-CAN-33774.git |