[Wipeload Step 7.] Lock the Pages, Break the Kernel (EN)
Hello. This is banda from Hackyboiz.

Previous articles
https://hackyboiz.github.io/2026/06/06/OUYA77/Wipeload_step1/en/
https://hackyboiz.github.io/2026/06/21/ji9umi/Wipeload_step2/en/
https://hackyboiz.github.io/2026/06/26/ji9umi/Wipeload_step3/en/
https://hackyboiz.github.io/2026/07/13/OUYA77/Wipeload_step4/en/
https://hackyboiz.github.io/2026/07/19/gongjae/Wipeload_step5/EN/
https://hackyboiz.github.io/2026/08/01/gongjae/Wipeload_step6/EN/
Previously, thanks to the great work of Master Sifu, it was possible to use the WASM trick to escape the V8 Sandbox and execute native code, and then escape to the Chrome Sandbox through ALPC UAF and execute code with Medium Integrity permissions. The final boundary from a chaining perspective would be the gap between UserMode and KernelMode that remains even after being moved to the Medium IL execution context.
In steps 7 and 8, we will complete Kernel EoP, the final gateway to chaining. In this 7th step, we will look at the MDL data structure and operation, which are the core of CVE-2023-29360, and in the next 8th step, we will use this to complete the actual Kernel EoP, so please look forward to it! Will his disciple Po be able to follow in his master’s footsteps and reach SYSTEM authority? Let’s find out together.
1. Memory Descriptor List (MDL) Overview

MDL is a kernel structure that contains information about which virtual address (VA) buffer in the kernel is actually composed of which physical pages.
In general, it is difficult for kernel mode drivers to directly access user mode memory. This is because user mode buffer addresses cannot be safely stored and used with simple pointer values. When the kernel driver accesses the user mode buffer, the following problems occur.
- User-mode virtual addresses are interpreted differently for each process. If the driver stores only the user buffer pointer value of the current process and later accesses it from the context of another process, it may interpret the wrong process address space or refer to the wrong memory.
- User pages are not always fixed in RAM and can be moved to disk by the OS. If the driver stores only the user buffer pointer and then accesses it, a page fault may occur because the page is not in memory.
Therefore, there are two main ways the driver handles the user buffer. One is the method of copying the user mode buffer contents to the kernel buffer and using it, and the other is the MDL method, which we will look at now.
1.1 General case (when not using MDL)

In general cases, that is, when MDL is not used directly, the user buffer is copied to the kernel buffer for processing. In IOCTL, I/O Manager usually uses METHOD_BUFFERED to create a kernel buffer called SystemBuffer and copy data.
ProbeForRead(userBuf, size, 1);
RtlCopyMemory(kernelBuf, userBuf, size);
Alternatively, when a user pointer is passed directly as with METHOD_NEITHER, the driver can validate the user address with ProbeForRead and RtlCopyMemory before copying it to a kernel buffer. Rather than continuously referencing the user buffer directly, these approaches bring the required data into kernel memory before processing it.
1.2 When to use MDL

In the case of MDL, unlike the previous case, rather than copying the contents of the user buffer to the kernel buffer, copying is avoided by allowing the driver to directly use the RAM physical page to which the user buffer is actually mapped.
User VA: 0x000001A2F0001000
-> Physical pages: PFN 1234, PFN 1235, PFN 1236
Looking at the code above, when a buffer is created with malloc, VirtualAlloc, etc. through a user program, it is internally connected to several physical memory pages. MDL is a structure that records information that this user buffer consists of PFN 1234, PFN 1235, and PFN 1236 pages, as well as the starting address and size of the buffer.
In other words, MDL is not a structure that copies and stores the contents of the user buffer, but corresponds to metadata to describe the physical pages that make up the buffer. Afterwards, the driver can lock the page based on this MDL and, if necessary, remap it to the kernel address space.
1.3 MDL creation flow
- User mode buffer allocation
BYTE *buf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_READWRITE);
Looking at the MDL creation flow, let’s first assume that the user mode program allocates a buffer to be delivered to the driver through VirtualAlloc in the virtual address space as shown above. buf will contain user mode VA, right?
- Create MDL: IoAllocateMdl
PMDL IoAllocateMdl(
[in, optional] __drv_aliasesMem PVOID VirtualAddress,
[in] ULONG Length,
[in] BOOLEAN SecondaryBuffer,
[in] BOOLEAN ChargeQuota,
[in, out, optional] PIRP Irp
);
PMDL mdl = IoAllocateMdl(userBuffer, bufferSize, FALSE, FALSE, NULL);
Afterwards, IoAllocateMdl() is performed to create an MDL kernel structure containing metadata and PFN array space for a specific virtual address range. In particular, immediately after executing IoAllocateMdl(), only the PFN array space has been created and the PFN array has not yet been filled.
typedef struct _MDL {
struct _MDL *Next;
CSHORT Size;
CSHORT MdlFlags;
struct _EPROCESS *Process;
PVOID MappedSystemVa;
PVOID StartVa;
ULONG ByteCount;
ULONG ByteOffset;
} MDL, *PMDL;
The MDL structure created with IoAllocateMdl() contains key fields to describe the buffer range. StartVa is the starting virtual address of the first page containing the buffer, ByteOffset is the in-page offset of the buffer start position, and ByteCount is the buffer length. MdlFlags is a flag field that indicates the current state of the MDL, such as whether the page is locked or whether the system address is mapped.
- Physical Page Locking: MmProbeAndLockPages
void MmProbeAndLockPages(
[in, out] PMDL MemoryDescriptorList,
[in] KPROCESSOR_MODE AccessMode,
[in] LOCK_OPERATION Operation
);
MmProbeAndLockPages(mdl, UserMode, IoReadAccess);
After creating an MDL structure through IoAllocateMdl(), the process of fixing physical pages through MmProbeAndLockPages generally follows. Because the user-mode buffer can be paged out at any time, the process of fixing the page in physical memory is necessary before the kernel accesses it. Through the above locking process, all pages pointed to by the MDL reside in RAM, the PFN array is filled inside the MDL, and the driver can then safely access the memory based on this MDL.
__try {
MmProbeAndLockPages(
mdl,
UserMode,
IoReadAccess
);
}
__except (EXCEPTION_EXECUTE_HANDLER) {
status = GetExceptionCode();
}
However, you can’t just trust the address provided by the user and lock the page, right? In a typical driver, an exception may occur while processing a buffer with an incorrect address or incorrect permissions, so the call to MmProbeAndLockPage() as in the code above must be performed within the scope of exception handling.
MmProbeAndLockPages() checks whether the passed virtual address is actually accessible and whether it has read/write permissions appropriate for Operation. If verification is successful, the physical page in the corresponding address range is locked and the PFN array of the MDL is filled with the actual page number.
In other words, this function not only fixes the page in RAM, but is also an important verification point to check whether the address passed by the user is appropriate for MDL processing.
- Kernel VA Remapping: MmMapLockedPagesSpecifyCache
PVOID MmMapLockedPagesSpecifyCache(
PMDL MemoryDescriptorList,
KPROCESSOR_MODE AccessMode,
MEMORY_CACHING_TYPE CacheType,
PVOID RequestedAddress,
ULONG BugCheckOnFailure,
ULONG Priority
);
If you perform MmProbeAndLockPages() earlier, what physical pages the user buffer actually consists of are recorded in the MDL, and the pages are locked to prevent them from being removed from RAM. Afterwards, locked physical pages recorded in MDL are mapped to new virtual addresses through MmMapLockedPagesSpecifyCache. Here, based on the case where AccessMode is KernelMode, we will look at the flow of remapping the Physical Pages indicated by the MDL’s PFN array to the Kernel VA in the kernel address space.
The important thing is that during this process, the entire user buffer is not copied to a separate kernel buffer in advance. The driver obtains a Kernel VA that points to the same Physical Page used by the existing user buffer, and can refer to the contents of the original buffer through this.
For example, if the driver needs to move the contents to another buffer or device I/O area, the mapped Kernel VA can be used as the source of RtlCopyMemory() as shown below.
RtlCopyMemory(dst, kernelVa, length);
As shown above, the driver can use the mapped KernelVa as a source to copy the data required for device I/O processing to another buffer.
- MDL Unmap: MmUnmapLockedPages
MmUnmapLockedPages(kernelVa, mdl);
MmUnlockPages(mdl);
IoFreeMdl(mdl);
After creating and using the MDL through the above process, the lock must be unlocked and freed, and it must be released using the above process in reverse order. First, remove the kernel virtual address mapping created with MmMapLockedPagesSpecifyCache() via MmUnmapLockedPages(kernelVA, mdl). Afterwards, unlock the physical pages fixed with MmProbeAndLockPages() through MmUnlockPages(mdl). Finally, through IoFreeMdl(mdl), the MDL structure allocated with IoAllocateMdl() is freed, and the metadata and PFN array space contained in the MDL are returned.
In other words, the MDL organizing process proceeds in the following order: Unmapping → Page Unlock → MDL Unlock. If the MDL is released first while the mapping remains, the Kernel VA can continue to refer to the PFN information of the already returned MDL, and if the Page Lock remains, the corresponding Physical Page is unnecessarily locked in RAM.
2. MDL ABUSE CASE STUDY

As seen earlier, the driver can lock the physical pages that make up the user buffer through MDL and map those pages to new virtual addresses as needed! In particular, when Locked MDL is mapped to user mode, a new User VA is created in the user process address space that points to the same physical page as the existing buffer.
So far, you have looked at the lifetime of MDL being created, mapped, and then released. After reading the article, do you not see any points where vulnerabilities may occur if something is mismanaged in this process? you’re right. MDL is created based on the buffer passed by the user, locks the physical page, and maps it to a different virtual address space. Therefore, if you mishandle which frame the generated MDL is connected to, when the mapped address and MDL are released, and whether an unlocked MDL can enter the mapping path, it can lead to strong memory corruption primitives.
His teammates have arrived for his final training before his heated bout with Tai Lung. Let’s check what type of vulnerability it can lead to when MDL management is incorrect!
Now, let’s examine the Case 1 Lifetime Bug MDL vulnerability I previously discovered, along with past MDL-related CVEs, to see the different forms MDL Abuse can take! Case 2’s CVE-2024-38237 and CVE-2025-21375 are MDL Mismatch cases where the connection between the Frame Header and the MDL is mismatched. Case 3’s CVE-2024-38238 is a Forgotten Lock case in which MDL is passed to the mapping path without being locked. For Case 2 and Case 3, we referred to the DEVCORE case to classify MDL vulnerability types.
2.1 CASE: MDL Lifetime Bug Pattern
First, the first type of vulnerability is a Double Free vulnerability that occurs when the Lifetime of an MDL object is processed without synchronization. Broadly speaking, it is a race condition type of vulnerability that can appear in the process of MDL being released and reused.

If you check the MDL Register/Unregister Flow of the vulnerable driver, the Register request stores the VA mapped to the user and the MDL pointer describing the VA in the internal slot table. Afterwards, the Unregister request operated as a flow to find a slot based on the VA returned by the user and free the corresponding MDL.
Therefore, in the normal flow, unregister had to be performed only once for one mapped VA. In other words, when a specific slot is selected for unregister, that slot must be marked as no longer valid, and even if an unregister request is made again to the same mapped VA, the same MDL point must not be freed again.
In particular, if there is no synchronization between the time of invalidating this slot and the process of freeing the MDL, two requests may simultaneously confirm that the same slot is still valid. As a result, it may lead to a double free where the same MDL is each freed.
slot.flag = 1; // valid registration entry
slot.mapped_va = 0x12340000; // mapped VA returned to user mode
slot.mdl = 0xffff...; // MDL pointer describing this mapping
For example, each slot stores validity as shown above, the Mapped VA returned to the user, and an MDL Pointer that describes the mapping. At this time, slot.flag == 1 means that the slot is a valid registration entry, and slot.flag == 0 indicates a slot that has already been released or can no longer be used.
If a specific slot is selected as an unregister target, the driver must first clear the slot’s flag to ‘0’ so that the slot can no longer be used, and release the mapping using the mapped VA and MDL pointer stored in the same slot. Even if an Unregister request is made again to the same VA later, a slot that has already been invalidated must not be selected again.

The problem is that if you check the code of the vulnerable function, you can see that the slot search, slot flag clear, and MDL unmap/free processes are not protected by a single lock. In other words, if another thread sends an Unregister request to the same VA between checking whether slot.flag == 1 and changing it to slot.flag = 0, both threads may decide that the same Slot is still valid.😱 Even if one thread invalidates the Slot and releases the MDL, the other thread may perform the Unmap and Free process again using the same MDL pointer that has already been secured.
This can result in a double free, where one MDL is freed twice.
Thread A:
- Search for slot matching mapped VA
- Change slot.flag = 0
MmUnmapLockedPages()IoFreeMdl()
Thread B:
- Unregister request with same mapped VA
- Check
slot.flag == 0- slot search failed
- Does not enter MDL free path
First, let’s take a look at the normally synchronized Unregister flow. Since the slot is invalidated after Thread A selects it, even if Thread B searches for the same slot later, it cannot use the slot by checking flag == 0. In other words, Unregister and Free are performed only once for one MDL.
Thread A:
- Check
slot.flag == 1- Check for
mapped_vamatch- Select slot
Thread B:
- Thread A enters before clearing the flag
- Check
slot.flag == 1- Check for
mapped_vamatch- Select the same slot
However, in vulnerable functions, the process from slot search to flag change to MDL release is not protected by a single lock. If two Threads send Unregister requests to the same Mapped VA at almost the same time, both Threads may select the same Slot with slot.flag == 1.
Afterwards, the two threads call MmUnmapLockedPages() and IoFreeMdl() respectively using the same MDL Pointer, and as a result, a double free occurs in which one MDL is released twice through a race condition.

(A BSOD occurs..!)
2.2 Case: MDL Mismatch Bug Pattern
CVE-2024-38237, CVE-2025-21375
The second case is an MDL Mismatch type vulnerability that occurs when ksthunk.sys and ks.sys process the KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE (0x8000) flag differently.
First, Kernel Streaming has the characteristic of delivering KSSTREAM_HEADER for each request when a video or audio frame is requested in UserMode. This structure contains the address of the actual data buffer, the frame size to process, and flags specifying frame properties.
typedef struct {
ULONG Size;
ULONG TypeSpecificFlags;
KSTIME PresentationTime;
LONGLONG Duration;
ULONG FrameExtent;
ULONG DataUsed;
PVOID Data;
ULONG OptionsFlags;
ULONG Reserved;
} KSSTREAM_HEADER, *PKSSTREAM_HEADER;
Afterwards, ksthunk.sys and ks.sys prepare and connect the MDL to be used in each Frame based on this KSSTREAM_HEADER. The problem is that two drivers may interpret the same flags differently, resulting in the Header being connected to a different MDL than the intended buffer. The fields that are particularly important here are:
Data→ User buffer address where the device will write data.FrameExtent→ Size of buffer to be processed in the frameOptionsFlags→ Flags that specify how frames are processed and whether MDL is cached.
At this time, Kernel Streaming uses MDL to use the user buffer indicated by Data for actual device I/O, and then the MDL and header information are connected to the KS Frame of the kernel.
struct _KSPFRAME_HEADER
{
_LIST_ENTRY ListEntry;
_KSPFRAME_HEADER *NextFrameHeaderInIrp;
void *Queue;
_IRP *OriginalIrp;
_MDL *Mdl;
_IRP *Irp;
KSPIRP_FRAMING_ *IrpFraming;
KSSTREAM_HEADER *StreamHeader;
void *FrameBuffer;
KSPMAPPINGS_TABLE *MappingsTable;
unsigned int StreamHeaderSize;
unsigned int FrameBufferSize;
void *Context;
int RefCount;
void *OriginalData;
void *BufferedData;
int Status;
unsigned __int8 DismissalCall;
_KSPFRAME_HEADER_TYPE Type;
_KSPSTREAM_POINTER *FrameHolder;
unsigned int OriginalOptionsFlags;
_KSPMDLCACHED_STREAM_POINTER *MdlCaching;
};
The field relationships directly related to the bug in this structure are as follows:
KSFrame::StreamHeader- FrameExtent: Frame processing sizeKSFrame::FrameBuffer: Buffer where device data will be written.KSFrame::Mdl: Describes the actual physical pages and size of the FrameBuffer.
Under normal circumstances, KSSTREAM_HEADER and MDL should be concatenated in the same order and based on the same size.
Header 1 (0x1000) → MDL 1 (0x1000 buffer)
Header 2 (0x20000) → MDL 2 (0x20000 buffer)
In other words, if the Header processes 0x20000 bytes, the connected MDL must also be a buffer that describes 0x20000 bytes.
However, when a 32-bit process sends a Kernel Streaming request, the request goes through ksthunk.sys first and then is forwarded to ks.sys. At this time, in frames where the KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE (0x8000) flag is set, a problem may occur in which the MDL processing methods of the two drivers are different.
KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE ON
ksthunk.sys: skip
ks.sys: allocate
KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE OFF
ksthunk.sys: allocate
ks.sys: skip
If only a single frame is processed, there is no problem because the final required MDL is prepared, but if multiple frames, KSSTREAM_HEADER, are placed in one request…? The order of the header array and the order of the MDL chain inside the IRP may be different.
Headers (user-controlled array)
headers[0]
FrameExtent = 0x1000
OptionsFlags = KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE
headers[1]
FrameExtent = 0x20000
OptionsFlags = 0
In other words, two KSSTREAM_HEADERs with different sizes and flags are included in one request. In this case, ksthunk.sys skips generating the MDL for the first header with the KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE flag set, and instead generates an MDL describing a buffer of size 0x20000 for the second header without the flag.
IRP->MdlAddress
MDL A
buffer size = 0x20000
Next = NULL
Afterwards, ks.sys checks that the flag is set in the first Header and creates a new MDL describing the 0x1000 size buffer of that header.
IRP->MdlAddressMDL A (0x20000) -> MDL B (0x1000) -> NULL
However, the newly created MDL B is connected after the existing MDL A. Therefore, the Header array is in the order headers[0] → headers[1], but the final MDL Chain is in the order MDL A for headers[1] → MDL B for headers[0].
KS Frame 0
StreamHeader->FrameExtent = 0x1000
Mdl = MDL A (0x20000)
KS Frame 1
StreamHeader->FrameExtent = 0x20000
Mdl = MDL B (0x1000)
headers[0]:0x1000headers[1]:0x20000MDL A (for
0x20000,headers[1])
MDL B (for0x1000,headers[0])
Afterwards, ks.sys takes out the Header array and MDL Chain in order from the front and connects them to the KS Frame. At this time, because the original correspondence between the header and the MDL is not rechecked, the information for different headers is connected to each KS Frame with the information reversed.
The first KS Frame uses information from headers[0] with FrameExtent of 0x1000, but is actually associated with MDL A for headers[1]. Conversely, the second KS Frame uses the information from headers[1] whose FrameExtent is 0x20000, and is connected to MDL B for headers[0] whose actual size is 0x1000.
In other words, the problem may not be immediately apparent because the first frame uses a larger buffer, but the second frame is trying to process 0x20000 bytes in a 0x1000 byte buffer!🤯 This mismatch ultimately leads to a buffer overflow.
KS Frame 0
FrameExtent = 0x1000
MDL-described buffer = 0x20000
KS Frame 1
FrameExtent = 0x20000
MDL-described buffer = 0x1000
Between Frame 0 and Frame 1, which one do you think is more suspicious? In the above situation, it is KS Frame 1 that creates the actual overflow. StreamHeader->FrameExtent specifies that it handles 0x20000 bytes, but the actual buffer described by the associated MDL B is only 0x1000 bytes.
As a result, the Kernel Streaming Worker writes data beyond the valid range described by the MDL, and adjacent kernel memory becomes corrupted. An MDL Mismatch vulnerability occurs when the order of MDL generated for different headers is reversed and the wrong header and MDL are connected to one KS Frame.
2.3 Case: Forgotten Lock / Uninitialized MDL Bug Pattern
CVE-2024-38238
Lastly, this is the third case. Do you remember that MmProbeAndLockPages() had to be performed before MmMapLockedPagesSpecifyCache() was called during normal MDL processing? If this order is not followed, the PFN array in the MDL may remain uninitialized correctly.
The CVE-2024-38238 case is a Forgotten Lock type vulnerability that occurs when ks.sys generates an MDL and then maps the MDL without calling MmProbeAndLockPages(). In the previous case, MDL Mismatch was a problem in which the connection relationship between the header and MDL changed, while Forgotten Lock was a problem in which the driver considered this to be a normal chain even though the locked MDL and unlocked MDL existed together in one MDL chain.
(Here, MDL Chain refers to a structure that connects multiple MDLs with Next Pointer. When one IRP processes multiple frames, the MDL describing each frame can be managed in a connected form starting from IRP->MdlAddress.)
Header traversal logic in ks.sys
ks!CKsMdlcache::MdlCacheHandleThunkBufferIrp
Let’s check the vulnerable MDL creation and lock flow. First, the WoW64 Kernel Streaming request goes through preprocessing in ksthunk.sys and is then sent to the above function in ks.sys. Afterwards, inside the function, it iterates over the KSSTREAM_HEADER array and checks the OptionsFlags of each Header.

while ( TotalSize >= 0x38 )
If you check the function in detail, you can see that KSSTREAM_HEADER included in the request is processed in order through the While Loop. Here, 0x38 is the size of the 64-bit KSSTREAM_HEADER.
if ( (OptionsFlag & 0x8000) == 0 )
return (unsigned int)KsProbeStreamIrp(irp, a3, 0);
If the flag is set, the function generates the MDL for the current Header and then continues processing the next KSSTREAM_HEADER. Conversely, if the flag is not set, I have a flow that calls KsProbeStreamIrp() and returns directly from the function. The problem is that when this early return occurs, not only the subsequent Header processing but also the MDL Lock loop performed at the bottom of the function is not reached.
Location of MDL Lock Loop
If Header traversal is completed normally, MmProbeAndLockPages() is called while traversing the MDL Chain of the IRP at the bottom of the function.

Therefore, you can see that the normal processing sequence follows the flow below.
- Header array traversal
- Generate the required MDL
- Header traversal completed
- IRP’s MDL Chain traversal
MmProbeAndLockPages()
But can you guess the problem? The MmProbeAndLockPages() loop is placed after the Header traversal. Even if MDL was created through IoAllocateMdl() while processing the Header earlier, if there is no Cache Flag in the next Header, the flag verification code below is executed.
if ( (OptionsFlag & 0x8000) == 0 )
return (unsigned int)KsProbeStreamIrp(irp, a3, 0);
However, the problem is that in the second Header, if OptionsFlags is 0, it returns early to the KsProbeStreamIrp() loop and does not reach the Lock loop at the bottom of the function, MmProbeAndLockPages().
In other words, a problem arises in that the MDL generated in the previous iteration can be passed to KsProbeStreamIrp() without going through MmProbeAndLockPages().
KsProbeStreamIrp MDL verification issue
Then, let’s go into KsProbeStreamIrp()..? This function maps the Frame Buffer to the kernel address space using the IRP MDL Chain.

If you check the code, it first fetches the first MDL of the IRP and then checks the 0x6 mask for the first MdlFlags. The relevant flags are as follows:
#define MDL_MAPPED_TO_SYSTEM_VA 0x0001
#define MDL_PAGES_LOCKED 0x0002
#define MDL_SOURCE_IS_NONPAGED_POOL 0x0004
0x6 is 0x2 | With 0x4, it contains the MDL_PAGES_LOCKED, MDL_SOURCE_IS_NONPAGED_POOL flags. This means that if either flag is present, the condition passes. If the first MDL is Locked or an MDL that describes a NonPaged Pool, the while loop below is entered.
However, this check is only performed for the first MDL pointed to by Irp→MdlAddress, and the status of each MDL MDL_PAGES_LOCKED is not checked again in subsequent while loops.
CurrentMdl = Irp->MdlAddress;
if ( CurrentMdl &&
(CurrentMdl->MdlFlags & 6) &&
(ProbeFlags & 0x40) )
{
while ( CurrentMdl )
{
if ( !(CurrentMdl->MdlFlags & 5) )
MmMapLockedPagesSpecifyCache(CurrentMdl, ...);
CurrentMdl = CurrentMdl->Next;
}
}
Additionally, inside the while statement, only the 0x5 mask is checked for each MDL. Based on the flag checked earlier, this means MDL_MAPPED_TO_SYSTEM_VA or MDL_SOURCE_IS_NONPAGED_POOL state. If a System VA already exists or is an MDL describing a NonPaged Pool, the existing MappedSystemVa is used; otherwise, it moves on to the next MmMapLockedPagesSpecifyCache() call.
MmMapLockedPagesSpecifyCache(
CurrentMdl,
0,
MmCached,
0,
0,
0x40000010u
);
However, even here we do not check whether MmProbeAndLockPages() is performed. In the end, we can see that the core verification error is the logic that only checks the state of the first MDL and then assumes that the rest of the chain will be in the locked state.
Configuring Vulnerability Triggers
Now let’s connect the two logics. First, we include two KSSTREAM_HEADERs of the same size in one request to trigger the vulnerability.
Headers (user-controlled array)
headers[0]
FrameExtent = 0x2000
Data = 0x42420000
OptionsFlags = KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE
headers[1]
FrameExtent = 0x2000
Data = 0x43430000
OptionsFlags = 0
The key is to put the Locked MDL at the beginning of the chain, followed by an Unlocked MDL with OptionsFlags set to 0 and Cache Flag turned off. Then, the first Header creates MDL in ks.sys, and the second Header returns early to the KsProbeStreamIrp() path.
Afterwards, ksthunk.sys skips creating the MDL for the first Header, but for the second Header without the Cache Flag, it generates and locks the MDL on behalf of ks.sys. Therefore, when ksthunk.sys processing is completed, only MDL 2 for the second header is connected to the IRP.
IRP->MdlAddress
MDL 2
Header = headers[1]
Data = 0x43430000
ByteCount = 0x2000
Locked = TRUE
PFN[] = Initialized
Next = NULL
And on the ks.sys side, since the first header has the Cache Flag set, MDL 1 is created through IoAllocateMdl. At this time, since MDL 2 already exists in the IRP, the newly created MDL 1 is connected to the back of the chain.
IRP->MdlAddress
->
MDL 2
for headers[1]
Locked = TRUE
PFN[] = Initialized
->
MDL 1
for headers[0]
Locked = FALSE
PFN[] = Uninitialized
->
NULL
At this point, only IoAllocateMdl() has been performed in MDL 1, and then ks.sys processes the second header, and eventually, due to the weak logic, the MmProbeAndLockPages() loop is not executed and MDL 1 remains in the unlocked state.
Afterwards, KsProbeStreamIrp() examines MDL 2, the first node in the chain. At this time, since MDL 2 is already normally locked by ksthunk.sys, MDL_PAGES_LOCKED is set and the condition passes.
However, in the case of MDL 1, the PFN array is not initialized because it did not go through MmProbeAndLockPages(), and even so, MmMapLockedPagesSpecifyCache() tries to use the value as the physical page number described by the MDL. Therefore, if the remaining uninitialized PFN value is used, the wrong physical page will be referenced and a Bug Check (BSOD) may occur.
Furthermore, the MDL created with
IoAllocateMdl()in this path can reuse Pool Memory without initializing the PFN array. Therefore, an attacker can configure EoP to control uninitialized PFN values by spraying the desired value in the Pool area where the PFN array is located.
3. Outro

In this article, we looked at the basic flow of MDL fixing physical pages in the user buffer and mapping them to the kernel address space, and the Lifetime, Mismatch, and Forgotten Lock bug patterns that occur when this is handled incorrectly.
This article was not about a final battle with Tai Lung, but rather about training to deal with him. Now that you have learned the operation of MDL and the three bug patterns, all that remains is the final authority wall that will lead you from Medium Integrity to SYSTEM in practice. Please look forward to the next Taoist episode to see whether Po will be able to complete the MDL and win the fight against Tai Lung in the next battle!
Reference.
https://theori.io/blog/chaining-n-days-to-compromise-all-part-3-windows-driver-lpe-medium-to-system
https://devco.re/blog/2025/05/17/frame-by-frame-kernel-streaming-keeps-giving-vulnerabilities-en/
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.