Root Cause Analysis of a Printer’s Driver Vulnerability
Last week SentinelOne disclosed a "high severity" flaw in HP, Samsung, and Xerox printer's drivers (CVE-2021-3438); the blog post highlighted a vulnerable strncpy
operation with a user-controllable size parameter but it did not explain the reverse engineering nor the exploitation phase of the issue. With this blog post, I would like to analyse the vulnerability and its exploitability.
Pre-requisites
As I’ve already blogged before about driver exploitation and reverse engineering there will be some concepts that I would give per granted and as a pre-requisite, feel free to skip them if you are already familiar with the topics.
- Windows Kernel Exploitation: Setting up the lab
Setting up kernel debugging on different Windows flavours; as the blog post does not explicitly mention Windows 10, follow the “More Windows Debuggee Flavours” paragraph. - Windows Kernel Exploitation: Exploiting System Mechanic Driver
If you are not familiar with concepts likeDriverEntry
, Dispatch Routines, theIRP_MJ_DEVICE_CONTROL
structure, IOCTL codes,_SEP_TOKEN_PRIVILEGES
andEPROCESS
structures as well as their exploitation I highly recommend reading this lengthy blog post. - Reverse Engineering & Exploiting Dell CVE-2021-21551
More driver's reverse engineering practice, buffer setup, buffer constraints as well as exploiting an arbitrary write vulnerability for privilege escalation.
Reverse Engineering
First of all, I had to recover a copy of the SSPORT.sys
driver mentioned by SentinelOne in their blog post, as HP removed every links to the outdated and vulnerable driver, recovering it was a bit of a challenge. I've ended up downloading a Xerox driver (which is signed by Samsung ¯\_(ツ)_/¯
); Xerox's ZIP includes different drivers compiled for both x86 and x64 architectures and different Windows OS versions (there’s also a version compiled with stack cookies). I've chosen the following one:
SSPORT.sys - SHA1: CCD547EF957189EDDB6EE213E5E0136E980186F9
You can download the driver file as well as the IDA’s DB (to follow along) from my GitHub repository.
IDA's Structures
We can start the analysis by loading the driver into our preferred disassembler, IDA in my case, and adding the following needed structures if missing:
DRIVERSTATUS
DRIVER_OBJECT
IRP
IO_STACK_LOCATION
DeviceName
To find the DeviceName
we have three possible options, depending on obfuscation and driver's complexity some are better than others:
- strings (or poor's man technique):
strings64.exe SSPORT.sys
which will return a list of strings present in the binary. It's definitely the easiest and less complex way but won't work if the driver is too complex (a lot of strings) or if the code is obfuscated/encrypted (we'll dump garbage).
Note: in the above image we can also see the pdb
file location (compilation symbols) and an interesting, hardcoded string: This String is from Device Driver@@@@
. As previously noted by Sentinel One, it seems that Samsung didn’t entirely develop the driver but copied part of it from a Windows Driver Samples Project by Microsoft that has almost the same functionality; fortunately, the MS sample project does not contain the vulnerability.
- Leveraging WinObj: once the driver is loaded in the system we can leverage WinObj to recover the device name and privileges by looking in the
GLOBAL??
- Reverse Engineering: looking at the
DriverObject
initialization where theDeviceName
will be instantiated.
Device Name:
\DosDevices\ssportc
\Device\SSPORT
We'll later use the DeviceName
to communicate with the driver and reach the vulnerable function.
DriverEntry
Loading our driver in IDA we'll be presented with the following list of functions:
DriverEntry
sub_15000
sub_15030
sub_15070
We'll start our analysis from DriverEntry
, which is small and honestly not very interesting. Here DeviceName
is being instantiated and the DriverObject
is passed around.
Let's decompile it:
Looking at MajorFunction[14]
(offset 0x0e
) we found the driver IRP_MJ_DEVICE_CONTROL
, a request that drivers must support (in a DispatchDeviceControl
routine) if a set of system-defined I/O control codes (IOCTL
s) exists.
sub_15070 - dispatch routine
Looking at sub_15070
it's clear we're in a dispatch routine. Here IOCTL codes are compared in a sort of "switch-case" as visible in the following image:
Decompiling this function, we are greeted with the following C++ like code (I've cleaned it a bit and renamed some variables to make it more comprehensible):
__int64 __fastcall dispatch_routine(__int64 DeviceObject, PIRP Irp) { unsigned int status; // ebx unsigned __int64 hardcoded_array_len; // kr08_8 unsigned int hardcodedArray_len; // edi _IO_STACK_LOCATION *v6; // rax size_t UserBufferIn_Length; // r8 unsigned int len; // er12 ULONG IOCTL_Code; // eax char *dst; // r13 char *v11; // rax char *v12; // rdx unsigned __int8 v13; // cl int flag; // eax const char *src; // rdx status = 0; hardcoded_array_len = strlen("This String is from Device Driver@@@@@ !!!") + 1; hardcodedArray_len = hardcoded_array_len; v6 = Irp->Tail.Overlay.CurrentStackLocation; UserBufferIn_Length = v6->Parameters.Create.Options; len = v6->Parameters.Read.Length; if ( (_DWORD)UserBufferIn_Length && len ) { IOCTL_Code = v6->Parameters.Read.ByteOffset.LowPart; if ( IOCTL_Code != 0x9C402401 && IOCTL_Code != 0x9C402406 ) { if ( IOCTL_Code == 0x9C402408 ) { dst = (char *)Irp->AssociatedIrp.MasterIrp; v11 = dst; v12 = (char *)((char *)qword_FFFFF8036C401030 - dst); while ( 1 ) { v13 = *v11; if ( *v11 != v12[(_QWORD)v11] ) break; ++v11; if ( !v13 ) { flag = 0; goto to_or_from; } } flag = -((unsigned __int8)*v11 < (unsigned int)v12[(_QWORD)v11]) - (((unsigned __int8)*v11 < (unsigned int)v12[(_QWORD)v11]) - 1); to_or_from: if ( flag ) { strncpy(Dest, (const char *)Irp->AssociatedIrp.MasterIrp, UserBufferIn_Length);// buff=1000h src = dst; } else { src = Dest; } strncpy(dst, src, len); // if flag has been set: copy from UserBufferIn to UserBufferIn // if flag has not been set: copy from buff to UserBufferIn if ( len < (unsigned int)hardcoded_array_len ) hardcodedArray_len = len; Irp->IoStatus.Information = hardcodedArray_len; } else if ( IOCTL_Code != 0x9C40240F ) { status = 0xC0000010; // STATUS_INVALID_DEVICE_REQUEST } } } else { status = 0xC000000D; // STATUS_INVALID_PARAMETER } Irp->IoStatus.Status = status; IofCompleteRequest(Irp, 0); return status; }
Here we are mostly interested in two things:
- IOCTL codes and possible buffer constraints.
- Finding the vulnerable
strncpy
operation.
sub_1500
and sub_15030
won't be discussed here as they're related to other driver's functionalities. They are respectively used to:
sub_1500
is called by the I/O system when the SIOCTL is opened or closed. It indicates that the caller has completed all processing for a given I/O request and returns the given IRP to the I/O manager (IofCompleteRequest
). No action is performed other than completing the request successfully.sub_15030
is called by the I/O system to unload the driver. (IoDeleteSymbolicLink
andIoDeleteDevice
).
Root Cause Analysis
As we can see from sub_15070
decompiled code, first the IOCTL code is retrieved and compared. The provided IOCTL code must be different from both 0x9C402401
and 0x9C402406
, if the IOCTL code is 0x9C402408
we "fall" into a case where two strncpy
operations are performed, otherwise, the driver will return a STATUS_INVALID_DEVICE_REQUEST
error code.
Obviously, we are interested in the 0x9C402408
IOCTL code, specifically this part of code:
dst = *(char **)(a2 + 0x18); v11 = dst; v12 = (char *)((char *)qword_FFFFF80655A31030 - dst); while ( 1 ) { v13 = *v11; if ( *v11 != v12[(_QWORD)v11] ) break; ++v11; if ( !v13 ) { v14 = 0; goto to_or_from; } } v14 = -((unsigned __int8)*v11 < (unsigned int)v12[(_QWORD)v11]) - (((unsigned __int8)*v11 < (unsigned int)v12[(_QWORD)v11]) - 1); to_or_from: if ( v14 ) { strncpy(buff, *(const char **)(a2 + 0x18), v7);// buff=1000h src = dst; } else { src = buff; } strncpy(dst, src, len); // copy from UserBufferIn to UserBufferOut
Which we can further clean and make it a bit more readable:
buff = [4096]; dst = *UserBufferIn; v12 = *HarcodedArray - *UserBufferIn); int i = 0; while (1) { v13 = *UserBufferIn[i]; if (*UserBufferIn[i] != *(UserBufferIn + HarcodedArray))] ) goto set_flag; ++i; if (!v13) { flag = 0; goto to_or_from; } } set_flag : flag = 1; to_or_from : if (flag) { // !! Vulnerable Function !! // copy from UserBufferIn to buff strncpy(buff, *UserBufferIn, UserBufferIn.length); // --------------------------- src = *UserBufferIn; } else { src = buff; } // if flag has been set: copy from UserBufferIn to UserBufferIn // if flag has not been set: copy from buff to UserBufferIn strncpy(dst, src, UserBufferIn.length);
The vulnerable function copies bytes from the user's input buffer via the strncpy
function call with an arbitrary size parameter (controlled by the user), causing a buffer overflow.
To being able to exploit this issue, we should verify if the overflowing data can corrupt some important return values on the stack/function pointers/adjacent variables to control and redirect the execution flow.
Testing Assumptions
As we now expect to crash the driver with any payload big enough to overflow the static buffer (size 4096 bytes), we should also verify our hypothesis.
We can start off by configuring IOCTLpus to use the following settings:
DeviceName
:\\.\ssportc
- IOCTL Code:
9C402408
- Input & Output size:
1770
h (really anything bigger than 4096 bytes) - Access Mask:
20000000
- Input Buffer: 6000 bytes (or a value congruent with what was set in the "Input & Output size") of any content
We should be greeted in WinDbg with the following Bugcheck Analysis (snipped for brevity):
ATTEMPTED_WRITE_TO_READONLY_MEMORY (be) An attempt was made to write to readonly memory. Arguments: Arg1: fffff80672d84000, Virtual address for the attempted write. Arg2: 8900000233e9c021, PTE contents. Arg3: ffffd7035747d610, (reserved) Arg4: 000000000000000b, (reserved) Debugging Details: ------------------ BUGCHECK_CODE: be BUGCHECK_P1: fffff80672d84000 BUGCHECK_P2: 8900000233e9c021 BUGCHECK_P3: ffffd7035747d610 BUGCHECK_P4: b PROCESS_NAME: IOCTLpus.exe TRAP_FRAME: ffffd7035747d610 -- (.trap 0xffffd7035747d610) rax=4141414141414141 rbx=0000000000000000 rcx=000035f7d33dd000 rdx=ffffc20e9f9a7000 rsi=0000000000000000 rdi=0000000000000000 rip=fffff8067499c380 rsp=ffffd7035747d7a8 rbp=0000000000000002 r8=0000000000000768 r9=8101010101010100 r10=7efefefefefefefe r11=fffff80672d83000 r12=0000000000000000 r13=0000000000000000 r14=0000000000000000 r15=0000000000000000 iopl=0 nv up ei pl zr na po nc nt!strncpy+0x30: fffff806`7499c380 4889040a mov qword ptr [rdx+rcx],rax ds:fffff806`72d84000=0000502200005000 STACK_TEXT: nt!DbgBreakPointWithStatus nt!KiBugCheckDebugBreak+0x12 nt!KeBugCheck2+0x952 nt!KeBugCheckEx+0x107 nt!MiSystemFault+0x18fc30 nt!MmAccessFault+0x34f nt!KiPageFault+0x35a ffffd703`5747d7a8 fffff806`72d85139 : 00000000`00000000 fffff806`74e1da15 00000000`00000000 00000000`00000000 : nt!strncpy+0x30 ffffd703`5747d7b0 fffff806`74827da9 : 00000000`00000000 00000000`00000001 00000000`00000001 00000000`0000020c : SSPORT+0x5139 SYMBOL_NAME: SSPORT+5139 MODULE_NAME: SSPORT IMAGE_NAME: SSPORT.sys STACK_COMMAND: .thread ; .cxr ; kb BUCKET_ID_FUNC_OFFSET: 5139 FAILURE_BUCKET_ID: 0xBE_SSPORT!unknown_function OS_VERSION: 10.0.18362.1 BUILDLAB_STR: 19h1_release OSPLATFORM_TYPE: x64 OSNAME: Windows 10 FAILURE_ID_HASH: {c3ac0246-f599-25de-5b8c-cf711e209873}
This is not exactly great as we are failing inside nt!strncpy+0x30
with an ATTEMPTED_WRITE_TO_READONLY_MEMORY
error caused by the mov qword ptr [rdx+rcx],rax
instruction given the fact that [rdx+rcx]
is referencing a piece of memory that cannot be written. Why is that happening? Is it really exploitable?
Exploitability
A closer inspection in IDA will better explain the above fault:
As we can see from the above image, the buffer has been allocated in the .data
segment (at the start of the section).
The
.data
segment contains any global or static variables which have a pre-defined value and can be modified; any variables that are not defined within a function (and thus can be accessed from anywhere) or are defined in a function but are defined as static so they retain their address across subsequent calls. The values for these variables are initially stored within the read-only memory (typically within.text
) and are copied into the.data
segment during the start-up routine of the program. - Wikipedia
If we'll look at the sections within WinDbg we should have the following layout:
lmDvmSSPORT !address SSPORT !dh SSPORT start fffff806`71550000 .text - fffff806`71551000 - fffff806`715510BE - Execute Read .rdata - fffff806`71552000 - fffff806`715520E4 - Read Only .data - fffff806`71553000 - fffff806`71553064 - Read Write buffer - fffff806`71553000 <<----- .pdata - fffff806`71554000 - fffff806`71554030 - Read Only PAGE - fffff806`71555000 - fffff806`71555178 - Execute Read INIT - fffff806`71556000 - fffff806`715561C2 - Execute Read Write .rsrc - fffff806`71557000 - fffff806`71557400 - Read Only end fffff806`71558000
From the above schema, we can verify that our buffer really resides in the .data
segment and that the entire data section is big 4096 bytes (or one page). When overflowing the buffer, we are also implicitly overflowing the .data
section and overwriting also the .pdata
section (which privileges are set as "Read Only"); that's why we are getting the ATTEMPTED_WRITE_TO_READONLY_MEMORY
error inside nt!strncpy+0x30
.
Conclusion
The buffer, initialized with all zeroes, is the only reference in all of the data segments and it is only used in the highlighted strncpy
operations; there are no pointers nor interesting structures written inside it that we can corrupt to redirect the execution flow.
This vulnerability can, at best, be used to perform a local Denial of Service (DoS) crashing the entire OS.
Given all the above analysis, and threat risk, I think a more appropriate CVSS score is 6.5, rather than the arbitrary 8.8/10 score given to the original CVE.