
Call stack spoofing for Rust
Unwinder는 SilentMoonWalk 기법의 완전한 무기화(weaponization)를 제공하여 Rust에서 완전하고 안정적인 호출 스택 스푸핑(call stack spoofing)을 달성할 수 있게 합니다.
이 기법은 다음과 같은 특징을 가집니다:
SilentMoonWalk 기법을 만든 이들에게 감사를 전합니다:
그리고 물론 이 프로젝트 전체에 영감을 준 Twitter 스레드에 대해 namazso에게 큰 감사를 보냅니다.
프로젝트에 이 크레이트를 가져오려면 cargo.toml에 다음 줄을 추가하고 release 모드로 컴파일하세요:
[dependencies]
unwinder = "=0.1.4"
이 크레이트의 주요 기능은 두 개의 매크로로 래핑되어 있습니다:
call_function!() 매크로는 깨끗한 호출 스택으로 임의의 함수를 실행할 수 있게 합니다.indirect_syscall!() 매크로는 깨끗한 호출 스택으로 지정된 (간접) 시스템 콜을 실행합니다.이 매크로들을 사용하려면 std::ffi::c_void 데이터 타입을 가져와야 합니다.
두 매크로 모두 실행된 함수가 반환한 값을 검색하는 데 사용할 수 있는 *mut c_void를 반환합니다. 자세한 내용은 예제 섹션을 참조하세요.
이 매크로는 깨끗한 호출 스택으로 원하는 함수를 호출하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:
usize, isize 또는 포인터로 전달해야 합니다.이 매크로는 깨끗한 호출 스택으로 원하는 간접 시스템 콜을 수행하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:
이 두 매크로에 다양한 타입의 인수를 전달하려면 다음 사항을 고려해야 합니다:
usize로 변환할 수 있는 모든 기본 데이터 타입(u8-u64, i8-i64, bool 등)은 매크로에 직접 전달할 수 있습니다.&str 및 String)은 포인터로 전달해야 합니다.ptr::null(), ptr::null_mut() 등)는 0으로 전달됩니다(u8, u16, i32 또는 다른 타입이든 상관없습니다).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);
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가 반환한 값, 즉 대상 프로세스에 대한 새 핸들에 접근할 수 있습니다.
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 값을 검색할 수 있습니다.
스푸핑 프로세스는 비정상적인 호출 스택 크기 증가 없이 몇 번이든 연결할 수 있습니다. 실행 흐름도 보존됩니다. 다음 코드는 이에 대한 예입니다:
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의 프레임에서 시작됩니다.

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

이 기법은 SilentMoonWalk의 대안이 되는 호출 스택 스푸핑 방식으로, 프로그램 실행 중에 깨끗한 호출 스택을 유지할 수 있게 합니다. 이 기법의 기본 아이디어는 모듈 내부에서 호출되는 각 함수가 이전에 푸시된 반환 주소를 처리하고, 런타임에 스푸핑할 반환 주소와 동일한 프레임 크기를 가진 합법적인 함수를 찾는 것입니다. 동일한 프레임 크기의 정상적인 함수를 찾으면 그 함수 내부의 오프셋을 계산하고, 최종 주소를 사용하여 마지막 반환 주소를 교체하여 호출 스택에서 비정상적인 항목을 숨기고 스택을 계속 해제(unwind) 가능하게 유지합니다. 원래 반환 주소는 unwinder에 저장되며, return 명령이 실행되기 전에 스택의 올바른 위치로 다시 이동되어 프로그램의 정상적인 흐름을 계속할 수 있습니다.
이 기능은 완전히 작동하지만 여전히 개발 및 연구 중인 실험적 기능입니다. 따라서 이 기법을 코드에 통합하려면 반드시 테스트를 수행하세요.
스택 교체 기능을 사용하려면 cargo.toml에 다음 줄을 추가하고 release 모드로 컴파일해야 합니다:
[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 매크로 쌍과 여기에 기대되는 인수를 전달하는 방법을 간단히 살펴보겠습니다.
이 매크로는 스택 교체를 사용하면서 깨끗한 호출 스택으로 현재 모듈 외부의 원하는 함수를 호출하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:
usize, isize 또는 포인터로 전달해야 합니다.이 매크로는 스택 교체를 사용하면서 깨끗한 호출 스택으로 원하는 간접 시스템 콜을 수행하는 데 사용됩니다. 매크로는 다음 매개변수를 기대합니다:
이 매크로들이 어떻게 사용되는지 보여주는 가장 좋은 방법은 실용적인 예제를 통해서라고 생각합니다. 메모리에 반사적으로 주입(reflectively injected) 될 dll을 만들고 있다고 가정해 봅시다. 이 dll은 ExportA와 ExportB 두 함수를 내보낼 것이므로, 이 두 함수를 모듈의 진입점으로 간주하겠습니다. 두 함수 모두 시작 부분에서 start_stack_replacement 매크로를 호출해야 하며, 반환하기 전에 역방향인 end_replacement 매크로도 호출해야 합니다. start_stack_replacement 매크로는 인수로 모듈의 기본 주소를 기대합니다. 런타임에 해당 주소를 모를 경우 0을 전달할 수 있으며, 매크로는 스스로 알아내려고 시도합니다.
#[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 매크로 사이에 감싸는 것을 포함합니다:
#[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(간접 시스템 콜) 매크로를 통해 수행해야 합니다.
#[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
}
이 기능은 개발 중인 기능이므로 몇 가지 사항을 고려해야 합니다:
start_stack_replace 매크로에 모듈의 기본 주소를 전달해야 합니다. 현재는 스스로 찾을 수 없습니다(다음 업데이트에서 해결 예정).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개의 인수를 허용합니다.이 기능을 사용할 때 발생할 수 있는 버그를 신고해 주시기 바랍니다.