9 min read

PhysMem(e): When Kernel Drivers Peek into Memory CVE-2024-41498

PhysMem(e): When Kernel Drivers Peek into Memory CVE-2024-41498
Courtesy of GPT-4o

Introduction

In this post, we explore a vulnerability in the Windows IOMap64.sys driver (CVE-2024-41498) RevEng.AI researchers discovered with the help of our AI Binary Analysis Platform. We perform a technical analysis of the IOMap64.sys driver, cover the software fault leading to the vulnerability which under the hood allow a malicious user to read / write the entire physical memory (RAM), and finally provide a PoC to demonstrate exploitability.

Alongside the core analysis, RevEng.AI has provided a YARA rule and Indicators of Compromise (IOCs) at the end of the post to aid detection.

Background

It is known that threat-actors modus operandi has evolved over time, in response to the increase in the complexity of defensive technologies. One of those challenges is called "vulnerable drivers"; drivers are pieces of software that run deep in the kernel (ring-0). They usually provide an interface between the OS and the hardware itself. They are of particular interest to bad actors due to their use in Bring Your Own Vulnerable Driver (BYOVD) exploit chains which provide a mechanism to gain kernel privileges.

As we are talking about low-level code, programmers must be extremely careful; even the slightest error could lead to a Blue Screen Of Death (BSOD) in the best case, and a complete system compromise in the worse. For more information on the impact errors in Windows kernel drivers can have, please contact CrowdStrike.

Obviously, not all drivers can be trivially loaded by Windows. In fact, since the 64-bit version of Windows 10 Driver Signature Enforcement (DSE) feature was enabled by default, only drivers from official vendors signed with a valid certificate can be loaded; this means that it's "no longer possible" to compile a malicious driver, without a valid signature, and load it into the kernel. That being said, Threat Actors started to use known signed drivers with known vulnerabilities (such as the infamous CAPCOM driver).

To prevent such abuse, Windows has started to maintain a list of vulnerable drivers which it prevents from loading (if enabled); this setting on Windows devices can be found under the Core Isolation settings.

Technical Analysis of IOMap64.sys

In May 2024, RevEng.AI analysed a series of third-party Microsoft Windows kernel drivers. A notable candidate for analysis was ASUS's IOMap64.sys (SHA-256: d78d7516dbb8cad08f355a070790d6dd629dcf58d816855b958669fecb8b68b5). The driver is signed by ASUS, and holds a valid signature from ASUSTeK COMPUTER INC. (serial: 04 14 DC F7 AC 18 BE 7B 0E 5D 1D B9 A3 FE E4 69). The build time of the driver is likely 2023-11-24 10:25:39 UTC - making it fairly recent.

There are many different types of drivers that can handle a wide array of low-level operations. The image in Figure 1, provided by Microsoft, splits them into three different macro categories.

Figure 1 - Windows Driver Categories

In our case, IOMap64.sys is a driver used by ASUS software to manage device hardware; this is quite interesting as the driver must be able to interface with the hardware directly. Thus, some interesting primitives could exist (from a threat actor perspective).

Once loaded in Hex-Rays IDA Pro, we are welcomed with the DriverEntry function, the standard entry-point of Windows kernel drivers. In our case, all of the relevant logic stuff happens inside RealDriverEntry (some code was removed for brevity).

__int64 __fastcall Real_Driver_Entry(PDRIVER_OBJECT DriverObject, PCHAR* pcArg)
{
  ...
  ...
  wcscpy(DestinationString, L"\\Device\\IOMap");
  ...
  ...
  BaseAddress2 = 0;
  BaseAddress = 0;
  g::Unk_0 = 0;
  result = WrapIoCreateDeviceSecure(
             DriverObject,
             0x98u,
             &DestinationString,
             FILE_DEVICE_UNKNOWN,
             0,
             0,
             &DefaultSDDLString,
             0,
             &DeviceObject);
  if ( result < 0 )
  {
    _mm_lfence();
  }
  else
  {
    DeviceExtension = (struct_DeviceExtension *)DeviceObject->DeviceExtension;
    DeviceExtension->DeviceObject = DeviceObject;
    RtlInitUnicodeString(&SymbolicLinkName, (PCWSTR)wSymLinkName);
    
    nsLink = IoCreateSymbolicLink(&SymbolicLinkName, &DestinationString);
    if ( nsLink >= 0 )
    {
      DriverObject->MajorFunction[0] = (PDRIVER_DISPATCH)Possible_DispatchDeviceControl_0;
      DriverObject->MajorFunction[2] = (PDRIVER_DISPATCH)Possible_DispatchDeviceControl_0;
      DriverObject->MajorFunction[3] = (PDRIVER_DISPATCH)Possible_DispatchDeviceControl_0;
      DriverObject->MajorFunction[4] = (PDRIVER_DISPATCH)Possible_DispatchDeviceControl_0;
      DriverObject->MajorFunction[14] = (PDRIVER_DISPATCH)Possible_DispatchDeviceControl_0;
      DriverObject->MajorFunction[22] = (PDRIVER_DISPATCH)&dispatch_func;
      DriverObject->DriverUnload = (PDRIVER_UNLOAD)&unload_func;
      DeviceObject->Flags |= 0x2004u;
      DeviceObject->Flags &= ~0x80u;
      
      KeInitializeMutex(&DeviceExtension->mutex, 1);
    }
    else if ( DeviceObject )
    {
      IoDeleteDevice(DeviceObject);
    }
    
    return nsLink;
  }
  
  return result;
}

As we can see from the code above, there is a large amount of logic occurring. The first thing we notice is the string \Device\IOMap. This is the DOS device path associated with the driver. To trigger I/O request packets (IRPs) and communicate with the driver, we must first open a HANDLE to that device.

Another noteworthy characteristic of the driver is the device extension size 0x98. The device extension is one of the most important data structures associated with a device object. Its internal structure is driver-defined, and it is typically used to:

  • Maintain device state information;
  • Provide storage for any kernel-defined objects or other system resources, such as spin locks, used by the driver; and,
  • Hold any data the driver must have resident and in system space to carry out its I/O operations

This information is helpful from a reverse-engineering perspective as in some cases this structure holds buffers associated with the various IRPs, thus making it easier to understand and recover the various internal structures.

Another thing we notice is that the driver is setting the various callbacks for MajorFunction; this is effectively an array wherein each index represents a different operation on the device file (Open, Close, DeviceIoControl, and so on). For more information on this topic, refer to Microsoft's official documentation.

We are interested in IRP_MJ_DEVICE_CONTROL events. These events define how the driver should behave upon a DeviceIoControl IRP.

After loading the target function associated with a IRP_MJ_DEVICE_CONTROL event, renamed Possible_DispatchDeviceControl_0, a lot of logic can be observed. This code first checks the parameters associated with the IRP (buffer, input and output buffer length, and the MajorFunction). It was observed from further analysis this subroutine is used by all the I/O operations, which is why many checks are performed.

Function for IRPs handling
Figure 2 - Function for IRPs Dispatch

DeviceIoControl requests are typically identified by a number, which are defined by the developer. Think about an Remote Procedure Call (RPC); but instead of a function name, you must pass the correct IOCTL (number) to call the associated function. In our case, the large switch in Figure 2 handles the dispatch to the correct function handler.

Upon analysis of the dispatch routines, RevEng.AI observed a series of interesting functions. For readability, two of the functions are renamed ProcessIrpAndMapMemory and ProcessIrpAndMapMemory2 .

Both of these functions use the memory-management (Mm) API MmMapIoSpace to map a physical memory address to a virtual address. Although we won't cover how such mappings work under the hood in the scope of this blog-post, Microsoft provide a thorough explanation here.

The two functions mentioned above work almost the same way. First, they check if the device extension has initialized two global pointers and, if so, unmap the memory pointed to by those pointers. Then, given the user buffer as input, they perform a few manipulations and map an address inside our buffer.

Vulnerable routine for arbitrary address mapping
Figure 3 - Vulnerable routine for arbitrary address mapping

Now, we don't care much about HalTranslateBusAddress as this is typically used to translate a bus address, but the case of the translation failing, it jumps into MmMapIoSpace. Once the translation succeeds, the resulting VA is stored inside the a1 parameter - the global device extension pointer - and copied into globalBaseAddress (Figure 4).

Further analysis of the code revealed further IOCTLs that, without checking what address was actually mapped inside translatedPhysicalAddress (Figure 4) allows to read a BYTE/WORD/DWORD directly dereferencing a user-provided offset.

Vulnerable routine for arbitrary read
Figure 4 - Vulnerable routine for arbitrary read

The two functions together create a powerful primitive that allows reading the entire physical memory except from the lowest address (0x0 0x1000). Even PPL processes can be accessed this way without having to deal with PPL intricacies.

An important note is that the two functions using MmMapIoSpace have a fixed mapping size, one of 0x40000 and the other 0x100000. The two pointers are both copied to the device extension.

Functions other than ReadByteAtVirtualAddr (Figure 4) exist that write to these virtual addresses given an arbitrary offset, thus transforming the read primitive into a read/write primitive, which could be used to further elevate privileges, but for some reason, the MmMapIoSpace call keeps failing during our test when the size parameter is set to 0x100000.

Dynamic Analysis - A Walkthrough

To debug the kernel driver we first create a guest Windows 11 VM to load the driver, and connect IDA Pro from our host to the guest debugger using the WinDbg connector.

After setting up our environment, the kernel eventually initialises and we hit a break-point during kernel startup. Next, we hit run to let Windows boot and suspend when the system is fully loaded. If you did everything correctly on the modules list, you should be able to see our driver mapped in memory:

IDA Pro kernel debugging
Figure 5 - IDA Pro kernel debugging

Now, we upload the driver for analysis to RevEng.AI and use the RevEng.AI IDA Pro plugin to find common symbols between the IOMap64.sys driver and the memory map of the loaded driver. This will generate a list of symbols for our target driver, which will allow use to match the same functions when loaded in memory.

Breakpoint into the vulnerable function
Figure 6 - Breakpoint into the vulnerable function

And we are ready to go! Now, you can experiment with your driver and possibly create a PoC.

PoC or It Never Happened

We started developing a simple PoC to open the device and call the vulnerable IOCTL to map physical memory, but it wasn't working as expected; in fact, we had a BSOD almost immediately. Upon further inspecting the parameters passed to MmIoMapSpace, everything seemed correct, so we checked the call trace. We found out that the process was crashing inside MiShowBadMapper due to a call to KeBugCheckEx.

After countless hours trying to understand what was happening, we noticed that the bug was happening only when we were debugging the kernel! So we opted to bypass the check inside MiShowBadMapper by patching the assembly itself

13,18c13,15
-   if ( ((v3 & 1) == 0 || KdPitchDebugger || (_BYTE)KdDebuggerNotPresent) && dword_140D1C22C == 0 )
-   {
      RtlCaptureStackBackTrace(1u, 0x10u, BackTrace, BackTraceHash);
      MiAllocatePool(64LL, 128LL, 538996045LL);
      JUMPOUT(0x14062ED71LL);
-   }

As we can see from the diff above, we remove the entire if statement which checks KdPitchDebugger and KdDebuggerNotPresent. This will prevent the kernel from calling the KeBugCheckEx function, thus making the MmMapIoSpace call work.

Proof of Concept reading physical memory
Figure 7 - Proof of Concept reading physical memory

You can find the code for the PoC attached below.

Conclusion

We hope you enjoyed this reading as much as we enjoyed writing it. It is left to the reader to investigate why the mapping with a size of 0x100000 was failing and possibly write a PoC to steal the token from an elevated process and copy it to your own one.

Remember that when it comes to complex drivers, you may waste days trying to reverse it and months trying to exploit it; patience is the key to success! To speed up the process, you can use our AI Binary Analysis Platform to automate the identification and understanding of binary code when performing any kind of vulnerability research or binary analysis.

Indicators of Compromise

Indicator Type Description
\Device\IOMap Path Device used for IOMap64 communication, an open HANDLE to this from a user-mode process suggests usage
d78d7516dbb8cad08f355a070790d6dd629dcf58d816855b958669fecb8b68b5 SHA-256 Impacted IOMap64.sys driver

YARA Rule

import "pe"

rule driver_IOMap64
{
    meta:
        id = "fa8afcc8-081c-425a-a957-9babb03cbedc"
        author = "contact@reveng.ai"
        version = "040924-01"
        reference = "https://blog.reveng.ai/physmem-e-when-kernel-drivers-peek-into-memory/"
        description = "Detects the vulnerable IOMap64 ASUS-signed Windows driver"

    strings:
        $ = "IOMap-V3.0_20231124_vs2019"

        $ = {04 21 00 83}
        $ = {08 21 00 83}

    condition:
        uint16(0) == 0x5A4D and
        uint32(uint32(0x3C)) == 0x00004550 and
        pe.machine == pe.MACHINE_AMD64 and
        pe.subsystem == pe.SUBSYSTEM_NATIVE and 
        pe.is_signed and
        (
            for any i in (0..pe.number_of_signatures - 1):
            (
                pe.signatures[i].serial == "04:14:dc:f7:ac:18:be:7b:0e:5d:1d:b9:a3:fe:e4:69" and
                pe.signatures[i].subject contains "ASUSTeK COMPUTER INC."
            )
        )
        and all of them
}