[CVE-2024-43560] Windows Storage Port Driver Privilege Escalation Vulnerability

1. Background

Based on the vulnerability name (Microsoft Windows Storage Port Driver), we can search and identify the corresponding file as storport.sys. This driver is used for communication between the computer and high-performance storage devices, defining how the computer communicates with these devices.

2. Patch Diffing

System Version: Win11 22H2 Pro

The diff shows that 6 functions were modified:

image.png

After comparing the function code before and after the patch, the differences mainly lie in the calls to Feature_ class functions. These functions are used for compatibility with pre-upgrade code by checking whether a certain global variable is enabled to execute the post-upgrade code. Among them, the function RaUnitStorageGetIdlePowerUpReason performs the most data operations in addition to calling Feature_ class functions. Therefore, it is suspected that the vulnerability resides in this function.

image.png

Thus, in the patched code, if a certain global variable is not enabled, the pre-patch code is executed; if enabled, the newly added code is executed. Therefore, we can directly compare the differences between the two, which lie in the boxed code:

image.png

3. Vulnerability Principle and Patch Analysis

Although the location of the patched code has been identified, no obvious vulnerability can be seen from the current code. What is noteworthy is that when comparing the size of stackLocation->Parameters.Read.Length, the value changed from 0x8 to 0xC, and it also checks whether masterIrp is NULL. This part is certainly related to the vulnerability.

I speculated on some reasons, but because there was too little known information, I couldn’t be certain. First, we need to clarify what the parameters and structures are here.

By checking the official documentation about the IRP structure, we find that MasterIrp in irp->AssociatedIrp.MasterIrp is actually a union field, defined as:

   union {
    struct _IRP     *MasterIrp;
    __volatile LONG IrpCount;
    PVOID           SystemBuffer;
  } AssociatedIrp;

Here, the more reasonable choice should be SystemBuffer. Therefore, we need to figure out what request is being processed in the function RaUnitStorageGetIdlePowerUpReason, and what data structure is stored in the corresponding SystemBuffer.

Searching for the keywords Storage PowerUpReason led to this page and this page, where the following data structures were found:

//
// IOCTL_STORAGE_GET_IDLE_POWERUP_REASON
//
// Input Buffer:
//      None.
//
// Output Buffer:
//      A STORAGE_IDLE_POWERUP_REASON structure specifying what caused the power up.
//
typedef enum _STORAGE_POWERUP_REASON_TYPE {
  StoragePowerupUnknown           = 0,
  StoragePowerupIO,
  StoragePowerupDeviceAttention
} STORAGE_POWERUP_REASON_TYPE, *PSTORAGE_POWERUP_REASON_TYPE;

typedef struct _STORAGE_IDLE_POWERUP_REASON {
    ULONG Version;                          // Structure version, should be set to 1 for Win8.
    ULONG Size;                             // Size of this structure in bytes.
    STORAGE_POWERUP_REASON_TYPE PowerupReason;   // The reason for the power up (see above).
} STORAGE_IDLE_POWERUP_REASON, *PSTORAGE_IDLE_POWERUP_REASON;

#define STORAGE_IDLE_POWERUP_REASON_VERSION_V1 1

Although we cannot be absolutely sure of the structure of SystemBuffer at this point, these are the only details obtained so far. So let’s temporarily cast it to PSTORAGE_IDLE_POWERUP_REASON. The code looks very reasonable:

image.png

I am not entirely sure what the stackLocation->Parameters.Read.Length field means here, but based on the context, it is likely checking the size of the buffer allocated for PSTORAGE_IDLE_POWERUP_REASON. I also found a snippet of code constructing such a request:

irp = IoBuildDeviceIoControlRequest(IOCTL_STORAGE_GET_IDLE_POWERUP_REASON,
                                    DeviceExtension->LowerPdo,
                                    PowerupReason,
                                    sizeof (STORAGE_IDLE_POWERUP_REASON),
                                    PowerupReason,
                                    sizeof (STORAGE_IDLE_POWERUP_REASON),
                                    FALSE,
                                    &event,
                                    &ioStatus);

As we can see, both the InputBuffer and OutputBuffer for this request are PowerupReason. Therefore, stackLocation->Parameters.Read.Length is likely checking the size of this buffer.

So, what exactly is the issue with the pre-patch code? First, an obvious problem is that before the fix, the code only checked whether stackLocation->Parameters.Read.Length was less than 8. This is clearly incorrect because the size of this buffer should normally be 12 (0xC bytes). It is unclear why it was written as 8.

But does this actually have any impact? At first, I didn’t see it, perhaps because it isn’t very clear from the pseudocode. Let’s look directly at the assembly:

image.png

After checking if stackLocation->Parameters.Read.Length is less than 8, the pre-patch code checks whether [sys_buf] + 12 is larger than [sys_buf] + sys_buf.size. That is, it checks if the end address of sys_buf calculated from the Size field is beyond the address of sys_buf plus 12 bytes. Only if the size is large enough will it access the PowerupReason field. This seems fine at first glance—isn’t it just checking if the sys_buf size is at least 12 bytes? But then I realized: the size of sys_buf obtained here does not come from the InputBufferSize parameter in IoBuildDeviceIoControlRequest (i.e., the size obtained via sizeof(STORAGE_IDLE_POWERUP_REASON)), but rather from the Size field inside the PowerupReason structure, which is user-controlled!

If an attacker passes a PowerupReason buffer of size 8 when constructing the request via IoBuildDeviceIoControlRequest, but sets the Size field inside that buffer to 12, it will still pass the check. However, when the driver accesses the PowerupReason field after passing the check, an out-of-bounds access occurs, because the PowerupReason field is located at offset 8.

Therefore, this is an out-of-bounds write vulnerability.

Microsoft’s fix is simply changing 8 to 12, while also updating the contents of the OutputBuffer (which also acts as the input PowerupReason) in a more standardized manner rather than directly trusting the contents from InputBuffer (also PowerupReason).

4. Vulnerability Triggering

With the above analysis, triggering the vulnerability is relatively straightforward. Let’s look at the function call chain:

image.png

The call chain is not deep. Examining the upper-level code shows that the vulnerable function is called directly once the request type is identified. Therefore, there are no other conditions to satisfy, and we can directly call DeviceIoControl to trigger the vulnerability.

The example code we found earlier also constructed an IRP request and called the driver, but did not use the DeviceIoControl method because it is OS-level code. While we can refer to this example when writing our own PoC, we cannot copy it directly.

Referring to IoBuildDeviceIoControlRequest, the DeviceIoControl call is as follows:

status = DeviceIoControl(  
    hDevice,  
    IOCTL_STORAGE_GET_IDLE_POWERUP_REASON,  
    PowerupReason,  
    sizeof (STORAGE_IDLE_POWERUP_REASON),  
    PowerupReason,  
    sizeof (STORAGE_IDLE_POWERUP_REASON),  
    &bytesReturned,  
    NULL  
);

We only need to obtain hDevice and then construct a PowerupReason that can trigger the vulnerability.

In the example code, there is a parameter deviceExtension of type PCDROM_DEVICE_EXTENSION, which suggests that hDevice should be a handle to a CDROM device.

Searching for “cdrom” in WinObj64.exe reveals the following:

image.png

Although it is not clear what exactly these are, \Device\0000006e seems more reasonable.

Therefore, we only need to call NtCreateFile to obtain the device handle, construct the malformed PowerupReason to trigger the vulnerability, and then call DeviceIoControl.

However, the issue with this vulnerability is that although we write beyond the bounds of the PowerupReason buffer by definition, the memory actually allocated by the system is larger than 8 bytes. Thus, in most cases, the PoC will not cause a system crash. Success in triggering the vulnerability can only be verified via debugging. Moreover, the out-of-bounds written content is not controlled, so the direct impact of this vulnerability is minimal, though it might be chained with other vulnerabilities for exploitation.

// Check the stored buffer length, which is indeed 8
1: kd> dd ffffe28ff4a43f20 L1
ffffe28f`f4a43f20  00000008
// Buffer content
1: kd> dd rdx l4
ffffe28f`f4c11f80  00000001 0000000c 00000000 00000000
// Information of the pool containing rdx, size is 96 bytes
1: kd> !pool rdx
Pool page ffffe28ff4c11f80 region is Nonpaged pool
 ffffe28ff4c11050 size:   60 previous size:    0  (Allocated)  MmSe
 ......
 ffffe28ff4c11ef0 size:   60 previous size:    0  (Allocated)  MmSe
*ffffe28ff4c11f50 size:   60 previous size:    0  (Allocated) *IoSB Process: ffffe28ff4a340c0
        Owning component : Unknown (update pooltag.txt)

We can see that the buffer address is rdx (ffffe28ff4c11f80), and its pool page region starts from ffffe28ff4c11f50 with a size of 96 bytes (0x60). That is, although the buffer size is nominally 8 bytes, it can actually hold up to 48 bytes without triggering a crash.

5. Reflections

Actually, after locating the vulnerable function, I lacked confidence because I couldn’t immediately figure out the vulnerability principle. So, I took a look at the materials provided by Microsoft. Without these resources, I would still suspect the vulnerability is here, but might have missed the correct path due to a lack of confidence. Belief that a vulnerability exists -> failure to find the vulnerability due to unclear data structures. How can we establish a clearer connection between the two? While believing a vulnerability exists is just psychological self-justification, identifying that a data structure is incorrect requires the accumulation of experience and knowledge.

Imagine how I would handle this vulnerability without external materials:

  1. After locating the vulnerable function, I need to analyze the differences before and after the patch with more confidence or certainty. This way, I could locate the red and yellow boxes in the diagram and notice the difference between 8 and 12. Such numeric differences would further reinforce the conclusion that “this is the vulnerable function.”
  2. Despite the numeric difference, it would be hard for me to realize that a buffer is involved here (specifically, that MasterIrp is actually another field in the union structure). I would highly likely have stopped here. This is more about cultivating awareness, so we can only assume that I developed this awareness through further study.
  3. After guessing that a buffer is involved, I would have more confidence and motivation to search for information. Visiting Microsoft’s official documentation is a natural step, and I would likely find relevant content about PSTORAGE_IDLE_POWERUP_REASON and successfully determine the vulnerability mechanism.
  4. In the exploitation phase, the main obstacle is obtaining the device handle. In the write-up, the reason I looked for the CDROM handle seems like working backward from a known result—since I knew I had to find CDROM, I focused on the path related to CDROM in the source code. The uncertainty stems from the fact that I am only familiar with passing standard file path parameters to the CreateFile function, and unfamiliar with deeper system programming. Furthermore, when I first started writing the PoC code, I used the CreateFile function, which failed; the NtCreateFile function must be used instead. Actually, if I limit my learning strictly to writing PoC code, the scope is quite narrow. Since my previous analysis focused more on the vulnerability principle and I was unfamiliar with the exploit code, this area of capability should be improved quickly.