Back to Posts

Share this post

Root Cause Analysis of a Printer’s Drivers Vulnerability CVE-2021-3438

Posted by: voidsec

Reading Time: 12 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.

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.

  1. 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.
  2. Windows Kernel Exploitation: Exploiting System Mechanic Driver
    If you are not familiar with concepts like 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.
  3. 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:

  1. 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.

  1. 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??
  2. Reverse Engineering: looking at the DriverObject initialization where the DeviceName 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 (IOCTLs) 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:

  1. IOCTL codes and possible buffer constraints.
  2. 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 and IoDeleteDevice).

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

Thanks to 0x1F9F1 for the cleaned up version of the decompilation:

char GlobalBuffer[4096];

NTSTATUS SioctlDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS status = STATUS_SUCCESS;
    size_t datalen = strlen("This String is from Device Driver@@@@@ !!!") + 1;
    PIO_STACK_LOCATION irpSP = IoGetCurrentIrpStackLocation(Irp);
    ULONG inBufLength = irpSP->Parameters.DeviceIoControl.InputBufferLength;
    ULONG outBufLength = irpSP->Parameters.DeviceIoControl.OutputBufferLength;

    if (!inBufLength || !outBufLength) {
        status = STATUS_INVALID_PARAMETER;
        goto End;
    }

    switch (irpSP->Parameters.DeviceIoControl.IoControlCode) {
        case 0x9C402408: // CTL_CODE(40000, 0x902, METHOD_BUFFERED, FILE_ANY_ACCESS)
        {
            char* inBuf = (char*)Irp->AssociatedIrp.SystemBuffer;
            char* outBuf = (char*)Irp->AssociatedIrp.SystemBuffer;

            if (strcmp(inBuf, "AA"))
            {
                strncpy(GlobalBuffer, inBuf, inBufLength);
            }
            else
            {
                inBuf = GlobalBuffer;
            }

            strncpy(outBuf, inBuf, outBufLength);
            Irp->IoStatus.Information = outBufLength < datalen ? outBufLength : datalen; 
            break; 
        } 

        case 0x9C402401: // CTL_CODE(40000, 0x900, METHOD_IN_DIRECT, FILE_ANY_ACCESS)
        case 0x9C402406: // CTL_CODE(40000, 0x901, METHOD_OUT_DIRECT, FILE_ANY_ACCESS)
        case 0x9C40240F: // CTL_CODE(40000, 0x903, METHOD_NEITHER, FILE_ANY_ACCESS) 
            break; 

        default: 
            status = STATUS_INVALID_DEVICE_REQUEST; 
            break;
    } 

End: 
    Irp->IoStatus.Status = status;
    IofCompleteRequest(Irp, 0);
    return status;
}

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: 1770h (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.

Hat tip to @last and @wvuuuuuuuuuuuuu for the peer review.

Video PoC

Resources & References

Back to Posts