CVE-2025-13032: Entering and Breaking the Avast Antivirus Sandbox Part 2
This post is the second and final part of our Avast Antivirus research, detailing the full exploitation of CVE-2025-13032 on an up-to-date Windows 11 system. Starting from the double-fetch vulnerability introduced in Part 1, we walk through how the controlled paged pool overflow was turned into an arbitrary kernel read/write primitive by corrupting the RegBuffers array of the IORing object. The post covers the heap spray strategy, the kernel address leak via MDL introspection, the repairs needed to avoid a blue screen on teardown, and the final privilege escalation to SYSTEM via token theft.

Introduction
This blogpost is the second and final part of our Avast research and will focus on the exploitation of CVE-2025-13032, a double-fetch vulnerability we discovered in Avast’s kernel driver.
This post recaps the bug and walks through how we exploited it on an up-to-date Windows 11 system at the time of the finding.
Feel free to read the first part if you missed it → https://www.safateam.com/intelligence-hub/research/technical-articles/cve-2025-13032-entering-and-breaking-the-avast-antivirus-sandbox-part-1
Note: In the latest version the windows kernel and drivers are using user-mode accessors (https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/user-mode-accessors) to verify each kernel access to user-mode memory and ensure at each access that user-buffers are in fact reside in userspace. This mitigation will prevent the use of the exploitation technique that is described in this writeup, see additional details at https://www.youtube.com/watch?v=ry4SNYe2f68
I/O Ring Object
The I/O Ring Object is an object that maintains a submission queue of I/O operations to be performed asynchronously.
Concretely, it lets userland batch file I/O requests: `IoRingReadFile` copies data from a file into a pre-registered buffer, and `IoRingWriteFile` copies data from a pre-registered buffer into a file. These registered buffers — tracked in the `RegBuffers` field of the `_IORING_OBJECT` — are validated once at registration time and then reused freely for every subsequent operation, making them a persistent and interesting target to corrupt.We chose this object as our corruption target for several reasons. While the IORing object itself is located in the `NON_PAGED_POOL`, its `RegBuffers` field is allocated in `PAGED_POOL`, which directly matches the pool where the overflow occurs. Second, the size of the `RegBuffers` allocation is fully user-controlled: registering N buffers produces an array of N pointers, each 8 bytes, giving us precise control over the allocation size and making it ideal for a heap spray. Third, corrupting a single pointer in that array is sufficient to gain a full arbitrary read/write primitive — there is no need to corrupt a more complex structure. Finally, I/O Ring Objects have already been used publicly to achieve this exact goal, which confirms the technique and provides a solid reference point for our approach. ( https://windows-internals.com/one-i-o-ring-to-rule-them-all-a-full-read-write-exploit-primitive-on-windows-11/ )
Multiple APIs are available from userland to use the object, here are some of them:
- CreateIoRing
- CloseIoRing
- BuildIoRingReadFile
- BuildIoRingWriteFile
- BuildIoRingRegisterBuffers
- BuildIoRingRegisterFileHandles
- SubmitIoRing
- ...
The `Build.*` APIs are used to construct entries that need to be submitted through the `SubmitIoRing` API.
The `IoRingRegisterBuffers` allows the user to register an array of buffers for future I/O Ring operations, which can be used as a destination buffer for the `IoRingReadFile` operation or as a source buffer for the `IoRingWriteFile`. This action creates the `RegBuffers` pointer array in the `_IORING_OBJECT` and allocates the individual `_IOP_MC_BUFFER_ENTRY` objects it points to, each holding information about the registered buffer.
Find below the `_IORING_OBJECT` and the `_IOP_MC_BUFFER_ENTRY` structure:
struct _IOP_MC_BUFFER_ENTRY
{
unsigned __int16 Type;
unsigned __int16 Reserved;
unsigned int Size;
int ReferenceCount;
_IOP_MC_BUFFER_ENTRY_FLAGS Flags;
_LIST_ENTRY GlobalDataLink;
void *Address;
unsigned int Length;
char AccessMode;
int MdlRef;
_MDL *Mdl;
_KEVENT MdlRundownEvent;
unsigned __int64 *PfnArray;
_IOP_MC_BE_PAGE_NODE PageNodes[1];
};
struct _IORING_OBJECT
{
__int16 Type;
__int16 Size;
_NT_IORING_INFO UserInfo;
void *Section;
_NT_IORING_SUBMISSION_QUEUE *SubmissionQueue;
_MDL *CompletionQueueMdl;
_NT_IORING_COMPLETION_QUEUE *CompletionQueue;
unsigned __int64 ViewSize;
int InSubmit;
unsigned __int64 CompletionLock;
unsigned __int64 SubmitCount;
unsigned __int64 CompletionCount;
unsigned __int64 CompletionWaitUntil;
_KEVENT CompletionEvent;
unsigned __int8 SignalCompletionEvent;
_KEVENT *CompletionUserEvent;
unsigned int RegBuffersCount;
_IOP_MC_BUFFER_ENTRY **RegBuffers; // Array of registered buffers
unsigned int RegFilesCount;
void **RegFiles;
};
The diagram below shows this structure in memory: `RegBuffers` is an array of pointers, where each `RegBuffers[i]` points to a `_IOP_MC_BUFFER_ENTRY` structure holding the `Address` field that the kernel uses as the I/O target:

The `IopIoRingDispatchRegisterBuffers` function is responsible for allocating and setting up the `RegBuffers` field of our IORing Object.
When used in a normal way, a read operation using a registered buffer will read the file and copy the retrieved data into the address contained in the corresponding RegBuffers entry `RegBuffers[i].Address` without checking if it's still valid as the check is only done during registration.
Our plan is to redirect a `RegBuffers` entry to point to a fake `_IOP_MC_BUFFER_ENTRY` structure we fully control in userland. When the kernel performs an I/O operation using that entry, it will dereference our fake structure directly — reading the `Address` field from userland and using it as the r/w target. This is only possible because Windows does not implement SMAP (Supervisor Mode Access Prevention), which would otherwise prevent the kernel from dereferencing a pointer into userland memory.
With this fake entry in place, the two IORing operations become our r/w primitives:
`IoRingReadFile` reads from a file and writes into `RegBuffers[i].Address` — making it our arbitrary kernel write:

`IoRingWriteFile` reads from `RegBuffers[i].Address` and writes into a file — making it our arbitrary kernel read:

Concretely: to perform an arbitrary kernel write to address X, set the `Address` field of the fake `BufferEntry` to X and submit an `IoRingReadFile` operation — the kernel copies the read data directly into the memory at X. To read from address Y, set `Address` to Y and submit an `IoRingWriteFile` operation — the kernel reads from Y and writes the data to the output file, which we retrieve from userland. In both cases, updating the `Address` field in our userland-resident fake entry is all that is needed to redirect the operation.
Spray explanation
The size of a `RegBuffers` allocation is N × 8 bytes for N registered buffers, meaning it can fall in either the LFH or the VS backend depending on the chosen N. For this demonstration we picked a value of N that places the allocation in the LFH, which is sufficient to show the impact of the technique. Since the LFH randomises slot selection within its subsegments, precise placement is not possible — the strategy is therefore to flood the pool with a large number of `RegBuffers` allocations so that, after freeing a subset, the probability of our overflowing buffer landing adjacent to a live one is high enough to be reliable.
We choose N registered buffers such that the `RegBuffers` allocation falls in the same pool bucket as our overflowing `_UNICODE_STRING` buffer (allocated at `Length + 16` bytes). This ensures the freed `RegBuffers` holes are exactly the right size to receive our overflowing allocation, making adjacency reliable.
To reach a state where our heap overflow lands on a `RegBuffers` allocation, we use the following spray strategy. The setup is minimal: one IORing object is required per `RegBuffers` structure we want to position.
The spray itself is straightforward: we allocate a large number of `RegBuffers` structures, free a subset of them to create holes of the right size, then trigger the vulnerability to land our overflowing allocation in one of those holes and corrupt an adjacent entry.
This is how it looks in memory:
1. Allocate a large number of `RegBuffers` structures

2. Deallocate some of them

3. Allocate our unicode string

4. Trigger the corruption at the same time

From there we have a corrupted `RegBuffers` entry — the heap overflow has succeeded and our arbitrary r/w primitive is in place. The next step is obtaining a kernel address to use as the r/w target.
Abusing IORing to get a leak
At this point we have an arbitrary r/w primitive but need a kernel address to target — specifically our own `_EPROCESS` address, which we will use to steal the SYSTEM process token.
The diagram below shows the state of the structures after the corruption:

Since we corrupted the pointer inside `RegBuffers[0]` — redirecting it to a fake `_IOP_MC_BUFFER_ENTRY` that lives in our own process memory — we can modify the `Address` field of that fake entry at any time simply by writing to it from userland. There is no need to trigger the vulnerability a second time.
Our arbitrary r/w primitive is operational, but it requires a target kernel address. Since kernel addresses are randomized and cannot be predicted from userland, we need to leak one — specifically the address of our own `_EPROCESS` structure, which we will later use to manipulate our process token.
While using our registered buffer, the address will be mapped through an MDL. The associated MDL pointer is stored in our BufferEntry structure:
( https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/using-mdls )
struct _IOP_MC_BUFFER_ENTRY
{
[...]
_MDL *Mdl;
[...]
} A Memory Descriptor List (MDL) is a kernel structure that describes a range of virtual memory by locking its physical pages in place. When the kernel needs to safely operate on a userland buffer — for example, to perform I/O into it — it creates an MDL for that buffer, which pins the underlying physical pages so they cannot be paged out or remapped during the operation. Because the MDL describes a userland address, the kernel needs to track which process owns that memory, so the MDL stores a pointer to the owning process’s _EPROCESS structure in its Process field:
struct _MDL
{
[...]
_EPROCESS *Process;
[...]
};
In our case, when the corrupted BufferEntry points to a userland address and we trigger an IORing operation, the kernel creates and attaches an MDL to our BufferEntry to map that address. Since the BufferEntry itself now lives in userland (as a result of our corruption), we can simply read its Mdl field directly from our process. We then use our arbitrary read primitive to dereference that MDL pointer and extract the Process field — giving us a valid _EPROCESS pointer for our process, which is all we need to proceed with the privilege escalation.
The leak proceeds in four steps:
(1) `RegBuffers[0]` now points to our fake `_IOP_MC_BUFFER_ENTRY` at a known userland address.
(2) We trigger an IORing operation — the kernel creates an MDL for our userland buffer and writes its pointer into our fake entry’s `Mdl` field.
(3) Since the fake entry is in our own process memory, we read the `Mdl` pointer directly from userland without any kernel primitive.
(4) We set the `Address` field of our fake entry to that MDL address, trigger another operation, and read the `Process` field from the MDL — giving us a valid `_EPROCESS` pointer.
Issues
At this point we have both an arbitrary read/write primitive and a kernel address leak. Before proceeding to the privilege escalation, however, we need to repair the corrupted state — releasing the IORing object without cleanup will crash the system.
ProcessBilled
During our overflow, we corrupted an important field in the pool chunk header: the `ProcessBilled` field, which stores a pointer to the process responsible for the allocation. If left uncorrected, this will trigger a blue screen when the chunk is freed.
The ProcessBilled value is an obfuscated pointer to an `EPROCESS`, this value is computed as follows:
`@EPROCESS ^ ChunkAddress ^ ExpPoolQuotaCookie`
`ChunkAddress` is the address of the corrupted pool chunk header, located at a known negative offset before the `RegBuffers` pointer we already know.
`ExPoolQuotaCookie` is a global kernel value used to obfuscate pool billing pointers; to derive it, we use a second uncorrupted IORing object.
Via our arbitrary read, we read its `RegBuffers` address from the `_IORING_OBJECT` and the `ProcessBilled` value from the preceding pool chunk header. Since we know our `_EPROCESS` address, we reverse the formula: `Cookie = EPROCESS ^ ChunkAddress_clean ^ ProcessBilled_clean`.
We then compute the correct `ProcessBilled` for the corrupted chunk and write it back using our arbitrary write primitive.
With the formula in hand, the remaining step is locating the corrupted IORing object itself in memory so we can apply the fix.
We locate the IORing object by parsing our process handle table, found in the `_EPROCESS` structure, following the same logic as `ExpLookupHandleTableEntry` to retrieve the handle table entry, then using the same formula as in `ExGetHandlePointer` to transform it into an object pointer.
Buffer Entry reference
When we obtain our leak, a reference to our buffer entry located in userland is stored in the kernel which will lead to a crash when the kernel attempts to process it during teardown.
The fix is to release the `RegBuffers` registration. This causes the kernel to clean up the associated MDL as part of teardown, resolving the lingering reference. Releasing the MDL directly would not be sufficient — the MDL release is a consequence of releasing the `RegBuffers` entry, not a standalone action. However, releasing the registration triggers another issue, covered below.
Free user buffer
As we have corrupted a buffer entry, the kernel will try to release our userland pointer when closing the object.
The solution to this issue is to simply increase the reference count of our fake buffer entry.
This prevents the kernel reference count from ever reaching zero during IORing teardown, so the corresponding release function is never invoked on our userland pointer.
struct _IOP_MC_BUFFER_ENTRY
{
[...]
int ReferenceCount;
[...]
};
Abuse Arbitrary R/W to get more privilege
To escalate our privileges, we steal the SYSTEM process token. Using our arbitrary read primitive, we walk the `EPROCESS` doubly-linked list to locate the SYSTEM process entry and read its `Token` field. We then use our arbitrary write primitive to overwrite our own `EPROCESS` `Token` field with the SYSTEM token value, granting our process SYSTEM-level privileges.
Conclusion
In this post we demonstrated a full local privilege escalation exploit against an up-to-date Windows 11 system, leveraging CVE-2025-13032 — a double-fetch vulnerability in Avast’s kernel driver. Starting from a controlled heap pool overflow in PAGED_POOL, we used the RegBuffers array of the IORing object as our corruption target, turning a single overwritten pointer into an arbitrary kernel read/write primitive. From there, we leaked an _EPROCESS pointer via the MDL attached to our corrupted BufferEntry, repaired the pool allocation header to avoid a crash on teardown, and completed the privilege escalation by stealing the SYSTEM process token.
CVE-2025-13032 has since been patched. We encourage all users to ensure their Avast installation is up to date. The full disclosure timeline is detailed in Part 1 of this research.
If you missed the first part of this research, which covers the vulnerability discovery and sandbox escape, you can find it here: CVE-2025-13032 — Entering and Breaking the Avast Antivirus Sandbox (Part 1).
Related posts
More content you might like