Due to the vulnerability’s constraints, the target heap buffers were fixed at 0x4000 bytes, placing them inevitably in the Windows 11 Low Fragmentation Heap (LFH). This allocator implements security mitigations that are notorious for being difficult to bypass. Typically, attackers would target a different size class to shift the allocation to a less hardened allocator, avoiding the LFH entirely. However, the nature of the PVSCSI vulnerability offered no such flexibility. This section details the main challenges we faced.
Note that we did not fully reverse-engineer (nor understand) the LFH internals. To keep this article concise, we intentionally omit implementation details and only provide a simplified model sufficient to understand our exploitation method. Readers interested in a comprehensive analysis should refer to existing litterature [1]. Finally, for the sake of simplicity, we assume throughout this section that all allocations are of 0x4000 bytes.
The LFH groups the allocations into buckets. Each bucket can hold 16 elements of 0x4000 bytes. A chunk is preceeded with 16 bytes of metadata, so a bucket’s size is 0x4010*16 bytes in total.
Exploiting the LFH with the PVSCSI bug was extremely challenging due to the combination of two mitigations: strict checking of chunk’s metadata and shuffling of allocations.
The metadata contains a checksum that is computed using a secret key. When a chunk is allocated or freed from a bucket, this checksum is verified first, and if it has been corrupted the program will abort. So whenever we corrupt a chunk’s metadata, we must ensure that this chunk will never be allocated or freed again. To achieve this in our exploit, we were dreaming of simple, deterministic heap shaping strategies. But the shuffling of allocations order made the whole process a living nightmare.
When we allocate a chunk, the LFH returns one random chunk among the ones that are not allocated. Note that it first looks into the bucket containing the most recently freed chunk. If no chunk is available, the LFH creates a new bucket and returns a random chunk from it. If several chunks are available, the LFH returns a random free chunk.
Triggering the realloc() bug once roughly behaves as follows on the 1025th iteration:
If we send more than 1024 S/G entries to the vulnerable function, the 1025th entry corrupts a chunk header. The function then continues to free and allocate 0x4000-byte buffers at each loop iteration. Due to the LFH’s randomized allocation order, the allocator will inevitably end up reusing the corrupted chunk. As soon as this happens, the process detects the corruption and aborts. We tried various techniques to work around this blindly, but without prior knowledge of the heap state, none succeeded.