返回资讯中心
外部精选
软件工程
#社区热议

CVE-2025-13032: Entering and Breaking the Avast Antivirus Sandbox Part 2

CVE-2025-13032: Entering and Breaking the Avast Antivirus Sandbox Part 2

Hacker Newssafateam2026/9/25 · 2026/9/2530 分钟阅读

以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。

All posts

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.

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

The 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.

1

if ( !a1 && unicodestring_user )

2

{

3

    ProbeForRead(unicodestring_user, 0x10, 1); // Check if unicodestring_user is located in userland

4

    ProbeForRead(unicodestring_user->Buffer, unicodestring_user->Length, 1); // Check if buffer is located in userland

5

}

6

v17 = sub_140071C6C((__int64)v18, &v14[v23 + 13], unicodestring_user); // Capture the 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); // Allocating Buffer based on current length

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); // Copy data based on newly fetched 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 ).

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.

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:

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:

1

struct _IOP_MC_BUFFER_ENTRY

2

{

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

};

18

struct _IORING_OBJECT

19

{

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; // Array of registered buffers

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. In both cases, updating the Address field in our userland-resident fake entry is all that is needed to redirect the operation.

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

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →