CVE-2025-13032: Het binnendringen en doorbreken van de Avast Antivirus Sandbox Deel 2

Dit artikel is het tweede en laatste deel van ons onderzoek naar Avast Antivirus en beschrijft de volledige exploitatie van CVE-2025-13032 op een up-to-date Windows 11-systeem. Uitgaande van de double-fetch kwetsbaarheid die in Deel 1 is geïntroduceerd, leggen we uit hoe de gecontroleerde paged pool overflow werd omgezet in een arbitraire kernel read/write-primitief door het RegBuffers-array van het IORing-object te corrumperen. Het artikel behandelt de heap spray-strategie, het lekken van kerneladressen via MDL-introspectie, de reparaties die nodig zijn om een blue screen bij het beëindigen te voorkomen, en de uiteindelijke privilege-escalatie naar SYSTEM via token theft.

Inleiding

Deze blogpost is het tweede en laatste deel van ons Avast-onderzoek en richt zich op de exploitatie van CVE-2025-13032, een double-fetch kwetsbaarheid die we hebben ontdekt in de kernel-driver van Avast.

In dit artikel vatten we de bug samen en lopen we door de manier waarop we deze hebben geëxploiteerd op een Windows 11-systeem dat op het moment van de ontdekking up-to-date was.

Opmerking: In de nieuwste versies gebruiken de Windows-kernel en drivers user-mode accessors om elke kerneltoegang tot user-mode geheugen te verifiëren en ervoor te zorgen dat user-buffers zich daadwerkelijk in de userspace bevinden. Deze mitigatie voorkomt het gebruik van de exploitatietechniek die in dit verslag wordt beschreven.

Uitleg van de Bug

De bug die we willen exploiteren is een double-fetch probleem dat leidt tot een kernel pool overflow.

Het onderstaande codefragment is bedoeld om een UNICODESTRING-structuur te capturen die door de gebruiker wordt aangeleverd. Echter, het Length-veld van de gebruikersinput wordt meerdere keren opgehaald, wat resulteert in het double-fetch probleem.

De eerste fetch wordt gedaan om een buffer te alloceren waarin de string wordt gekopieerd. Een tweede fetch wordt gedaan om een memcpy uit te voeren op basis van de opnieuw opgehaalde waarde. Als de gebruiker de waarde tussen deze twee acties wijzigt, ontstaat er een pool overflow.

1  if ( !a1 && unicodestring_user )
2  {
3      ProbeForRead(unicodestring_user, 0x10, 1); // Controleer of unicodestring_user zich in userland bevindt
4      ProbeForRead(unicodestring_user->Buffer, unicodestring_user->Length, 1); // Controleer of buffer zich in userland bevindt
5  }
6  v17 = sub_140071C6C((__int64)v18, &v14[v23 + 13], unicodestring_user); // Capture de 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); // Allocatie buffer op basis van huidige lengte
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); // Kopieer data op basis van nieuw opgehaalde lengte
22     }
23     *a2 = PoolWithTag;
24     return 0;
25 }

Om de double-fetch te exploiteren, draait een tweede thread in een strakke loop en wisselt voortdurend het Length-veld van de gedeelde UNICODESTRING tussen een kleine veilige waarde en een grote kwaadaardige waarde (bijv. 0x1000, groter dan de gealloceerde buffer). De hoofdthread roept de kwetsbare IOCTL in een loop aan. Wanneer de timing overeenkomt — de kernel leest Length als klein voor de ExAllocatePoolWithTag-aanroep, en vervolgens als groot voor de memmove — worden er meer bytes gekopieerd dan er zijn gealloceerd, wat leidt tot de pool overflow. Het tijdvenster (race window) is nauw, maar kan binnen een bescheiden aantal iteraties betrouwbaar worden gewonnen.

Ons doel is om deze pool overflow te gebruiken om een arbitraire kernel read/write-primitief te verkrijgen en lokale privilege-escalatie te bewerkstelligen. De bug biedt gunstige exploitatievoorwaarden: de overflow richt zich op de PAGED_POOL, en zowel de allocatiegrootte als de overflow-grootte en de inhoud zijn beheersbaar.

De paged pool is een regio in het Windows-kernelgeheugen die wordt gebruikt voor objecten en data die door de kernel of drivers nodig zijn, maar die naar de schijf kunnen worden gepaged. De allocator groepeert allocaties per grootteklasse, wat betekent dat objecten van dezelfde grootte vaak dicht bij elkaar in het geheugen landen — een eigenschap die heap spraying mogelijk maakt.

Sinds Windows 10 19H1 wordt dit beheerd door de Segment Heap, die twee backends gebruikt: de LFH voor kleine allocaties (die vrije slots willekeurig kiest binnen een grootte-bucket) en de VS-allocator voor grotere allocaties (die het eerste beschikbare blok van de juiste grootte serveert). De meeste Windows-objecten worden opgeslagen in de paged pool, wat ons een groot aantal kandidaten geeft om te corrumperen.

Het I/O Ring-object

Het I/O Ring-object is een object dat een submission queue bijhoudt van I/O-operaties die asynchroon moeten worden uitgevoerd.

Concreet stelt het userland in staat om bestand-I/O-verzoeken te batchen: IoRingReadFile kopieert data van een bestand naar een vooraf geregistreerde buffer, en IoRingWriteFile kopieert data van een vooraf geregistreerde buffer naar een bestand. Deze geregistreerde buffers — bijgehouden in het RegBuffers-veld van de IORINGOBJECT — worden één keer gevalideerd bij registratie en daarna vrij hergebruikt, wat ze een persistent en interessant doel maakt voor corruptie.

We hebben dit object gekozen om de volgende redenen:

  1. Hoewel het IORing-object zelf zich in de NONPAGEDPOOL bevindt, is het RegBuffers-veld gealloceerd in de PAGED_POOL, wat direct overeenkomt met de pool waar de overflow plaatsvindt.
  2. De grootte van de RegBuffers-allocatie is volledig door de gebruiker te bepalen: het registreren van N buffers produceert een array van N pointers van elk 8 bytes.
  3. Het corrumperen van één enkele pointer in die array is voldoende om een volledige arbitraire read/write-primitief te verkrijgen.
  4. I/O Ring-objecten zijn al publiekelijk gebruikt voor dit doel, wat de techniek bevestigt.

Verschillende API's zijn beschikbaar vanuit userland om het object te gebruiken, waaronder:

  • CreateIoRing
  • CloseIoRing
  • BuildIoRingReadFile
  • BuildIoRingWriteFile
  • BuildIoRingRegisterBuffers
  • BuildIoRingRegisterFileHandles
  • SubmitIoRing

De Build.* API's worden gebruikt om entries te construeren die via de SubmitIoRing API moeten worden ingediend. IoRingRegisterBuffers stelt de gebruiker in staat een array van buffers te registreren. Deze actie creëert de RegBuffers pointer-array in de IORINGOBJECT en alloceert de individuele IOPMCBUFFERENTRY objecten waar deze naar verwijst.

Hieronder staan de IORINGOBJECT en IOPMCBUFFERENTRY structuren:

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 *UserEvent;
    unsigned int RegBuffersCount;
    _IOP_MC_BUFFER_ENTRY **RegBuffers; // Array van geregistreerde buffers
    unsigned int RegFilesCount;
    void **RegFiles;
};

RegBuffers is een array van pointers, waarbij elke RegBuffers[i] wijst naar een IOPMCBUFFERENTRY structuur die het Address-veld bevat dat de kernel gebruikt als I/O-doel.

De functie IopIoRingDispatchRegisterBuffers is verantwoordelijk voor het alloceren en instellen van het RegBuffers-veld. Bij een normale leesoperatie kopieert de kernel de data naar het adres in RegBuffers[i].Address zonder opnieuw te controleren of dit nog geldig is, aangezien de controle alleen tijdens de registratie plaatsvindt.

Ons plan is om een RegBuffers-entry om te leiden naar een nep-IOPMCBUFFERENTRY structuur die we volledig controleren in userland. Wanneer de kernel een I/O-operatie uitvoert met die entry, dereferenceert hij onze nep-structuur direct — hij leest het Address-veld uit userland en gebruikt dit als r/w-doel. Dit is mogelijk omdat Windows geen SMAP (Supervisor Mode Access Prevention) implementeert.

Met deze nep-entry worden de twee IORing-operaties onze r/w-primitieven:

  • IoRingReadFile: leest van een bestand en schrijft naar RegBuffers[i].Address → Arbitraire kernel write.
  • IoRingWriteFile: leest van RegBuffers[i].Address en schrijft naar een bestand → Arbitraire kernel read.

Concreet: om een arbitraire kernel write naar adres X uit te voeren, zetten we het Address-veld van de nep-BufferEntry op X en dienen we een IoRingReadFile operatie in. Om te lezen van adres Y, zetten we Address op Y en dienen we een IoRingWriteFile operatie in.

Uitleg over de Spray

De grootte van een RegBuffers-allocatie is $N \times 8$ bytes voor $N$ geregistreerde buffers. Afhankelijk van de gekozen $N$ valt dit in de LFH of de VS-backend. Voor deze demonstratie hebben we een $N$ gekozen die de allocatie in de LFH plaatst. Omdat de LFH slots willekeurig kiest, is precieze plaatsing niet mogelijk; de strategie is daarom om de pool te overspoelen (flood) met een groot aantal RegBuffers-allocaties. Zo is de kans groot dat onze overflowing buffer naast een actieve allocatie landt nadat we een subset hebben vrijgegeven.

We kiezen $N$ zodanig dat de RegBuffers-allocatie in dezelfde pool-bucket valt als onze overflowing UNICODESTRING-buffer (gealloceerd op Length + 16 bytes). Dit zorgt ervoor dat de vrijgekomen gaten precies de juiste grootte hebben.

De spray-strategie ziet er als volgt uit:

  1. Alloceer een groot aantal RegBuffers-structuren.
  2. Dealloqueer een subset hiervan om gaten van de juiste grootte te creëren.
  3. Alloceer onze unicode string.
  4. Trigger de corruptie op hetzelfde moment.

Hierdoor hebben we een gecorrumpeerde RegBuffers-entry en is onze arbitraire r/w-primitief operationeel. De volgende stap is het verkrijgen van een kerneladres.

Misbruik van IORing voor een Memory Leak

We hebben nu een r/w-primitief, maar hebben een kerneladres nodig als doel — specifiek ons eigen _EPROCESS adres, om het SYSTEM-proces token te stelen.

Omdat we de pointer in RegBuffers[0] hebben gecorrumpeerd en deze hebben omgeleid naar een nep-IOPMCBUFFERENTRY in ons eigen procesgeheugen, kunnen we het Address-veld op elk moment wijzigen vanuit userland.

Wanneer we een geregistreerde buffer gebruiken, wordt het adres gemapt via een MDL (Memory Descriptor List). De bijbehorende MDL-pointer wordt opgeslagen in onze BufferEntry structuur:

struct _IOP_MC_BUFFER_ENTRY
{
    [...]
    _MDL *Mdl;
    [...]
}

Een MDL is een kernelstructuur die een bereik van virtueel geheugen beschrijft door de fysieke pagina's vast te zetten (locking). Omdat de MDL een userland-adres beschrijft, moet de kernel bijhouden welk proces dit geheugen bezit. Daarom slaat de MDL een pointer op naar de _EPROCESS structuur van het bezittende proces in het Process-veld:

struct _MDL
{
    [...]
    _EPROCESS *Process;
    [...]
};

In ons geval, wanneer de gecorrumpeerde BufferEntry naar een userland-adres wijst en we een IORing-operatie triggeren, maakt de kernel een MDL aan en koppelt deze aan onze BufferEntry. Omdat de BufferEntry zelf nu in userland leeft, kunnen we het Mdl-veld direct vanuit ons proces lezen. Vervolgens gebruiken we onze arbitraire read-primitief om de MDL-pointer te dereferencen en het Process-veld te extraheren.

Het lekproces verloopt in vier stappen:

  1. RegBuffers[0] wijst naar onze nep-IOPMCBUFFERENTRY op een bekend userland-adres.
  2. We triggeren een IORing-operatie; de kernel maakt een MDL aan en schrijft de pointer in het Mdl-veld van onze nep-entry.
  3. We lezen de Mdl-pointer direct uit userland.
  4. We zetten het Address-veld van onze nep-entry op dat MDL-adres, triggeren een nieuwe operatie en lezen het Process-veld uit de MDL → we hebben een geldig _EPROCESS pointer.

Problemen en Herstel (Issues)

Voordat we overgaan tot privilege-escalatie, moeten we de gecorrumpeerde staat herstellen. Het vrijgeven van het IORing-object zonder opruiming zou het systeem laten crashen.

ProcessBilled

Tijdens onze overflow hebben we een belangrijk veld in de pool-chunk header gecorrumpeerd: het ProcessBilled-veld, dat een pointer naar het proces opslaat dat verantwoordelijk is voor de allocatie. Als dit niet wordt gecorrigeerd, volgt er een blue screen bij het vrijgeven van de chunk.

De ProcessBilled-waarde is een geobfusceerde pointer naar een EPROCESS, berekend als: @EPROCESS ^ ChunkAddress ^ ExpPoolQuotaCookie

We kunnen de ExPoolQuotaCookie (een globale kernelwaarde) afleiden door een tweede, niet-gecorrumpeerde IORing-object te gebruiken. Via onze arbitraire read lezen we diens RegBuffers adres en de ProcessBilled-waarde. Met onze eigen EPROCESS adresse kunnen we de formule omdraaien: Cookie = EPROCESS ^ ChunkAddressclean ^ ProcessBilled_clean. Daarna berekenen we de juiste ProcessBilled voor de gecorrumpeerde chunk en schrijven we deze terug.

Om het gecorrumpeerde IORing-object in het geheugen te vinden, parsen we de handle-tabel van ons proces in de _EPROCESS structuur.

Referentie naar Buffer Entry

Wanneer we ons lek verkrijgen, wordt er in de kernel een referentie naar onze buffer-entry in userland opgeslagen. Dit leidt tot een crash tijdens de teardown. De oplossing is om de RegBuffers-registratie vrij te geven, waardoor de kernel de bijbehorende MDL opruimt.

User Buffer Vrijgeven

Omdat we een buffer-entry hebben gecorrumpeerd, zal de kernel proberen onze userland-pointer vrij te geven bij het sluiten van het object. De oplossing is om de reference count van onze nep-buffer-entry te verhogen. Dit voorkomt dat de teller nul wordt tijdens de IORing-teardown, waardoor de release-functie op onze userland-pointer nooit wordt aangeroepen.

struct _IOP_MC_BUFFER_ENTRY
{
    [...]
    int ReferenceCount;
    [...]
};

Misbruik van Arbitraire R/W voor Hogere Privileges

Om onze privileges te verhogen, stelen we het token van het SYSTEM-proces. Met onze arbitraire read-primitief lopen we door de dubbel gelinkte lijst van EPROCESS om de entry van het SYSTEM-proces te vinden en lezen we het Token-veld. Vervolgens gebruiken we onze arbitraire write-primitief om het Token-veld van onze eigen EPROCESS te overschrijven met de waarde van het SYSTEM-token. Hiermee krijgt ons proces SYSTEM-privileges.

Conclusie

In dit artikel hebben we een volledige lokale privilege-escalatie exploit gedemonstreerd tegen een up-to-date Windows 11-systeem, gebruikmakend van CVE-2025-13032 — een double-fetch kwetsbaarheid in de kernel-driver van Avast. Uitgaande van een gecontroleerde heap pool overflow in de PAGEDPOOL, gebruikten we het RegBuffers-array van het IORing-object als corruptiedoel. Hiermee zetten we een overschreven pointer om in een arbitraire kernel read/write-primitief. Vervolgens lekten we een EPROCESS pointer via de MDL, repareerden we de pool-allocatie header om crashes te voorkomen, en voltooiden we de privilege-escalatie door het SYSTEM-token te stelen.

CVE-2025-13032 is inmiddels gepatcht. We adviseren alle gebruikers om ervoor te zorgen dat hun Avast-installatie up-to-date is.