Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
Unwinder — Call stack spoofing for Rust | Kitploit
도구/GitHubGitHub/kudaes/unwinder
IDS/IPS EvasionPost-ExploitationRed TeamingPayload DevelopmentAdversarial Attack
GitHubkudaes/unwinder

Unwinder

Call stack spoofing for Rust

저장소 보기
384361년 전Kitploit 검토 완료

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

목차

  • SilentMoonWalk
    • 설명
    • 크레딧
    • 사용법
      • call_function!() 매크로
      • indirect_syscall!() 매크로
      • 매개변수 전달
    • 예제
      • kernel32.dll!Sleep() 호출
      • kernel32.dll!OpenProcess() 호출
      • 간접 시스템 콜로 NtDelayExecution() 호출
      • 매크로 호출 연결
    • 고려 사항
      • 초기 프레임
      • PoC
  • 스택 교체
    • 설명
    • 사용법
    • 실용 예제
    • 참고 사항

SilentMoonWalk

설명

Unwinder는 SilentMoonWalk 기법의 완전한 무기화(weaponization)를 제공하여 Rust에서 완전하고 안정적인 호출 스택 스푸핑(call stack spoofing)을 달성할 수 있게 합니다.

이 기법은 다음과 같은 특징을 가집니다:

  • 최대 11개의 매개변수를 사용하여 임의의 함수를 실행할 수 있습니다.
  • 최대 11개의 매개변수를 사용하여 간접 시스템 콜(indirect syscall)을 실행할 수 있습니다(추가 힙 할당 없음).
  • 이 크레이트는 이를 통해 호출된 함수가 반환하는 값을 검색할 수 있게 합니다.
  • 스푸핑 프로세스는 호출 스택 크기를 늘리지 않고 여러 번 연결할 수 있습니다.
  • 스푸핑 프로세스의 효율성을 높이기 위해 TLS가 사용됩니다.
  • 크레이트에 필요한 Windows API 호출을 위해 dinvoke_rs가 사용됩니다.

크레딧

SilentMoonWalk 기법을 만든 이들에게 감사를 전합니다:

  • KlezVirus
  • Waldo-IRC
  • Trickster0

그리고 물론 이 프로젝트 전체에 영감을 준 Twitter 스레드에 대해 namazso에게 큰 감사를 보냅니다.

사용법

프로젝트에 이 크레이트를 가져오려면 cargo.toml에 다음 줄을 추가하고 release 모드로 컴파일하세요:

root@kitploit:~
[dependencies]
unwinder = "=0.1.4"

이 크레이트의 주요 기능은 두 개의 매크로로 래핑되어 있습니다:

  • call_function!() 매크로는 깨끗한 호출 스택으로 임의의 함수를 실행할 수 있게 합니다.
  • indirect_syscall!() 매크로는 깨끗한 호출 스택으로 지정된 (간접) 시스템 콜을 실행합니다.

이 매크로들을 사용하려면 std::ffi::c_void 데이터 타입을 가져와야 합니다.

두 매크로 모두 실행된 함수가 반환한 값을 검색하는 데 사용할 수 있는 *mut c_void를 반환합니다. 자세한 내용은 예제 섹션을 참조하세요.

call_function 매크로

이 매크로는 깨끗한 호출 스택으로 원하는 함수를 호출하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:

  • 첫 번째 매개변수는 호출 스택을 스푸핑한 후 호출할 메모리 주소입니다. 이 매개변수는 usize, isize 또는 포인터로 전달해야 합니다.
  • 두 번째 매개변수는 시작 함수 프레임을 유지할지 여부를 나타내는 bool입니다. 확실하지 않으면 false로 설정하세요. 그러면 항상 좋은 호출 스택이 보장됩니다.
  • 그 다음 매개변수들은 호출 스택이 스푸핑된 후 함수에 전달할 인수들입니다.

indirect_syscall 매크로

이 매크로는 깨끗한 호출 스택으로 원하는 간접 시스템 콜을 수행하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:

  • 첫 번째 매개변수는 시스템 콜을 실행하려는 NT 함수의 이름을 포함하는 문자열입니다.
  • 두 번째 매개변수는 시작 함수 프레임을 유지할지 여부를 나타내는 bool입니다. 확실하지 않으면 false로 설정하세요. 그러면 항상 좋은 호출 스택이 보장됩니다.
  • 그 다음 매개변수들은 NT 함수에 전달할 인수들입니다.

매개변수 전달

이 두 매크로에 다양한 타입의 인수를 전달하려면 다음 사항을 고려해야 합니다:

  • usize로 변환할 수 있는 모든 기본 데이터 타입(u8-u64, i8-i64, bool 등)은 매크로에 직접 전달할 수 있습니다.
  • 크기가 8, 16, 32, 64비트인 구조체(struct)와 공용체(union)는 동일한 크기의 정수처럼 전달됩니다.
  • 64비트보다 큰 크기의 구조체와 공용체는 포인터로 전달해야 합니다.
  • 문자열(&str 및 String)은 포인터로 전달해야 합니다.
  • 널 포인터(ptr::null(), ptr::null_mut() 등)는 0으로 전달됩니다(u8, u16, i32 또는 다른 타입이든 상관없습니다).
  • 부동소수점 및 배정밀도(double-precision) 매개변수는 현재 지원되지 않습니다.
  • 다른 모든 데이터 타입은 포인터로 전달해야 합니다.

예제

Sleep 호출

root@kitploit:~
let k32 = dinvoke_rs::dinvoke::get_module_base_address("kernel32.dll");
let sleep = dinvoke_rs::dinvoke::get_function_address(k32, "Sleep"); // Memory address of kernel32.dll!Sleep() 
let miliseconds = 1000i32;
unwinder::call_function!(sleep, false, miliseconds);

OpenProcess 호출

root@kitploit:~
let k32 = dinvoke_rs::dinvoke::get_module_base_address("kernel32.dll"); 
let open_process: isize = dinvoke_rs::dinvoke::get_function_address(k32, "Openprocess");
let desired_access: u32 = 0x1000;
let inherit = 0i32;
let pid = 20628i32;
let handle = unwinder::call_function!(open_process, false, desired_access, inherit, pid); // returns *mut c_void
let handle: HANDLE = std::mem::transmute(handle);
println!("Handle id: {:x}", handle.0);

매크로는 *mut c_void를 반환하며, 두 데이터 타입의 크기가 같으므로 이를 HANDLE로 직접 변환할 수 있습니다. 이를 통해 OpenProcess가 반환한 값, 즉 대상 프로세스에 대한 새 핸들에 접근할 수 있습니다.

간접 시스템 콜로 NtDelayExecution 호출

root@kitploit:~
let large = 0x8000000000000000 as u64; // Sleep indefinitely
let large: *mut i64 = std::mem::transmute(&large);
let alertable = false;
let ntstatus = unwinder::indirect_syscall!("NtDelayExecution", false, alertable, large); // returns *mut c_void
println!("ntstatus: {:x}", ntstatus as i32);

매크로는 *mut c_void를 반환하며, 이를 사용하여 NtDelayExecution이 반환한 NTSTATUS 값을 검색할 수 있습니다.

매크로 호출 연결

스푸핑 프로세스는 비정상적인 호출 스택 크기 증가 없이 몇 번이든 연결할 수 있습니다. 실행 흐름도 보존됩니다. 다음 코드는 이에 대한 예입니다:

root@kitploit:~
fn main()
{
	function_a();
}

fn function_a()
{
	unsafe
	{
		let func_b = function_b as usize;
		call_function!(func_b, false);
		println!("function_a done.");
	}
}

fn function_b()
{
	unsafe
	{
		let func_c = function_c as usize;
		call_function!(func_c, false);
		println!("function_b done.")
	}
}

fn function_c()
{
	unsafe
	{
		let large = 0x0000000000000000 as u64; // Don't sleep so we return to function_b, allowing to check the execution flow preservation.
		let large: *mut i64 = std::mem::transmute(&large);
		let alertable = false;
		let ntstatus = unwinder::indirect_syscall!("NtDelayExecution", false, alertable, large);
		println!("ntstatus: {:x}", (ntstatus as usize) as i32); //NTSTATUS is a i32, although that second casting is not really required in this case.
	}
}

고려 사항

초기 프레임

두 매크로 모두 두 번째 매개변수를 true로 설정하면, 스푸핑 프로세스는 합법성을 높이기 위해 스레드 시작 주소의 프레임을 호출 스택에 유지하려고 시도합니다.

주 모듈을 유지한 채 스푸핑된 호출 스택.

때때로 스레드의 시작 함수는 후속 함수에 대한 call을 수행하지 않습니다(예: 대신 jmp 명령이 실행됨). 즉, 스택에 반환 주소가 푸시되지 않습니다. 이 시나리오에서는(그리고 두 번째 매개변수를 false로 설정한 경우에도) 스푸핑된 호출 스택은 BaseThreadInitThunk의 프레임에서 시작됩니다.

주 모듈 없이 스푸핑된 호출 스택.

PoC

기법의 구현을 테스트하기 위해 /threads 플래그와 함께 PE-sieve가 사용되었습니다. 테스트 결과는 이 크레이트의 기능을 사용할 때 호출 스택 검사가 페이로드의 존재를 드러내지 않음을 보여줍니다. 두 번째 이미지에서 볼 수 있듯이 unwinder를 사용하지 않으면 페이로드가 탐지됩니다.

unwinder를 사용한 경우의 PE-sieve 결과. unwinder를 사용하지 않은 경우의 PE-sieve 결과.

스택 교체

기법 설명

이 기법은 SilentMoonWalk의 대안이 되는 호출 스택 스푸핑 방식으로, 프로그램 실행 중에 깨끗한 호출 스택을 유지할 수 있게 합니다. 이 기법의 기본 아이디어는 모듈 내부에서 호출되는 각 함수가 이전에 푸시된 반환 주소를 처리하고, 런타임에 스푸핑할 반환 주소와 동일한 프레임 크기를 가진 합법적인 함수를 찾는 것입니다. 동일한 프레임 크기의 정상적인 함수를 찾으면 그 함수 내부의 오프셋을 계산하고, 최종 주소를 사용하여 마지막 반환 주소를 교체하여 호출 스택에서 비정상적인 항목을 숨기고 스택을 계속 해제(unwind) 가능하게 유지합니다. 원래 반환 주소는 unwinder에 저장되며, return 명령이 실행되기 전에 스택의 올바른 위치로 다시 이동되어 프로그램의 정상적인 흐름을 계속할 수 있습니다.

스택 교체

이 기능은 완전히 작동하지만 여전히 개발 및 연구 중인 실험적 기능입니다. 따라서 이 기법을 코드에 통합하려면 반드시 테스트를 수행하세요.

사용법

스택 교체 기능을 사용하려면 cargo.toml에 다음 줄을 추가하고 release 모드로 컴파일해야 합니다:

root@kitploit:~
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}

이 기능의 주요 기능은 다음 매크로들로 래핑되어 있습니다:

  • start_stack_replacement!()/end_replacement!() 매크로 쌍은 unwinder에게 스택 교체 프로세스를 시작/종료하도록 지시합니다. 이 두 매크로는 코드의 진입점(예: dll의 내보낸 함수)에서 호출해야 합니다.
  • replace_and_continue!()/restore!() 매크로 쌍은 마지막 반환 주소의 교체/복원을 수행합니다.
  • 마지막으로, replace_and_call!()/replace_and_syscall!() 매크로 쌍은 현재 모듈 외부의 함수를 호출하려 할 때(예: Windows API를 사용하거나 다른 dll의 코드를 호출할 때) 스택 교체를 수행하는 데 사용됩니다. 이 두 매크로 모두 이렇게 호출된 함수가 반환한 값을 담은 *mut c_void를 반환합니다(즉, SilentMoonWalk를 실행하는 데 사용되는 call_function 및 indirect_syscall 매크로에 대해 설명된 것과 동일한 방식으로 작동합니다).

이 매크로들을 사용하려면 std::ffi::c_void 데이터 타입을 가져와야 합니다. 이 매크로들을 사용하는 모든 함수는 최적화 과정에서 rust 컴파일러가 인라인하지 못하도록 #[no_mangle] 또는 #[inline(never)] 속성으로 표시해야 합니다.

이 모든 것을 사용하는 방법을 보여주는 실용적인 예제를 살펴보기 전에, replace_and_call/replace_and_syscall 매크로 쌍과 여기에 기대되는 인수를 전달하는 방법을 간단히 살펴보겠습니다.

replace_and_call

이 매크로는 스택 교체를 사용하면서 깨끗한 호출 스택으로 현재 모듈 외부의 원하는 함수를 호출하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:

  • 첫 번째 매개변수는 호출할 함수의 메모리 주소입니다. 이 매개변수는 usize, isize 또는 포인터로 전달해야 합니다.
  • 그 다음 매개변수들은 지정된 함수에 전달할 인수들입니다. 이들은 매개변수 전달 섹션에 명시된 것과 동일한 규칙을 따릅니다.

replace_and_syscall

이 매크로는 스택 교체를 사용하면서 깨끗한 호출 스택으로 원하는 간접 시스템 콜을 수행하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:

  • 첫 번째 매개변수는 시스템 콜을 실행하려는 NT 함수의 이름을 포함하는 문자열입니다.
  • 그 다음 매개변수들은 NT 함수에 전달할 인수들입니다. 이들은 매개변수 전달 섹션에 명시된 것과 동일한 규칙을 따릅니다.

예제

이 매크로들이 어떻게 사용되는지 보여주는 가장 좋은 방법은 실용적인 예제를 통해서라고 생각합니다. 메모리에 반사적으로 주입(reflectively injected) 될 dll을 만들고 있다고 가정해 봅시다. 이 dll은 ExportA와 ExportB 두 함수를 내보낼 것이므로, 이 두 함수를 모듈의 진입점으로 간주하겠습니다. 두 함수 모두 시작 부분에서 start_stack_replacement 매크로를 호출해야 하며, 반환하기 전에 역방향인 end_replacement 매크로도 호출해야 합니다. start_stack_replacement 매크로는 인수로 모듈의 기본 주소를 기대합니다. 런타임에 해당 주소를 모를 경우 0을 전달할 수 있으며, 매크로는 스스로 알아내려고 시도합니다.

root@kitploit:~
#[no_mangle]
fn ExportedA(base_address: usize) -> bool
{
    unwinder::start_replacement!(base_address);
    ...
    unwinder::end_replacement!();

    true
}

#[no_mangle]
fn ExportedB() -> bool
{
    unwinder::start_replacement!(0);
    ...
    unwinder::end_replacement!();

    true
}

스택 교체 프로세스를 시작하려면 end_replacement 매크로가 호출될 때까지 사용될 새 스택을 수동으로 구성해야 합니다. 다음 그림은 내부에서 어떤 일이 일어나는지 보여줍니다:

스택 교체

이론적으로는 처음부터 새 스택을 시작할 필요가 없지만, 안정성을 보장하고 문제가 발생하는 것을 방지하기 위해 이 방식으로 프로세스를 구현하기로 결정했습니다.

이제 ExportedA 함수가 내부 함수 두 개를 여러 번 호출한다고 가정해 봅시다. 이 두 내부 함수는 ExportedA 내부의 어떤 위치를 가리킬 원래 반환 주소를 교체/복원하는 역할을 하며, 이를 처리하지 않으면 호출 스택이 깨집니다. 이 교체 프로세스는 내부 함수의 코드를 replace_and_continue와 restore 매크로 사이에 감싸는 것을 포함합니다:

root@kitploit:~
#[no_mangle]
fn ExportedA(base_address: usize) -> bool
{
    unwinder::start_replacement!(base_address);
    let ret_a = internal_a();
    let ret_b = internal_b(ret_a);
    unwinder::end_replacement!();

    ret_b
}

#[inline(never)] // This attribute is mandatory
fn internal_a() -> bool
{
    unwinder::replace_and_continue();
    ...
    unwinder::restore();
    
    some_value
} 

#[inline(never)] // This attribute is mandatory
fn internal_b(value: bool) -> bool
{
    unwinder::replace_and_continue();
    ...
    unwinder::restore();
    
    some_value
} 

마지막으로, internal_a와 internal_b 함수는 모두 일부 Windows API 기능을 사용합니다. 언와인드 가능한 호출 스택을 유지하려면 이러한 호출은 replace_and_call(일반 호출) 또는 replace_and_syscall(간접 시스템 콜) 매크로를 통해 수행해야 합니다.

root@kitploit:~
#[no_mangle] // This attribute is mandatory
fn ExportedA(base_address: usize) -> bool
{
    unwinder::start_replacement!(base_address);
    let ret_a = internal_a();
    let ret_b = internal_b(ret_a);
    unwinder::end_replacement!();

    ret_b
}

#[inline(never)] // This attribute is mandatory
fn internal_a() -> bool
{
    unwinder::replace_and_continue();
    ...
    let module_name = "advapi32.dll";
    let module_name = CString::new(module_name.to_string()).expect("");
    let module_name_ptr: *mut u8 = std::mem::transmute(module_name.as_ptr());
    let k32 = dinvoke_rs::dinvoke::get_module_base_address("kernel32.dll");
    let load_library = dinvoke_rs::dinvoke::get_function_address(k32, "LoadLibraryA");
    let ret = unwinder::replace_and_call!(load_library, module_name_ptr); // Load a dll with an unwindable call stack
    println!("advapi.dll base address: 0x{:x}", ret as usize);
    ...
    unwinder::restore();
    
    some_value
} 

#[inline(never)] // This attribute is mandatory
fn internal_b(value: bool) -> bool
{
    unwinder::replace_and_continue();
    ...
    let large = 0xFFFFFFFFFF676980 as u64; // Sleep one second
    let large: *mut i64 = std::mem::transmute(&large);
    let alertable = false;
    let ntstatus = unwinder::replace_and_syscall!("NtDelayExecution", alertable, large);
    println!("ntstatus: {:x}", ntstatus as usize);
    ...
    unwinder::restore();
    
    some_value
} 

참고 사항

이 기능은 개발 중인 기능이므로 몇 가지 사항을 고려해야 합니다:

  • 로딩 과정에서 PE 헤더를 제거하는 경우 start_stack_replace 매크로에 모듈의 기본 주소를 전달해야 합니다. 현재는 스스로 찾을 수 없습니다(다음 업데이트에서 해결 예정).
  • 궁금해할 수 있는데, 스택 교체는 SilentMoonWalk 기법과 동일한 jmp rbx + 은닉 프레임(concealment frame) 조합을 사용합니다. 이는 replace_and_call 및 replace_and_syscall 매크로를 사용할 때만 발생하며, 다음 업데이트에서 변경될 예정입니다.
  • replace_and_call 및 replace_and_syscall 매크로는 둘 다 이를 통해 실행된 함수가 반환한 값을 검색하는 데 사용할 수 있는 *mut c_void를 반환합니다. 이는 call_function 및 indirect_syscall 매크로에 대해 설명된 것과 동일한 동작입니다.
  • replace_and_call 및 replace_and_syscall 매크로는 최대 11개의 인수를 허용합니다.

이 기능을 사용할 때 발생할 수 있는 버그를 신고해 주시기 바랍니다.

도구 다운로드