Reading Time: 8 minutes
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.
This blog post is a re-post of the original article “Root Cause Analysis of a Printer’s Driver Vulnerability” that I have written for Yarix on YLabs.
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.
DriverEntry
, Dispatch Routines, the
IRP_MJ_DEVICE_CONTROL
structure, IOCTL codes,
_SEP_TOKEN_PRIVILEGES
and
EPROCESS
structures as well as their exploitation I highly recommend reading this lengthy blog post.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.
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
To find the
DeviceName
we have three possible options, depending on obfuscation and driver’s complexity some are better than others:
strings64.exe SSPORT.sys
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 [email protected]@@@
. 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.
GLOBAL??
DriverObject
initialization where the
DeviceName
will be instantiated.\DosDevices\ssportc
\Device\SSPORT
We’ll later use the
DeviceName
to communicate with the driver and reach the vulnerable function.
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.
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 [email protected]@@@@ !!!") + 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:
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
and
IoDeleteDevice
).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.
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
9C402408
1770
h (really anything bigger than 4096 bytes)20000000
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?
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
.
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.
Hat tip to @last and @wvuuuuuuuuuuuuu for the peer review.