CVE-2025-13032: Entering and Breaking the Avast Antivirus Sandbox Part 2
Pangram verdict · v3.3
We believe that this text is a mix of AI, AI-assisted, and human-written content.
AI likelihood · overall
MixedArticle text · 1,328 words · 9 segments analyzed
IntroductionThis 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-1Note: 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=ry4SNYe2f68Bug ExplanationThe bug we want to exploit is a double fetch issue that leads to a kernel pool overflow.The snippet of code presented below is supposed to capture a `_UNICODE_STRING` structure supplied by the user, but the `Length` field of the user input is fetched multiple times which results in the double fetch issue.The first fetch is done to allocate a buffer where the string will be copied and a second fetch is done to perform a memcpy based on the retrieved value resulting in a pool overflow if the user changes it between those actions.1if ( !a1 && unicodestring_user )2{3 ProbeForRead(unicodestring_user, 0x10, 1); 4 ProbeForRead(unicodestring_user->Buffer, unicodestring_user->Length, 1); 5}6v17 = sub_140071C6C((__int64)v18, &v14[v23 + 13], unicodestring_user); 7[...]8__int64 __fastcall sub_140071C6C(__int64 a1, _QWORD *a2, _UNICODE_STRING *unicodestring_user)9{10 PoolWithTag = ExAllocatePoolWithTag(PagedPool, unicodestring_user->Length + 16, 0x20786E53u); 11 if ( !PoolWithTag )12 return 0xC000009A;13 Length = unicodestring_user->Length;14 *PoolWithTag = unicodestring_user->Length;15 PoolWithTag[1] = Length;16 *((_QWORD *)PoolWithTag + 1) = PoolWithTag + 8;17 Buffer = (char *)unicodestring_user->Buffer;18 if ( Buffer )19 {20 if ( unicodestring_user->Length )21 memmove((_OWORD *)PoolWithTag + 1, Buffer, unicodestring_user->Length); 22 }23 *a2 = PoolWithTag;24 return 0;25}To exploit the double fetch, a second thread runs in a tight loop, continuously toggling the `Length` field of the shared `_UNICODE_STRING` between a small safe value and a large malicious value (e.g.
`0x1000`, larger than the allocated buffer). The main thread calls the vulnerable IOCTL in a loop. When the timing aligns — the kernel reads `Length` as small for the `ExAllocatePoolWithTag` call, then reads it as large for the `memmove` — more bytes are copied than were allocated, producing the pool overflow.
The race window is narrow but can be won reliably within a modest number of iterations.Our goal is to exploit this pool overflow to gain an arbitrary kernel read/write primitive and achieve a local privilege escalation. The bug gives us good exploitation conditions: the overflow targets `PAGED_POOL`, both the allocation size and the overflow size are controlled, and so is the content.The paged pool is a region of Windows kernel memory used for objects and data that the kernel or drivers need, but that can be paged out to disk. It is used for memory that does not need to be accessed by critical code running with high priority.
The allocator groups allocations by size class, meaning same-sized objects tend to land close to each other in memory — the property that makes heap spraying viable. Since Windows 10 19H1 this is handled by the Segment Heap, which uses two backends: the LFH for small allocations, which picks free slots randomly within a size bucket, and the VS allocator for larger ones, which serves the first available chunk of the right size — each requiring a different spray strategy.
We can also note that most Windows objects are stored in the paged pool, which gives us a large number of candidates when choosing what to corrupt. In the next section we explain which object we chose and the reasons behind that choice.For more information about how windows pools work, you can refer to the `Scoop the Windows 10 pool!` paper from Synacktiv ( https://www.sstic.org/media/SSTIC2020/SSTIC-actes/pool_overflow_exploitation_since_windows_10_19h1/SSTIC2020-Article-pool_overflow_exploitation_since_windows_10_19h1-bayet_fariello.pdf ).I/O Ring ObjectThe 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.
Secondly, 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. Thirdly, 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:1struct _IOP_MC_BUFFER_ENTRY2{3 unsigned __int16 Type;4 unsigned __int16 Reserved;5 unsigned int Size;6 int ReferenceCount;7 _IOP_MC_BUFFER_ENTRY_FLAGS Flags;8 _LIST_ENTRY GlobalDataLink;9 void *Address;10 unsigned int Length;11 char AccessMode;12 int MdlRef;13 _MDL *Mdl;14 _KEVENT MdlRundownEvent;15 unsigned __int64 *PfnArray;16 _IOP_MC_BE_PAGE_NODE PageNodes[1];17};18struct _IORING_OBJECT19{20 __int16 Type;21 __int16 Size;22 _NT_IORING_INFO UserInfo;23 void *Section;24 _NT_IORING_SUBMISSION_QUEUE *SubmissionQueue;25 _MDL *CompletionQueueMdl;26 _NT_IORING_COMPLETION_QUEUE *CompletionQueue;27 unsigned __int64 ViewSize;28 int InSubmit;29 unsigned __int64 CompletionLock;30 unsigned __int64 SubmitCount;31 unsigned __int64 CompletionCount;32 unsigned __int64 CompletionWaitUntil;33 _KEVENT CompletionEvent;34 unsigned __int8 SignalCompletionEvent;35 _KEVENT *CompletionUserEvent;36 unsigned int RegBuffersCount;37 _IOP_MC_BUFFER_ENTRY **RegBuffers; 38 unsigned int RegFilesCount;39 void **RegFiles;40};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.