
Трассировка стека вызовов для Rust
Unwinder предоставляет полноценную реализацию техники SilentMoonWalk, позволяющую добиться полного и стабильного спуфинга стека вызовов в Rust.
Данная техника обладает следующими особенностями:
Благодарность создателям техники SilentMoonWalk:
И, конечно, огромная благодарность namazso за тред в Twitter, который вдохновил весь этот проект.
Чтобы подключить этот крейт к вашему проекту, добавьте следующую строку в ваш cargo.toml и выполните сборку в режиме release:
[dependencies]
unwinder = "=0.1.4"
Основная функциональность этого крейта обёрнута в два макроса:
call_function!() позволяет выполнять любую произвольную функцию с чистым стеком вызовов.indirect_syscall!() выполняет указанный (непрямой) системный вызов с чистым стеком вызовов.Для использования любого из этих макросов необходимо импортировать тип данных std::ffi::c_void.
Оба макроса возвращают *mut c_void, который можно использовать для получения значения, возвращённого выполненной функцией. Более подробная информация приведена в разделе с примерами.
Этот макрос используется для вызова любой нужной функции с чистым стеком вызовов. Макрос ожидает следующие параметры:
usize, isize или указатель.bool), указывающее, нужно ли сохранять кадр стартовой функции. Если вы не уверены в этом, установите его в false — это всегда гарантирует корректный стек вызовов.Этот макрос используется для выполнения любого нужного непрямого системного вызова с чистым стеком вызовов. Макрос ожидает следующие параметры:
bool), указывающее, нужно ли сохранять кадр стартовой функции. Если вы не уверены в этом, установите его в false — это всегда гарантирует корректный стек вызовов.Чтобы передавать этим двум макросам аргументы различных типов, необходимо учитывать следующее:
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, который можно использовать для получения NTSTATUS, возвращённого функцией NtDelayExecution.
Процесс спуфинга может быть выстроен в цепочку любое количество раз без аномального увеличения размера стека вызовов. Поток выполнения при этом также сохраняется. Ниже приведён пример:
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.

Для тестирования реализации этой техники использовался PE-sieve с флагом /threads. Результаты теста показывают, что проверка стека вызовов не выявляет присутствия полезной нагрузки при использовании функциональности этого крейта. Как видно на втором изображении, нагрузка детектируется, когда unwinder не используется.

Это альтернативный SilentMoonWalk метод спуфинга стека вызовов, который позволяет сохранять чистый стек вызовов во время выполнения вашей программы. Основная идея этой техники заключается в том, что каждая вызываемая внутри вашего модуля функция берёт на себя заботу о ранее помещённом в стек обратном адресе, находя во время выполнения легитимную функцию с таким же размером кадра, что и у подменяемого обратного адреса. Как только такая функция с тем же размером кадра найдена, вычисляется смещение внутри неё, и итоговый адрес используется для замены последнего обратного адреса, скрывая любые аномальные записи в стеке вызовов и сохраняя его раскручиваемым. Исходный обратный адрес сохраняется unwinder и возвращается на нужную позицию в стеке перед выполнением инструкции возврата, что позволяет продолжить нормальный поток выполнения программы.
Это экспериментальная функция: несмотря на полную работоспособность, она всё ещё находится в стадии разработки и исследований, поэтому обязательно тестируйте свой код, если решите интегрировать в него эту технику.
Чтобы использовать функциональность подмены стека, добавьте следующую строку в ваш 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, содержащий значение, возвращённое вызванной таким образом функцией (то есть они работают так же, как описано для макросов call_function и indirect_syscall, используемых для выполнения SilentMoonWalk).Для использования этих макросов необходимо импортировать тип данных std::ffi::c_void.
Все функции, использующие любой из этих макросов, должны быть помечены атрибутами #[no_mangle] или #[inline(never)], чтобы компилятор Rust не встраивал их во время оптимизации.
Прежде чем перейти к практическому примеру, демонстрирующему использование всего этого, кратко рассмотрим пару макросов replace_and_call/replace_and_syscall и то, как передавать им ожидаемые аргументы.
Этот макрос используется для вызова любой нужной функции за пределами текущего модуля с чистым стеком вызовов при использовании подмены стека. Макрос ожидает следующие параметры:
usize, isize или указатель.Этот макрос используется для выполнения любого нужного непрямого системного вызова с чистым стеком вызовов при использовании подмены стека. Макрос ожидает следующие параметры:
Думаю, лучше всего показать, как используются эти макросы, на практическом примере. Предположим, мы создаём 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 + маскирующий кадр, что и техника SilentMoonWalk. Это происходит только при использовании макросов 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 аргументов.Пожалуйста, сообщайте мне о любых ошибках, которые могут возникнуть при использовании этой функции.