lacuna-rs
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.
Critical: Frame-Pointer Requirement
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.
What it does
| 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 |
Feature flags
[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.
Quick start
Scan for ghost regions
cargo run --example scan
Build + verify the ghost-frame chain
cargo run --example verify --features stack-spoof
Inject shellcode via section + APC
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
Thread scoring algorithm
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:
- Cycle time —
NtQueryInformationThread(ThreadCycleTime)— primary activity indicator - CPU time —
NtQueryInformationThread(ThreadTimes)— kernel + user time - Suspend count —
NtQueryInformationThread(ThreadSuspendCount)— non-suspended threads get +300 bonus; suspended threads get -150 per suspend count - Priority —
NtQueryInformationThread(ThreadBasicInformation)— threads with priority 8-10 get +150 bonus; out-of-range priorities get -100 penalty
Completely 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.
Using as a library
Basic: scan + SSN resolution
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);
Emit an indirect syscall stub for any NT API
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()) };
Build the ghost-frame chain + register VEH
// 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);
Wrap a sensitive syscall with stack spoof + param encryption
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();
OPSEC: What to hide behind ghost frames
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.
Must be behind indirect syscalls + ghost frames + param encryption
These are the "crown jewel" NT syscalls that EDRs hook and correlate:
| Call | Why it's sensitive |
|---|---|
NtOpenProcess |
Opens a handle to another process -- first step of injection |
NtCreateSection + NtMapViewOfSection (remote) |
Section-based injection signature |
NtWriteVirtualMemory |
Cross-process write -- classic injection indicator |
NtCreateThreadEx |
Remote thread creation -- highest-signal injection primitive |
NtQueueApcThread |
APC injection -- high-signal |
NtProtectVirtualMemory |
RWX permission changes -- shellcode staging indicator |
NtAllocateVirtualMemory (remote) |
Remote allocation -- injection prelude |
NtSetInformationThread |
Thread hiding (HideFromDebugger) -- evasion indicator |
For each of these:
- Resolve with
nt::resolve(ntdll, b"NtXxx\0") - Emit a stub with
stub::make_stub(ssn, syscall_ret) - XOR-encrypt sensitive parameters with
veh::pcrypt_arm(key, syscall_ret, true) - Wrap the call between
chain::stomp_plant()andchain::stomp_restore()
Can be direct (no ghost frames needed)
| Call | Why it's safe |
|---|---|
NtQueryInformationThread |
Query-only, rarely hooked, no cross-process write |
NtDelayExecution |
Sleep -- benign, used by every application |
NtClose |
Handle close -- benign, extremely common |
GetModuleHandleA / GetProcAddress |
Module resolution -- not a syscall, not hookable by EDR userland hooks |
CreateToolhelp32Snapshot / Thread32First / Thread32Next |
Thread enumeration -- kernel32, not ntdll syscall |
OpenThread / CloseHandle |
Standard handle ops -- kernel32 |
GetThreadContext / SetThreadContext |
Needed for DR0 -- kernel32, self-process only |
Key principles
- Resolve any NT syscall with
nt::resolve(ntdll, b"NtXxx\0")-- returns(SSN, syscall_ret_VA). - Emit a JIT stub with
stub::make_stub(ssn, syscall_ret)-- returns a callable function pointer. - Cast the stub to the appropriate
extern "system" fn(...)type viacore::mem::transmute. - (For sensitive calls only) Build the ghost-frame chain with
chain::build_chain()and callchain::stomp_plant()/chain::stomp_restore()around the call. - (For sensitive calls only) Arm parameter encryption with
veh::pcrypt_arm(key, syscall_ret, true)before the call, andveh::pcrypt_disarm()after. - Always disarm the VEH before making non-protected calls -- a stray DR0 hit on an unarmed syscall will crash.
- All output strings must be wrapped in
lc!()-- the crate enforces this, and the VEH diagnostics are gated behindset_verbose(true)which defaults to off.
The stub handles the mov r10, rcx / mov eax, SSN / jmp [syscall;ret] sequence automatically. If a ghost gadget was registered, the stub routes through JMP [RBX] for dual-use execution redirect + zero-artifact bridge frame.
Writeup Coverage Verification
All 9 key contributions from the LACUNA Chain writeup are represented in the code:
| # | Writeup Concept | Code Location | Description |
|---|---|---|---|
| 1 | BYOUD-Gap (zero .pdata modification) |
chain.rs -- ghost frame chain construction |
Exploits gaps between RUNTIME_FUNCTION entries; unwinder treats them as leaf frames (RSP += 8) |
| 2 | ETW-Ti APC Window Attack | inject.rs -- NtDelayExecution alertable drain |
Controls when the ETW-Ti APC stack snapshot fires by manipulating thread alertable state |
| 3 | Parameter Encryption in BYOUD Context | veh.rs -- pcrypt_arm(), param_encrypt_veh() |
XOR-encrypts syscall params at staging; decrypts inside a hardware-breakpoint VEH at the syscall instruction |
| 4 | Win32u NOP Gap Chain + Ghost Gadget | scan.rs -- win32u_nop_gap(), scan_ghost_gadgets() |
1,242 NOP gaps in win32u.dll provide whitelisted leaf frames; JMP [RBX] ghost gadget at ntdll+0xFC47B |
| 5 | kernelbase Semantic Ghost Proximity | chain.rs -- L2_kbase layer near VirtualProtect |
238-byte ghost ending at VirtualProtect's entry point -- indistinguishable from a real VP return site |
| 6 | BYOUD-MF (Machine Frame RSP Teleport) | chain.rs -- MachFrame struct, scan.rs -- find_mf_target() |
Exploits UWOP_PUSH_MACHFRAME (opcode 10) in KiUser* dispatchers for arbitrary RSP teleport in a single frame |
| 7 | BYOUD-RT (Runtime RSP Calculation) | chain.rs -- teb_stack_base(), teb_stack_limit() |
Reads TEB.StackBase (GS:[0x08]) at call time to compute exact frame distance -- no pre-calibration needed |
| 8 | wow64.dll Ghost Proximity | chain.rs -- L1_wow64 layer, scan.rs -- targets Wow64PrepareForException |
91-byte ghost ending at Wow64PrepareForException entry -- fourth semantic layer for the chain |
| 9 | Six-Layer LACUNA Chain | chain.rs -- LacunaStack struct (L1-L5 + MachFrame) |
Full chain: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
Detection surface coverage
| Detection Layer | Status | Implementation |
|---|---|---|
| Module-of-origin check | EVADED | All frames in ntdll / kernelbase / wow64 / win32u |
| Unwind walk correctness | EVADED | All lacuna frames are leaf -> valid RSP+8 |
.pdata forensic scan |
EVADED | Zero modification; gaps are pre-existing |
| CET shadow stack | EVADED | Pure leaf chain; shadow stack not consulted |
| Semantic frame analysis | EVADED | WoW64 exception + VirtualProtect adjacency |
| Win32u rule exemption | EVADED | Layer 4 explicitly excluded by all rules |
| ETW-Ti STACKWALK | EVADED | APC window attack controls snapshot timing |
| Parameter inspection | EVADED | HW breakpoint VEH decryption |
| Kernel callbacks | PARTIAL | Handle operations still fire ObRegisterCallbacks |
Why not just use syscalls-rs?
syscalls-rs and wsyscall-rs generate a build-time SSN table from a specific Windows build's ntdll. If the target machine runs a different build, the SSNs are wrong and syscalls fail (or trip EDR heuristics).
lacuna-rs resolves SSNs at runtime by reading ntdll's stubs directly, and targets the function's own syscall;ret instruction so RIP is inside ntdll at kernel entry -- defeating the EDR "SSN mismatch" and "indirect syscall from unbacked memory" heuristics.
Additionally, lacuna-rs provides the ghost-frame stack spoofing chain that syscalls-rs does not.
Build requirements
- Target:
x86_64-pc-windows-msvc(orx86_64-pc-windows-gnu) - Rust edition: 2021
- Dependencies:
litcrypt2(compile-time string obfuscation)
Frame-pointer setup (for stack-spoof only)
The stack-spoof feature requires frame pointers. build.rs sets force-frame-pointers=yes automatically for this crate, but consuming crates must also set it in their .cargo/config.toml:
# .cargo/config.toml (in the consuming crate)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
litcrypt2 string obfuscation
All string literals in the crate and examples are wrapped in lc!() macros, which encrypt them at compile time and decrypt at runtime. The encryption key is read from the LITCRYPT_ENCRYPT_KEY environment variable; if unset, litcrypt2 auto-generates a random key.
To pin a key for reproducible builds:
set LITCRYPT_ENCRYPT_KEY=your-secret-key
cargo build
Architecture
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
Credits
- Original research & C implementation: Mohamed Alzhrani (0xmaz) -- LACUNA Chain
- Technique: Ghost Frames: Forging Plausible Call Stacks from .pdata Lacunae
- Find Best APC Thread Logic: Adapted from FrankensteinAPCInjection by S12cybersecurity
- String obfuscation: litcrypt2 v0.1.3
Full Disclosure
This project was activly assisted by an AI Chatbot. I had the chance to test a newer, very capable Open Source Model (GLM-5.2 by Z.ai (Zhipu AI)) and wanted to give it a challenging task. Lessons learned: It worked well if the human knows what results shall look like. If not... only crap
License
MIT