
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.4", 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 proof -- the payload executed inside the target process.
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
inject_sapc ranks the target's threads. The strongest ranking signal is
whether a thread owns a window:
EnumWindows +
GetWindowThreadProcessId. A GUI thread parks in an alertable wait in its
message loop, so a queued APC runs there first. +1000.NtQueryInformationThread(ThreadCycleTime); falls back to
ThreadTimes when the class is unavailable on the running build (see
examples/tprobe.rs). Normalised and added.NtQueryInformationThread(ThreadTimes) — kernel + user time.NtQueryInformationThread(ThreadSuspendCount). A suspended
thread cannot run an APC: −2000 per suspend count.NtQueryInformationThread(ThreadBasicInformation). Priority
8–10 gets +150; out-of-range priority gets −300.Filtering. Suspended threads and threads parked in a non-alertable kernel
wait — WrDelayExecution (Sleep/NtDelayExecution) or WrSuspended, read from
NtQuerySystemInformation(SystemProcessInformation) — are dropped outright (they
can never take an APC). The wait reason is otherwise diagnostic only: it is
logged per candidate as State: / Reason: / Alert:, but it does not
influence the score, because KWAIT_REASON does not encode alertability (an
alertable and a non-alertable object wait report the same value — verified with
examples/tprobe.rs-style probing).
The candidates are sorted (highest score, tie-break GUI then cycles) and
truncated to MAX_APC_CANDIDATES (10) for enumeration/logging.
Enumerate ≠ inject. The number of threads listed is decoupled from the number injected.
MAX_APC_CANDIDATES(10) controls how many are ranked and logged;APC_INJECT_THREADS(5) controls how many actually receive the APC.Why inject to several, then retry: user mode cannot tell which thread is in an alertable wait, and a single chosen thread often never enters one, so the APC would never fire.
inject_sapctherefore queues the APC to several threads and then re-queues in a short loop (~80 ms) until the shared gate word flips from 0 to 1 — i.e. until some target thread actually ran the payload. It logs whether that happened:payload claimed the gate(ran) vsgate NOT claimed.Why run once: the same shellcode/context is queued to each thread, so without protection a non-reentrant payload would run concurrently and crash the host (measured: 3 simultaneous executions). The reentrancy gate (
USE_REENTRANCY_GATE, default on) makes the payload body run exactly once regardless of how many threads (or how many retry rounds) take the APC (measured: exactly 1). Leave the gate on unless your payload is explicitly concurrency-safe.
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");