Karkas66/lacuna-rs
GitHub: Karkas66/lacuna-rs
将 LACUNA Chain 的 ghost-frame 调用栈欺骗与间接 syscall 技术移植为 Rust 库,帮助红队工具绕过 EDR 的调用栈检测。
Stars: 17 | Forks: 2
# lacuna-rs
**适用于 Windows x64 的 Ghost-frame 调用栈欺骗 + 运行时间接 syscalls —— 移植至 Rust。**
由 Mohamed Alzhrani (0xmaz) 编写的 [LACUNA Chain](https://github.com/MazX0p/LACUNA-Chain) (`lacuna_chain.c`) 移植而来。
`lacuna-rs` 是一个可复用的 Rust crate,提供了与原始 C TTP 相同的原语,并进行了结构化处理,使其可以直接集成到任何 Rust 项目中——就像 `wsyscall-rs` 或 `syscalls-rs` 一样,但增加了**运行时 SSN 解析**、**针对每个函数的 `syscall;ret` 定位**以及 **ghost-frame 栈欺骗**。
## 关键:帧指针要求
`chain.rs` 中的栈覆写(stack-stomping)原语通过 `mov rbp, {x}` 内联汇编定位调用者的返回地址槽。这要求 RBP 链保持完整。Rust(以及大多数 release 模式的编译器)默认会省略帧指针。
**`build.rs` 会为本 crate 自身的代码生成设置 `force-frame-pointers=yes`,但 Cargo 无法将编译器标志传递给下游 crate。** 你必须将以下内容添加到你自己的项目中:
```
# .cargo/config.toml(在你的 crate 中,而不是在 lacuna-rs 中)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
```
如果没有这样做,`stomp_plant()` 将从 RBP 中读取到垃圾数据,要么不起作用(最好的情况),要么破坏栈(最坏的情况)。该 crate 无法在运行时检测是否启用了帧指针——它根本无法工作。
如果你只需要扫描、SSN 解析或注入原语(不需要栈欺骗),你可以省略 `stack-spoof` 特性,此要求即不适用。
## 功能说明
| 原语 | C 函数 | Rust 模块 |
|---|---|---|
| PE 节区 + 导出解析 | `pe_section()`, `pe_export()` | `pe` |
| `.pdata` 幽灵区域扫描 | `scan_ghosts()`, `best_ghost()` | `scan` |
| Ghost-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 间接 syscall stub 生成 | `alloc_stub()` | `stub` |
| Ghost-gadget stub 重定向 | (位于 `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 flags
```
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
```
| Feature | 描述 | 需要帧指针? |
|---|---|---|
| `syscalls` (默认) | SSN 解析 + JIT stub 生成 | 否 |
| `inject` (默认) | 基于节区的 APC 注入 (`inject::inject_sapc`) | 否 |
| `veh` | VEH + 硬件断点参数加密 | 否 |
| `stack-spoof` | LACUNA ghost-frame 链 + 栈覆写 | **是** |
| `no-std` | `no_std` 模式 (实验性) | 否 |
当没有启用任何特性时,仅扫描/PE/NT 层可用。
## 快速开始
### 扫描幽灵区域
```
cargo run --example scan
```
### 构造 + 验证 ghost-frame 链
```
cargo run --example verify --features stack-spoof
```
### 通过节区 + APC 注入 shellcode

使用真实 implant 的注入示例 - C2 处于离线状态,但 Shellcode 已执行
```
cargo run --example inject --features inject,stack-spoof,veh --
```
添加 `--verbose` 以启用 VEH 诊断输出(栈转储,寄存器打印):
```
cargo run --example inject --features inject,stack-spoof,veh -- --verbose
```
#### 线程评分算法
与其向目标进程中的每个线程排队 APC(当太多线程同时收到通知时,可能会导致进程崩溃),`inject_sapc` 使用了一种评分算法来选择最佳的 `MAX_APC_THREADS` (5) 个候选者:
- **周期时间** — `NtQueryInformationThread(ThreadCycleTime)` — 主要活动指标
- **CPU 时间** — `NtQueryInformationThread(ThreadTimes)` — 内核 + 用户时间
- **挂起计数** — `NtQueryInformationThread(ThreadSuspendCount)` — 未挂起的线程获得 +300 加成;挂起的线程每次挂起计数扣除 150
- **优先级** — `NtQueryInformationThread(ThreadBasicInformation)` — 优先级为 8-10 的线程获得 +150 加成;超出范围的优先级受到 -100 惩罚
完全空闲的线程(周期时间和 CPU 时间均为零)将被完全跳过。
其余候选者按分数排序(分数最高者优先,分数相同者按周期数决胜)
并截断至前 5 名。
## 作为库使用
### 基础:扫描 + SSN 解析
```
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 生成间接 syscall stub
```
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()) };
```
### 构建 ghost-frame 链 + 注册 VEH
```
// Scan ntdll/kernelbase/wow64/win32u for ghost regions and construct
// the six-layer fake call stack. Sets G_GHOST_GADGET so stubs route
// through JMP [RBX] in a signed DLL.
lacuna::chain::build_chain();
// Register VEH handlers (param encryption + chain guard)
let _veh = lacuna::veh::VehGuard::register().expect("VEH registration failed");
// Optional: enable verbose diagnostics at runtime
lacuna::veh::set_verbose(true);
```
### 使用栈欺骗 + 参数加密包装敏感 syscall
```
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:什么应该隐藏在 ghost frames 之后
并非每个 API 调用都需要完整的 LACUNA 处理。核心原则是:**隐藏那些会被 EDR 关联为注入或后渗透活动的调用**。
### 必须位于间接 syscall + ghost frames + 参数加密之后
这些是 EDR 会进行 hook 和关联的“皇冠上的明珠”级别的 NT syscalls:
| 调用 | 敏感原因 |
|---|---|
| `NtOpenProcess` | 打开指向另一个进程的句柄 —— 注入的第一步 |
| `NtCreateSection` + `NtMapViewOfSection` (远程) | 基于节区的注入特征 |
| `NtWriteVirtualMemory` | 跨进程写入 —— 经典的注入指示器 |
| `NtCreateThreadEx` | 远程线程创建 —— 最高信号的注入原语 |
| `NtQueueApcThread` | APC 注入 —— 高信号 |
| `NtProtectVirtualMemory` | RWX 权限更改 —— shellcode 暂存指示器 |
| `NtAllocateVirtualMemory` (远程) | 远程分配 —— 注入前奏 |
| `NtSetInformationThread` | 线程隐藏 (`HideFromDebugger`) —— 规避指示器 |
对于以上每一项:
1. 使用 `nt::resolve(ntdll, b"NtXxx\0")` 进行解析
2. 使用 `stub::make_stub(ssn, syscall_ret)` 生成 stub
3. 使用 `veh::pcrypt_arm(key, syscall_ret, true)` 对敏感参数进行 XOR 加密
4. 将调用包裹在 `chain::stomp_plant()` 和 `chain::stomp_restore()` 之间
### 可以是直接的(不需要 ghost frames)
| 调用 | 安全原因 |
|---|---|
| `NtQueryInformationThread` | 仅查询,极少被 hook,无跨进程写入 |
| `NtDelayExecution` | 睡眠 —— 良性的,每个应用程序都会使用 |
| `NtClose` | 句柄关闭 —— 良性的,极其常见 |
| `GetModuleHandleA` / `GetProcAddress` | 模块解析 —— 不是 syscall,无法被 EDR 用户态 hook |
| `CreateToolhelp32Snapshot` / `Thread32First` / `Thread32Next` | 线程枚举 —— kernel32,不是 ntdll syscall |
| `OpenThread` / `CloseHandle` | 标准句柄操作 —— kernel32 |
| `GetThreadContext` / `SetThreadContext` | DR0 所需 —— kernel32,仅限自身进程 |
### 核心原则
1. **解析** 任何 NT syscall 使用 `nt::resolve(ntdll, b"NtXxx\0")` —— 返回 `(SSN, syscall_ret_VA)`。
2. **生成** JIT stub 使用 `stub::make_stub(ssn, syscall_ret)` —— 返回可调用的函数指针。
3. **转换** stub 通过 `core::mem::transmute` 转换为适当的 `extern "system" fn(...)` 类型。
4. **(仅针对敏感调用)** 使用 `chain::build_chain()` 构建 ghost-frame 链,并在调用前后使用 `chain::stomp_plant()` / `chain::stomp_restore()`。
5. **(仅针对敏感调用)** 在调用前使用 `veh::pcrypt_arm(key, syscall_ret, true)` 启用参数加密,并在调用后使用 `veh::pcrypt_disarm()`。
6. **始终禁用** VEH,然后再进行非保护调用 —— 在未启用的 syscall 上发生杂散的 DR0 命中会导致崩溃。
7. **所有输出字符串** 必须包裹在 `lc!()` 中 —— crate 强制要求这一点,并且 VEH 诊断受 `set_verbose(true)` 控制,默认关闭。
stub 会自动处理 `mov r10, rcx` / `mov eax, SSN` / `jmp [syscall;ret]` 序列。如果注册了 ghost gadget,stub 将通过 `JMP [RBX]` 进行路由,实现双重用途的执行重定向 + 零特征桥接帧。
## 文章覆盖范围验证
[LACUNA Chain 文章](https://0xmaz.me/posts/LACUNA-Chain-Ghost-Frames-defeats-All-EDR-layers-of-call-stack-based-detection/)中的全部 9 项关键贡献均已在代码中体现:
| # | 文章概念 | 代码位置 | 描述 |
|---|---|---|---|
| 1 | **BYOUD-Gap** (零 `.pdata` 修改) | `chain.rs` -- ghost frame 链构造 | 利用 `RUNTIME_FUNCTION` 条目之间的间隙;展开器将它们视为叶帧 (RSP += 8) |
| 2 | **ETW-Ti APC 窗口攻击** | `inject.rs` -- `NtDelayExecution` 可警告状态耗尽 | 通过操纵线程的可警告状态,控制 ETW-Ti APC 栈快照何时触发 |
| 3 | **BYOUD 上下文中的参数加密** | `veh.rs` -- `pcrypt_arm()`, `param_encrypt_veh()` | 在暂存时对 syscall 参数进行 XOR 加密;在 `syscall` 指令处的硬件断点 VEH 内进行解密 |
| 4 | **Win32u NOP 间隙链 + Ghost Gadget** | `scan.rs` -- `win32u_nop_gap()`, `scan_ghost_gadgets()` | `win32u.dll` 中的 1,242 个 NOP 间隙提供了白名单叶帧;位于 `ntdll+0xFC47B` 处的 `JMP [RBX]` ghost gadget |
| 5 | **kernelbase 语义 Ghost 邻近** | `chain.rs` -- 靠近 `VirtualProtect` 的 `L2_kbase` 层 | 在 `VirtualProtect` 入口点结束的 231 字节 ghost frame —— 与真实的 VP 返回站点无法区分 |
| 6 | **BYOUD-MF (机器帧 RSP 传送)** | `chain.rs` -- `MachFrame` 结构体, `scan.rs` -- `find_mf_target()` | 利用 `KiUser*` 分发器中的 `UWOP_PUSH_MACHFRAME` (操作码 10),在单帧中实现任意 RSP 传送 |
| 7 | **BYOUD-RT (运行时 RSP 计算)** | `chain.rs` -- `teb_stack_base()`, `teb_stack_limit()` | 在调用时读取 `TEB.StackBase` (GS:[0x08]) 以计算精确的帧距离 —— 无需预先校准 |
| 8 | **wow64.dll Ghost 邻近** | `chain.rs` -- `L1_wow64` 层, `scan.rs` -- 目标 `Wow64PrepareForException` | 在 `Wow64PrepareForException` 入口处结束的 91 字节 ghost frame —— 链的第四个语义层 |
| 9 | **六层 LACUNA 链** | `chain.rs` -- `LacunaStack` 结构体 (L1-L5 + MachFrame) | 完整链:`KiUserExceptionDispatcher` -> wow64 -> kernelbase -> ntdll -> win32u -> `RtlUserThreadStart` |
### 检测面
| 检测层 | 状态 | 实现 |
|---|---|---|
| 来源模块检查 | **规避** | 所有帧均位于 ntdll / kernelbase / wow64 / win32u 中 |
| 展开遍历正确性 | **规避** | 所有 lacuna 帧均为叶帧 -> 有效的 RSP+8 |
| `.pdata` 取证扫描 | **规避** | 零修改;间隙是预先存在的 |
| CET 影子栈 | **规避** | 纯叶链;不查询影子栈 |
| 语义帧分析 | **规避** | WoW64 异常 + VirtualProtect 邻接 |
| Win32u 规则豁免 | **规避** | 第 4 层被所有规则明确排除 |
| ETW-Ti STACKWALK | **规避** | APC 窗口攻击控制快照时机 |
| 参数检查 | **规避** | 硬件断点 VEH 解密 |
| 内核回调 | **部分** | 句柄操作仍会触发 `ObRegisterCallbacks` |
## 为什么不直接使用 `syscalls-rs`?
`syscalls-rs` 和 `wsyscall-rs` 根据特定 Windows 版本的 ntdll 生成**编译时 SSN 表**。如果目标机器运行的是不同的版本,SSN 将是错误的,syscall 会失败(或触发 EDR 启发式检测)。
`lacuna-rs` 通过直接读取 ntdll 的 stubs,在**运行时**解析 SSN,并定位到**函数自身的 `syscall;ret` 指令**,从而确保在内核入口处 RIP 位于 ntdll 内部——击败了 EDR 的“SSN 不匹配”和“来自无后备内存的间接 syscall”启发式检测。
此外,`lacuna-rs` 提供了 `syscalls-rs` 不具备的 **ghost-frame 栈欺骗**链。
## 构建要求
- **目标:** `x86_64-pc-windows-msvc` (或 `x86_64-pc-windows-gnu`)
- **Rust 版本:** 2021
- **依赖项:** `litcrypt2` (编译时字符串混淆)
### 帧指针设置(仅针对 `stack-spoof`)
`stack-spoof` 特性需要帧指针。`build.rs` 会自动为本 crate 设置 `force-frame-pointers=yes`,但**消费方 crate**也必须在其 `.cargo/config.toml` 中进行设置:
```
# .cargo/config.toml(在 consuming crate 中)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
```
### litcrypt2 字符串混淆
crate 和示例中的所有字符串字面量都包裹在 `lc!()` 宏中,这会在编译时对它们进行加密,并在运行时进行解密。加密密钥从 `LITCRYPT_ENCRYPT_KEY` 环境变量中读取;如果未设置,litcrypt2 会自动生成一个随机密钥。
要为可重现构建固定密钥:
```
set LITCRYPT_ENCRYPT_KEY=your-secret-key
cargo build
```
## 架构
```
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
```
## 完全披露
本项目得到了 AI 聊天机器人的积极协助。我有机会测试了一个全新的、非常强大的开源模型(由 Z.ai (Zhipu AI) 开发的 GLM-5.2),并想给它一个具有挑战性的任务。
经验教训:如果人类知道结果应该是什么样子,它就能很好地工作。如果不知道……那就只能产出垃圾了。
## 许可证
MIT
标签:DNS 反向解析, Hpfeeds, Rust, 免杀技术, 可视化界面, 底层开发, 暴力破解检测, 系统调用, 网络流量审计, 通知系统