适用于 Windows x64 的幽灵帧调用栈欺骗 + 运行时间接系统调用 —— 已移植到 Rust。
移植自 Mohamed Alzhrani (0xmaz) 的 LACUNA Chain(lacuna_chain.c)。
lacuna-rs 是一个可复用的 Rust crate,提供与原始 C TTP 相同的原语,其结构设计使其可以轻松集成到任何 Rust 项目中 —— 类似于 wsyscall-rs 或 syscalls-rs,但具备运行时 SSN 解析、按函数定向的 syscall;ret 以及幽灵帧栈欺骗能力。
如果使用该 crate 的项目未启用帧指针编译,
stack-spoof特性将静默失效。
chain.rs 中的栈覆盖原语通过 mov rbp, {x} 内联汇编定位调用者的返回地址槽。这要求 RBP 链保持完整。Rust(以及大多数 release 模式编译器)默认省略帧指针。
build.rs 为本 crate 自身的代码生成设置了 force-frame-pointers=yes,但 Cargo 无法将编译器标志传播到下游 crate。 你必须在你自己的项目中添加以下内容:```toml
[build] rustflags = ["-C", "force-frame-pointers=yes"]
如果没有这个,`stomp_plant()` 将从 RBP 读取垃圾数据,要么什么都不做(最好的情况),要么破坏栈(最坏的情况)。该 crate 无法在运行时检测帧指针是否启用——它只会直接不工作。
如果你只需要扫描、SSN 解析或注入原语(不需要栈欺骗),可以省略 `stack-spoof` feature,此要求便不适用。
---
## 功能
| 原语 | C 函数 | Rust 模块 |
|---|---|---|
| PE 节区 + 导出解析 | `pe_section()`, `pe_export()` | `pe` |
| `.pdata` 幽灵区域扫描 | `scan_ghosts()`, `best_ghost()` | `scan` |
| 幽灵 gadget 发现(`jmp [rbx]`) | `scan_ghost_gadgets()` | `scan` |
| `win32u` NOP 间隙查找器 | `win32u_nop_gap()` | `scan` |
| BYOUD-MF 锚点查找器 | `find_mf_target()` | `scan` |
| SSN 解析(Hell's Gate / Halo's Gate) | `resolve_ssn()` | `nt` |
| 逐函数 `syscall;ret` 定位器 | `find_func_syscall()` | `nt` |
| JIT 间接系统调用桩生成 | `alloc_stub()` | `stub` |
| 幽灵 gadget 桩重定向 | (在 `alloc_stub()` 中) | `stub` |
| VEH + 硬件断点参数加密 | `param_encrypt_veh()`, `pcrypt_arm()` | `veh` |
| 链守卫 VEH | `chain_veh()` | `veh` |
| LACUNA 链构建 | `build_chain()` | `chain` |
| 栈覆写(BYOUD-RT) | `stomp_plant()`, `stomp_restore()` | `chain` |
| 链遍历器(验证) | `lacuna_walk_chain()` | `chain` |
| 基于节区的 APC 注入 | `do_inject_sapc()` | `inject` |
---
## Feature 标志```toml
[dependencies]
lacuna-rs = { version = "0.4", features = ["inject", "stack-spoof", "veh"] }
| 功能 | 描述 | 需要帧指针? |
|---|---|---|
syscalls(默认) | SSN 解析 + JIT 存根生成 | 否 |
inject(默认) | 基于节的 APC 注入(inject::inject_sapc) | 否 |
veh | VEH + 硬件断点参数加密 | 否 |
stack-spoof | LACUNA 幽灵帧链 + 栈覆盖 | 是 |
no-std | no_std 模式(实验性) | 否 |
当未启用任何功能时,仅扫描/PE/NT 层可用。
cargo run --example scan
### 构建并验证幽灵帧链```sh
cargo run --example verify --features stack-spoof
注入证明——payload 在目标进程内执行。```sh
cargo run --example inject --features inject,stack-spoof,veh -- <sc.bin>
添加 `--verbose` 以启用 VEH 诊断输出(堆栈转储、寄存器打印):```sh
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
inject_sapc 对目标线程进行排名。最强的排名信号是线程是否拥有一个窗口:
EnumWindows + GetWindowThreadProcessId。GUI 线程在其消息循环中停驻于可告警等待状态,因此排队的 APC 会首先在那里运行。+1000。NtQueryInformationThread(ThreadCycleTime);当运行版本上该类不可用时回退到 ThreadTimes(参见 examples/tprobe.rs)。归一化后累加。NtQueryInformationThread(ThreadTimes) —— 内核 + 用户时间。NtQueryInformationThread(ThreadSuspendCount)。被挂起的线程无法运行 APC:每次挂起计数 −2000。NtQueryInformationThread(ThreadBasicInformation)。优先级 8–10 获得 +150;超出范围的优先级获得 −300。过滤。 被挂起的线程以及停驻在不可告警内核等待中的线程 —— WrDelayExecution(Sleep/NtDelayExecution)或 WrSuspended,从 NtQuerySystemInformation(SystemProcessInformation) 读取 —— 会被直接丢弃(它们永远无法接受 APC)。等待原因在其他情况下仅用于诊断:它会按候选线程记录为 State: / Reason: / Alert:,但不会影响评分,因为 KWAIT_REASON 并不编码可告警性(可告警和不可告警的对象等待报告相同的值 —— 已通过 examples/tprobe.rs 风格的探测验证)。
候选线程会被排序(最高分优先,平局时先 GUI 后周期)并截断至 MAX_APC_CANDIDATES(10)用于枚举/日志记录。
枚举 ≠ 注入。 列出的线程数与注入的线程数是解耦的。
MAX_APC_CANDIDATES(10)控制有多少线程被排名和记录;APC_INJECT_THREADS(5)控制实际有多少线程接收 APC。为什么要注入多个线程然后重试: 用户模式无法判断哪个线程处于可告警等待中,而单个选定的线程往往永远不会进入该状态,因此 APC 永远不会触发。
inject_sapc因此将 APC 排队到多个线程,然后在短循环(约 80 毫秒)中重新排队,直到共享门控字从 0 翻转为 1 —— 即直到某个目标线程实际运行了载荷。它会记录是否发生了这种情况:payload claimed the gate(已运行)对比gate NOT claimed。为什么只运行一次: 相同的 shellcode/上下文被排队到每个线程,因此如果没有保护,非可重入载荷会并发运行并使主机崩溃(实测:3 次同时执行)。可重入门控(
USE_REENTRANCY_GATE,默认开启)使载荷主体恰好运行一次,无论有多少线程(或多少轮重试)接受了 APC(实测:恰好 1 次)。除非你的载荷明确是并发安全的,否则请保持门控开启。
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);
### 为任意 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()) };
// 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);
### 使用栈欺骗 + 参数加密包装敏感系统调用```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();
并非每个 API 调用都需要完整的 LACUNA 处理。关键原则是:隐藏那些会被 EDR 关联为注入或后渗透活动的调用。
这些是 EDR 会挂钩并关联的“皇冠明珠”级 NT 系统调用:
| 调用 | 为何敏感 |
|---|---|
NtOpenProcess | 打开另一个进程的句柄——注入的第一步 |
NtCreateSection + NtMapViewOfSection(远程) | 基于节的注入特征 |
NtWriteVirtualMemory | 跨进程写入——经典注入指标 |
NtCreateThreadEx | 远程线程创建——信号最强的注入原语 |
NtQueueApcThread | APC 注入——高信号 |
NtProtectVirtualMemory | RWX 权限变更——shellcode 暂存指标 |
NtAllocateVirtualMemory(远程) | 远程分配——注入前奏 |
NtSetInformationThread | 线程隐藏(HideFromDebugger)——规避指标 |
对于其中每一个:
nt::resolve(ntdll, b"NtXxx\0") 解析stub::make_stub(ssn, syscall_ret) 生成桩veh::pcrypt_arm(key, syscall_ret, true) 对敏感参数进行 XOR 加密chain::stomp_plant() 与 chain::stomp_restore() 之间| 调用 | 为何安全 |
|---|---|
NtQueryInformationThread | 仅查询,很少被挂钩,无跨进程写入 |
NtDelayExecution | 睡眠——良性,每个应用程序都在使用 |
NtClose | 句柄关闭——良性,极其常见 |
GetModuleHandleA / GetProcAddress | 模块解析——不是系统调用,无法被 EDR 用户态钩子挂钩 |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | 线程枚举——kernel32,不是 ntdll 系统调用 |
OpenThread / CloseHandle | 标准句柄操作——kernel32 |
GetThreadContext / SetThreadContext | DR0 所需——kernel32,仅限自身进程 |