About Me

Kernel Driver Exploit: System Mechanic

Table of Contents

Introduction

Upon completing the Windows Kernel Exploitation by Ashfaq Ansari he provides a challenge to write a exploit for the System Mechanic driver. In this blog post we’ll dive deep into the driver recon, vulnerability discovery, and the eventual driver exploitation. I’ll also explain how I potentially discovered a new vulnerability (Vulnerability #2) which allows for arbitrary read and write of memory. Currently all of the public exploits/blogs (that I found) reference Vulnerability #1 which is why I think Vulnerability #2 has either gone undiscovered or is at least less widely known.

Driver Recon

In order for a user mode application to communicate with a kernel driver through the use of IOCTL’s (explained below) the user mode process must open up a handle to the device object using a symbol link that the driver has created. The following kernel API functions are used to create the symlink.

When reversing the System Mechanic Driver entry routine in Ghidra, I eventually came across the function that creates this symbolic link. This function I renamed to InitializeDevice and is shown below.

System Mechanic Device Initialization Function

We can verify that this symbolic link exists by using a tool called WinObj (from SysInternals).

WinObj Viewing System Mechanic Symbolic Link

Major Functions

A Driver_Object instance is provided by the windows kernel to a driver through the DriverEntry parameters when it is loaded.

NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT  DriverObject,_In_ PUNICODE_STRING RegistryPath);
{
   << Driver Initialization Code>>
}

The DriverObject Structure is shown below. The member that is most important for us when reviewing IOCTL’s is the MajorFunction pointer.

typedef struct _DRIVER_OBJECT {
  CSHORT             Type;
  CSHORT             Size;
  PDEVICE_OBJECT     DeviceObject;
  ULONG              Flags;
  PVOID              DriverStart;
  ULONG              DriverSize;
  PVOID              DriverSection;
  PDRIVER_EXTENSION  DriverExtension;
  UNICODE_STRING     DriverName;
  PUNICODE_STRING    HardwareDatabase;
  PFAST_IO_DISPATCH  FastIoDispatch;
  PDRIVER_INITIALIZE DriverInit;
  PDRIVER_STARTIO    DriverStartIo;
  PDRIVER_UNLOAD     DriverUnload;
  PDRIVER_DISPATCH   MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;

The MajorFunction pointer is a pointer to a table of callback function pointers. The Windows I/O manager uses this callback table to direct the proper handling of I/O Request Packets (IRP’s). The callback table size is static and each index in the callback table is associated with a MajorFunction. Driver developers looking to implement functionality for a specific MajorFunction will place the callback function address into the proper table index.

Major Function Codes

When driver developers need user mode applications to communicate with the kernel driver the IRP_MJ_DEVICE_CONTROL (0x0e) is used. The user mode application uses the DeviceIOControl windows API to then communicate with the driver. When DeviceIOControl is called the kernel I/O Manger will go to the specified drivers MajorFunction table and call the function located at the index 0x0e (IRP_MJ_DEVICE_CONTROL). The callback function must support the following routine.

NTSTATUS DriverDispatch(
  [in, out] _DEVICE_OBJECT *DeviceObject,
  [in, out] _IRP *Irp
)

When viewing the System Mechanic driver, We can see the callback function for IRP_MJ_DEVICE_CONTROL being set down at the bottom of the bottom of the InitializeDevice routine. I renamed the callback function to IrpDeviceControlHandler.

System Mechanic Major Function callbacks being set

Device IOCTL’s

Input and Output Controls (IOCTL’s) is a 32bit value passed into the DeviceIOControl API. The receiving driver can obtain this IOCTL by parsing the IRP structure, once the IOCTL code is retrieved the specified function can be invoked. An example of this is shown below.

switch(IOCTL) { 
    case 0xAAAAAAAA: 
       functionA();
       break;
    case 0xBBBBBBBB:
       functionB();
       break;
}

Although it may appear that IOCTL’s would be arbitrary numbers selected by the driver developers, they are not. IOCTL’s have a specific structure that the I/O manager also interprets.

IOCTL Definition
----------------------
CTL_CODE( DeviceType, Function, Method, Access ) (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

DDDD.DDDD.DDDD.DDDD.AAFF.FFFF.FFFF.FFMM

D = Device type bits 
A = Access bits
F = Function bits
M = Method bits

Device type: Are bits specifying the underlying hardware for the driver, this can be found as a parameter to IoCreateDevice.

Access bits: can be 0, 1, 2, or 3.

FILE_ANY_ACCESS        0x0 ->  anyone with a handle with the device can call the this IOCTL function
FILE_READ_ACCESS       0x1 -> the caller must have read access to the device to call the IOCTL function
FILE_WRITE_ACCESS      0x2  -> the caller must have read access to the device to call the IOCTL function
(FILE_READ_ACCESS | FILE_WRITE_ACCESS)  0x3 -> the caller must have read & write access to the device to call the IOCTL function

Function Bits: This value is defined by the driver developers however Microsoft reserves values under 0x800, thus vendors use 0x800 and greater.

Method Bits: can be 0, 1, 2, or 3. These bits are arguably the most important as they determine how the I/O manager passes the usermode buffer to the driver.

  • Buffered I/O 0x0
    • I/O manager validates that the buffer is in usermode address space
    • I/O manager copies the buffer into newly allocated memory in kernel space, this is where the driver can work on the buffer.
  • METHOD_IN_DIRECT   0x1 & METHOD_OUT_DIRECT 0x2
  • Neither I/O 0x3
    • No validation on the callers buffer (size or address)
      • Both the address & the size of the buffer are supplied by the user.
    • Driver must be in the same application context to work on this buffer

We previously saw that the IRP_MJ_DEVICE_CONTROL callback routine jumps to a function that I renamed to IrpDeviceControlHandler. The IrpDeviceControlHandler will be the function that will handle the IOCTL control codes as seen below.

System Mechanic handling of IOCTL’s

In the screenshot above we can see that the IRP is parsed and the IOCTL received equals 0x226003. If this IOCTL is not found System Mechanic returns the status 0xc0000010 (STATUS_INVALID_DEVICE_REQUEST). The IOCTL code can be parsed using the OSR Online IOCTL Decoder.

Decoded System Mechanic IOCTL

The parsed IOCTL provides two important pieces of information. First it shows that METHOD_NEITHER is used, this is useful because we know that the buffer will not be verified by the I/O Manager. Second we know that for a usermode application to send this IOCTL code, it will need to have Read privileges on the device handle. We can find who has these privileges by viewing the device in WinObj as shown below.

System Mechanic AMP Privileges

Vulnerability Discovery

Initial Fuzzing

Initial fuzzing begins with a tool called IOCTLBF. This basic fuzzer will provide a starting point for vulnerability research, the command used to fuzz the AMP driver.

IOCTLBF command to fuzz the System Mechanic Driver
  • Parameters:
    • -d
      • Device Driver Symlink
    • -i
      • IOCTL to fuzz
    • -u
      • Only fuzz the specified IOCTL

Crash Analysis

The crash we’re about to observe isn’t caused by IOCTLBF, Instead I used ioctlplus (updated by voidsec, created by Jackson T). This was used to emulate the crash that IOCTLBF would have produced but allows me to control the buffer for explanation purposes. The buffer created with ioctlplus is shown below.

Buffer That Causes System Mechanic To Crash
System Mechanic Driver Initial Crash

The first the to notice is that the crash happens while attempting to dereference the address 0x6262626262626262 that is stored is RSI. This is great because it is the same address stored in the second QWORD of our buffer, which means the value of RSI is controlled by the user. In the lines just after the initial crash we can see that RSI doesn’t just get dereferenced once, but three more times. Each time RSI increases by a QWORD and gets dereferenced. The dereferenced value is then placed into RCX,RDX,R8,R9 right before the call to RBX. Because we control RSI , we control the input parameters to the call (shown below in the screenshot on line 48). The screenshot below is of the decompiled function (referred to as VulnFunc) that the kernel crashed in. Line 40 (offset0x6C8D) is the line that crashed the kernel.

System Mechanic Initial Crash Function

Now that we know we can control the parameters to the Dynamic call on line 48, the next step is to understand how the function pointer ControlledFunctionPointer gets passed. If we can modify ControlledFunctionPointer we can modify the function called on line 48. To start we first need to understand the call stack to VulnFunc.

VulnFunc Reference Tree

If you recall IrpDeviceControlHandler will be the function that handles incoming IOCTL requests. This function will call IOCTL_FUNC and pass in 3 parameters. Is32Bit Boolean, InputBuffer pointer (pointer to our usermode buffer we supplied), InputBufferLength (user mode supplied length).

System Mechanic IrpDeviceControlHandler

The IOCTL_FUNC is shown below, The variable InputBuffer_DWORD1 will contain the first DWORD supplied in the user buffer, which acts as an index into UnknownGlobal array which points to a function pointer.

System Mechanic IOCTL_FUNC Method

The UnknownGlobal variable is a global variable that leads to a area of memory that is filled in dynamically. This memory is some sort of structure that appears to look like the following:

UnknownGlobal Struct {
   DWORD unknown_DWORD_0,
   DWORD unnown_DWORD_1,
   VOID* FunctionPointer
} 
UnknownGlobal Struct in Memory

In the IOCTL_FUNC code we can also see that InputBuffer_DWORD1 has a check that ensures it is less than 9, thus there are 9 functions (0-8) that the UnknownGlobal Array contains.

Lets review what we know, The user supplied buffer pointer is passed to from IrpDeviceControlHandler to IOCTL_FUNC. The first DWORD from the user supplied buffer is retrieved and then checked to ensure that it is less than 9. Once the check is successful it is used as in index to an array of function pointers. The function pointer in this array is retrieved and stored in a local variable (InputBuffer_DWORD1), the address of this variable (InputBuffer_DWORD1) is then passed to VulnFunc as the parameter ControlledFunctionPointer. We know that VulnFunc retrieves the second QWORD from the user supplied buffer (0x6262626262626262), because the dereference of the second QWORD (0x6262626262626262) is what causes the crash as it tries to resolve the parameters for the next call. This leaves a question, how does the code in VulnFunc retrieve the second QWORD value if it’s not passed into the function? The below picture of the stackframe just before entering VulnFunc should help provide the answer.

System Mechanic IOCTL_FUNC Just Before Execution of VulnFunc
System Mechanic VulnFunc obtaining QWORD2

Now the decompiled code makes more sense as we can see that line 39 in VulnFunc is manipulating the (InputBuffer_DWORD1) pointer to grab the 2nd QWORD stored on the previous stack frame. If this is difficult to understand right now, don’t worry, we’ll dive into it deeper in Vulnerability #1.


Now we know we can call a total of 9 (0-8) functions and control each parameter to them, we should start to do static and dynamic analysis on each function in search of vulnerabilities. The first vulnerability, which we’ll see in a second, was found this way. The second vulnerability was much more illusive and required the creation of a custom fuzzer to assist in discovery.

Vulnerability #1

For the first vulnerability we actually don’t need to dive into any of the 9 functions functions I discussed earlier, instead, we only care about their return value. The dynamic function we control returns a value and places it into the dereferencedInputBufferQword2 variable that was previously used to hold the pointer to our parameters (line 48). then it places it into the location **(undefined **)(ControlledFunctionPointer + 6). We have seen a similar pattern before when we were grabbing the 2nd QWORD from the previous stack. The question now is, where are we placing this returned value?

Lets go back to the decompiled view of IOCT_FUNC and determine where this value is placed. I believe this picture provides a good representation on what’s going on. Remember that the ControlledFunctionPointer variable in VulnFunc is actually the address of InputBuffer_DWORD1 in IOCTL_FUNC.

How VulnFunc Accesses V10 inside IOCTL_FUNC

The return value of the dynamic function call in VulnFunc is stored in V10! On line 51 V10 appears to be placed in a dereferenced location that the user supplies! We can see this because InputBuffer_DwordPointer is assigned on line 20 from the InputBuffer array at the second index (which means it will take the 3rd QWORD we provide in the user buffer). This is great, as it means we can supply a pointer and write the return value anywhere in memory.

It’s important to note, that this vulnerability is enough to write the token privilege exploit that we will be creating by the end of this write up. However, This vulnerability only allows you to write values that get returned from the dynamic function call (line 48) in VulnFunc, Vulnerability #2 allows for arbitrary read write.

Vulnerability #2

We’ll look for the second vulnerability inside the functions we can control through VulnFunc . A good place to start is to send 9 different IOCTL requests (With the supplied first DWORD being 0-8) and set a breakpoint in VulnFunc at line 48 (dereferencedInputBufferQword2 = (*pcVar4)(dereferencedInputBufferQword2,uVar1,uVar2,uVar3)). Once the breakpoint is hit we determine the address of each function you can call. You should start static analysis of those functions and determine if anything stands out. Initially this was what I did, however, some of the functions were difficult to reverse (function 5 in particular) and I didn’t come up with anything useful. My next step was to create a custom fuzzer.

Custom Fuzzer Creation

  • The custom fuzzer must be able to do the following
    • Check all 9 functions we can control
    • Control the parameters between random values (Represented by Param X [in the diagram below]) and usermode address buffer pointers (Represented by Pointer to userspace X [in the diagram below])
      • If the parameter points to a usermode address buffer and if the buffer that this address points to is modified, it should be reported
    • Supply a usermode return address
      • if the buffer that this address points to is modified, it should be reported
    • Should a usermode buffer that is pointed to by the return address or the parameters be modified we should be able to repeat the same IOCTL request again. This is to ensure we’ll be able to setup breakpoints and see the exact same request come through.

I’m not going to go through the code of the fuzzer, as it is hacky, although I’ll upload to my Github where you can view it. Instead I’ll provide a high level diagram of how this works.

Custom Fuzzer Steps 1 & 2
Custom Fuzzer Step 3
Custom Fuzzer Step 4
  • We’re interested in the following differences
    • Return Value
      • Helps us understand what values we can write using Vulnerability #1
    • Param X or Pointer to userspace X
      • This value should not be overwritten, however, if it is then this indicates something we do not understand is happening which may be interesting
    • userspace X
      • Indicates one of parameters was dereferenced and used to store information at that location
    • slack space
      • If this value is overwritten then we know that System Mechanic Driver has written a large buffer of memory

Custom Fuzzer Usage

Previously I mentioned function 5 being the most difficult to reverse, but this function also revealed the most interesting results of the fuzzer.

Custom Fuzzer detecting a change in bytes in a userland buffer

The important thing to note from the image above is that the values at userspace 3 & 4 were modified from their original values (9 & 15f31c32ca0) after the IOCTL call to system mechanic. This could mean that param3 & param4 (Pointer to userspace 3 & 4) was dereferenced by the system mechanic driver and used to place some memory into, this means that we may be able to supply an arbitrary address and have the kernel write into it.

Before starting to dig deeper into function 5, I decided to run more tests from the fuzzer and determine any consistencies that lies within.

Multiple Custom Fuzzer Runs of Function 5

There are a few takeaways from the data above:

  • param2 is always equal to 2 when the userspace buffer is modified
  • If param3 does not point to userspace 3 and param4 points to userspace 4, userspace 4 is still overwrote. This means that param3 & param4 are both dereferenced and have values placed into them. If userspace 4 was not overwrote, this could mean that only param3 was being dereferenced and the kernel was writing more than 8 bytes thus entering into the memory where the value userspace 4 is contained.

Reversing Function 5

Armed with the knowledge above, lets dig into decompiled code of function 5 (called opcode5 in the screenshot below).

Decompiled Function 5

The function starts by checking that param2 < 3 and param1 < 30 (0x1e), our fuzzer examples indicates both these conditions are met. The third conditional statement does a dynamic check that relies on param1 to determine if a value is 0, if true, execution will continue on line 13 (offset 0x8de6), otherwise execution will jump to line 50 offset (0x8dc2). We’ll rerun the fuzzer and until we hit a userspace overwrite, then we’ll setup breakpoints at these offsets (0x8de6,0x8dc2) to determine where our execution takes us. Additionally we’ll set a breakpoint at line 26 offset (0x8e98), to see where the dynamic call goes.

The following was the fuzzer reran until we had a userspace overwrite.

Custom Fuzzer Reran

Now we’ll set the breakpoints and rerun the last request done by the fuzzer.

Setting & Triggering Breakpoints in Function 5

We hit breakpoint at offset amp+0x8e98 which means we hit line 26. Lets first verify where this Call will take us (by figuring out what is stored at qword ptr [rsp+38]) and then verify our parameters.

Determining address of the function

The function that will be called is at offset 0x8730, we’ll take a look at that shortly, but first lets verify our parameters.

Verification of Parameters Before Dynamic Call in Function 5

The parameters are as we expect. RDX holds our input param3 (Pointer to userspace3) and R8 holds our input param4 (Pointer to userspace4).

Finding the Arbitrary Read/Write

The function at offset 0x8730 is shown below. From now on we’ll refer to it as dynamic_function_5_read_write.

Dynamic Call from Function 5

From the image above we can clearly see where the writes to our user buffer happens, line 12 & 20. On line 12, Param_3 is dereferenced (this is our user supplied param4) and the value of 4 is placed inside it (placing it at userspace 4). On line 20, Param_2 is dereferenced (this is our user supplied param3) and the value of dereferenced param_1 is placed inside it (placing it at userspace 3). If we can manipulate param_1 we will have an arbitrary read/write!

Lets take a look at where param1 takes us. From the registers image above, we know that param_1 in this case is 0xfffff805308c8ab4 . from the image below we can see it points to the value 0x00010000:

Value of Param_1

Lets look at decompiled function 5 (opcode5) again to see what control we have over param_1 in dynamic_function_5_read_write.

Inspecting Param_1 Origin from Function 5

We can see that param_1 inside dynamic_function_5_read_write is actually puVar2 inside opcode5. It appears that we can somewhat control puVar2 by modifying param_1 in opcode_5. lets take a look at the memory of array_of_puVar2_pointers and determine which values can be placed into puVar2 by modifying param_1.

array_of_puVar2_pointers structure in memory

The highlighted block above shows the value of param_1 from dynamic_function_5_read_write when we caught it in the debugger (see images above). The pointer values we could return from array_of_puVar2_pointers into puVar2 didn’t turn out to be that interesting.

At this point I was a little disheartened as I realized I would not be able to fully control the pointer returned from array_of_puVar2_pointers into puVar2 . The pointers I was able to store into puVar2 didn’t appear to be pointing to anything important. I took a small hiatus from the project before returning and giving it another look.

I came back to the project with a new idea. I’m unable to modify the puVar2 pointer sent to dynamic_function_5_read_write, However, perhaps I can modify the value the pointer points to. This would be like modifying the value 0x00010000that 0xfffff805308c8ab4 was pointing to. To see if we can modify this value, lets put a write breakpoint at the 0xfffff805308c8ab4 address and rerun the fuzzer.

Received a write breakpoint

Awesome we received a write breakpoint! Lets take the instruction offset 0x0884band see what the decompiled function looks like in Ghidra. We’ll refer to this function as our dynamic_function_call_store.

Function That Writes To Our Pointer Address

This function is perfect, as it is extremely simple. The next step is to evaluate the parameters passed into the function, we can view the stack for this.

Stack of dynamic_function_call_write

param_1 is the address we have seen passed to dynamic_function_5_read_write and param_2 looks like a userbuffer pointer we supplied, param3! The return address leads back to line 28 in function 5 (opcode5), as shown below.

Return Address of dynamic_call_function_store

We now know that param_1 is familiar to us, because it’s puVar2 and by viewing the values from the previous stack frame, we can determine what the values of the each parameter were that the fuzzer had input to get this result.

  • param1: 0x16
  • param2: 0x0
  • param3: 0x15f31c32c98
  • param4: 0x15f31c32ca0

This means that we should be able to specify an arbitrary address with param3 and place it into the value that puVar2 is pointing at by using dynamic_function_call_store then send another IOCTL request and read or write the value we just stored into puVar2 by calling dynamic_function_call_read_write and providing the address we want to write to in param3. We have just found an arbitrary read and arbitrary write! Lets review the IOCTL requests so we’re on the same page.

The first IOCTL request will call function 5 (opcode5) with the following parameters

  • param1: 0x16
  • param2: 0x0
  • param3: <target address to read from>
  • param4: <anything>

This will call dynamic_function_call_store with a specified puVar2 that we can read from later and param3 the target address we want to read from. This will place the value of a DWORD stored the target address (param3) into puVar2.

The second IOCTL request will call function 5 (opcode5) with the following parameters

  • param1: 0x16
  • param2: 0x2
  • param3: <target address to write to>
  • param4: <anything greater than 4>

This will call dynamic_function_call_read_write with the first parameter being the puVar2 address pointing to the value we chose with the previous IOCTL request callingdynamic_function_call_store. The second parameter being param3 in our IOCTL request, will point to the address that we want the value stored at puVar2 to be written to. The third parameter passed to dynamic_function_call_read_write is param4 of our IOCTL request. This parameter needs to be greater than 4 to satisfy a conditional statement. Once completed, the address passed in param3:<target address to write to> from our second IOCTL request will contain the DWORD that the second IOCTL’s request param3: <target address to read from> pointed to.

Now that we have an arbitrary read/write we can begin to work on the exploit.

Exploitation

This exploit will overwrite the token permission in our current process. It is possible to do this exploit only with vulnerability #1 as the token address for the current process can be found with usermode calls, therefore it does not require an arbitrary read. However, this code strictly uses Vulnerability #2 and obtains the current process token by manually enumerating kernel structures, to show off what it’s capable of doing.
The Code can be found on my Github. The steps are explained below:

  • Get the base address of the windows kernel driver ntoskrnl, by using EnumDeviceDrivers to get the base address of each driver in the kernel & GetDeviceDriverBaseNameA to find which address refers tontoskrnl.exe.
  • Next use LoadLibraryA to loadntoskrnl.exe into the current process (Yes we’re using LoadLibrary on a non .dll, but .dll and .exe files have the same PE file format so enumerating the exports will be the same routine), then use GetProcAddress to find the address of PsInitialSystemProcess. The following will give you the address of PsInitialSystemProcess in userspace. Substract the userspace address ofntoskrnl.exe from the PsInitialSystemProcess address to get the offset of PsInitialSystemProcess. Armed with the offset of PsInitialSystemProcess and the base address in the kernel of ntoskrnl we can now determine the address of PsInitialSystemProcess in the kernel by using the formula <Address of ntoskrnl> + <Offset of PsInitialSystemProcess> = <Kernel Address of PsInitialSystemProcess>
  • PsInitialSystemProcess is a pointer to the EPROCESS Structure, this structure holds information for each process. In this step we’ll supply the <Kernel Address of PsInitialSystemProcess> into Vulnerability#2 (arbitrary read), the end result is we’ll leak the address of the EPROCESS structure.
  • The next step all about enumerating the EPROCESS. EPROCESS Structure changes on different versions of windows, but below is the important parts of the structure based on Windows 10 build1909.
struct _EPROCESS
{
    struct _KPROCESS Pcb;                                                     //0x
    struct _EX_PUSH_LOCK ProcessLock;                                 //0x2e0
    VOID* UniqueProcessId;                                                   //0x2e8
    struct _LIST_ENTRY ActiveProcessLinks;                            //0x2f0
...
    struct _EX_FAST_REF Token;                                             //0x360
...
}

====================
Sub Structures are listed below
====================

struct _LIST_ENTRY
{
    struct _LIST_ENTRY* Flink;                                              //0x0
    struct _LIST_ENTRY* Blink;                                              //0x8
}; 

struct _EX_FAST_REF
{
    union
    {
        VOID* Object;                                                            //0x0
        ULONGLONG RefCnt:4;                                                //0x0
        ULONGLONG Value;                                                    //0x0
    };
}; 

We’ll use our arbitrary read vulnerability to read the UniqueProcessId (0x2e8) and determine if it matches the process PID of our exploit process. If it doesn’t we’ll read Flink (0x2f0)pointer stored in ActiveProcessLinks(0x2f0) which will bring us to the next EPROCESS structures ActiveProcessLinks(0x2f0). We can subtract 0x2f0 to get the Next EPROCESS Structure base. We’ll repeat this until we find a UniqueProcessId (0x2e8) match.

  • Once the EPROCESS Structure for our process is found we’ll leak the Token (0x360) structure. The Token Structure is _EX_FAST_REF which is essentially a pointer with the last 4 bits being a reference count. Thus the value we receive from this structure will need to be masked to get the pointer to the TOKEN Structure. The mask will look like <tokenFastRef> & 0xFFFFFFFFFFFFFFF0 = <Address of Our Process Token>.
  • The <Address of Our Process Token> points to the TOKEN Structure shown below.
struct _TOKEN
{
    struct _TOKEN_SOURCE TokenSource;                                       //0x0
    struct _LUID TokenId;                                                   //0x10
    struct _LUID AuthenticationId;                                          //0x18
    struct _LUID ParentTokenId;                                             //0x20
    union _LARGE_INTEGER ExpirationTime;                                    //0x28
    struct _ERESOURCE* TokenLock;                                           //0x30
    struct _LUID ModifiedId;                                                //0x38
    struct _SEP_TOKEN_PRIVILEGES Privileges;                                //0x40
...
}


====================
Sub Structures are listed below
====================
struct _SEP_TOKEN_PRIVILEGES
{
    ULONGLONG Present;                                                      //0x0
    ULONGLONG Enabled;                                                      //0x8
    ULONGLONG EnabledByDefault;                                             //0x10
}; 

Using write portion vulnerability #2 and with the knowledge of the address of our Token Structure we can Write 0xffffffffffffffff into _SEP_TOKEN_PRIVILEGES->Present and _SEP_TOKEN_PRIVILEGES->Enabled. With that write our process will now have full permissions and we have just escalated our privilege.

Final Notes

I appreciate you reading the blog, hope you learned something new. If you have any comments or would like to further discuss the post, feel free to reach out on twitter.