
Spoofing de pila de llamadas para Rust
Unwinder proporciona una weaponización completa de la técnica SilentMoonWalk, permitiendo obtener un spoofing completo y estable de la pila de llamadas en Rust.
Esta técnica presenta las siguientes características:
Reconocimientos a los creadores de la técnica SilentMoonWalk:
Y por supuesto, un enorme agradecimiento a namazso por el hilo de Twitter que inspiró todo este proyecto.
Importa este crate en tu proyecto añadiendo la siguiente línea a tu cargo.toml y compila en modo release:
[dependencies]
unwinder = "=0.1.4"
La funcionalidad principal de este crate se ha envuelto en dos macros:
call_function!() permite ejecutar cualquier función arbitraria con una pila de llamadas limpia.indirect_syscall!() ejecuta el syscall (indirecto) especificado con una pila de llamadas limpia.Para usar cualquiera de estas macros es necesario importar el tipo de datos std::ffi::c_void.
Ambas macros devuelven un *mut c_void que puede usarse para recuperar el valor devuelto por la función ejecutada. En la sección de ejemplos se proporciona información más detallada.
Esta macro se utiliza para llamar a cualquier función deseada con una pila de llamadas limpia. La macro espera los siguientes parámetros:
usize, isize o un puntero.Esta macro se utiliza para realizar cualquier syscall indirecto deseado con una pila de llamadas limpia. La macro espera los siguientes parámetros:
Para pasar argumentos de diferentes tipos a estas dos macros, se deben tener en cuenta las siguientes consideraciones:
usize (u8-u64, i8-i64, bool, etc.) puede pasarse directamente a las macros.&str y String) deben pasarse como un puntero.ptr::null(), ptr::null_mut(), etc.) se pasan como un 0 (sin importar si es u8, u16, i32 o cualquier otro).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);
Observa que la macro devuelve un *mut c_void que puede convertirse directamente a un HANDLE ya que ambos tipos de datos tienen el mismo tamaño. Esto permite acceder al valor devuelto por OpenProcess, que es el nuevo handle al proceso objetivo.
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);
Observa que la macro devuelve un *mut c_void que puede utilizarse para recuperar el NTSTATUS devuelto por NtDelayExecution.
El proceso de spoofing puede concatenarse cualquier número de veces sin un incremento anómalo del tamaño de la pila de llamadas. El flujo de ejecución también se conservará. El siguiente código es un ejemplo de ello:
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.
}
}
Si estableces el segundo parámetro en true (en ambas macros), el proceso de spoofing intentará mantener el frame de la dirección de inicio del hilo en la pila de llamadas para aumentar la legitimidad.

A veces, la función de inicio del hilo no realiza una call a una función posterior (por ejemplo, se ejecuta una instrucción jmp en su lugar), lo que significa que no se ha introducido una dirección de retorno en la pila. En ese escenario (y también si estableces ese segundo parámetro en false), la pila de llamadas falseada comenzará en el frame de BaseThreadInitThunk.

Para probar la implementación de la técnica, se ha utilizado PE-sieve con la flag /threads. Los resultados de la prueba muestran cómo la inspección de la pila de llamadas no revela la presencia del payload cuando se utilizan las funcionalidades de este crate. Como se puede ver en la segunda imagen, el payload se detecta cuando no se utiliza unwinder.

Esta es una alternativa a SilentMoonWalk para el spoofing de la pila de llamadas que permite mantener una pila de llamadas limpia durante la ejecución de tu programa. La idea principal detrás de esta técnica es que cada función llamada dentro de tu módulo se encarga de la dirección de retorno previamente introducida en la pila, encontrando en tiempo de ejecución una función legítima con el mismo tamaño de frame que el de la dirección de retorno que se va a falsear. Una vez localizada una función legítima con el mismo tamaño de frame, se calcula un offset dentro de ella y la dirección final se utiliza para reemplazar la última dirección de retorno, ocultando cualquier entrada anómala en la pila de llamadas y manteniéndola desenrollable. La dirección de retorno original es almacenada por unwinder y se vuelve a colocar en la posición correcta de la pila antes de que se ejecute una instrucción de retorno, lo que permite continuar con el flujo normal del programa.
Esta es una característica experimental que, a pesar de ser totalmente funcional, aún está en desarrollo e investigación, así que asegúrate de probar tu código si decides integrar esta técnica en él.
Para usar la funcionalidad de reemplazo de pila, debes añadir la siguiente línea a tu cargo.toml y compilar en modo release:
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}
La funcionalidad principal de esta característica se ha envuelto en las siguientes macros:
start_stack_replacement!()/end_replacement!() indica a unwinder que inicie/finalice el proceso de reemplazo de pila. Estas dos macros deben llamarse en el punto de entrada de tu código (por ejemplo, en las funciones exportadas de tu dll).replace_and_continue!()/restore!() realiza el reemplazo/restauración de la última dirección de retorno.replace_and_call!()/replace_and_syscall!() se utiliza para realizar el reemplazo de pila cuando queremos llamar a funciones fuera del módulo actual (por ejemplo, al usar la API de Windows o al llamar a código de cualquier otra dll). Ambas macros devuelven un *mut c_void que contiene el valor devuelto por la función llamada de esta manera (es decir, funcionan de la misma forma que se describe para las macros call_function e indirect_syscall utilizadas para ejecutar SilentMoonWalk).Para usar estas macros es necesario importar el tipo de datos std::ffi::c_void.
Todas las funciones que utilicen cualquiera de estas macros deben etiquetarse con los atributos #[no_mangle] o #[inline(never)] para evitar que el compilador de Rust las inlinee durante el proceso de optimización.
Antes de sumergirnos en un ejemplo práctico que muestre cómo usar todo esto, hagamos una rápida inspección del par de macros replace_and_call/replace_and_syscall y de cómo pasarles los argumentos esperados.
Esta macro se utiliza para llamar a cualquier función deseada fuera del módulo actual con una pila de llamadas limpia mientras se usa el reemplazo de pila. La macro espera los siguientes parámetros:
usize, isize o un puntero.Esta macro se utiliza para realizar cualquier syscall indirecto deseado con una pila de llamadas limpia mientras se usa el reemplazo de pila. La macro espera los siguientes parámetros:
Creo que la mejor manera de mostrar cómo se usan estas macros es mediante un ejemplo práctico. Supongamos que estamos creando una dll que será inyectada reflectivamente en memoria. Esta dll exportará dos funciones ExportA y ExportB, por lo que consideraremos estas dos funciones como los puntos de entrada del módulo. Ambas deben llamar a la macro start_stack_replacement justo al principio y también deben llamar a la macro inversa end_replacement antes de retornar. La macro start_stack_replacement espera como argumento la dirección base del módulo, o puedes pasar 0 si no conoces esa dirección en tiempo de ejecución; la macro intentará averiguarla por sí misma.
#[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
}
Iniciar el proceso de reemplazo de pila implica la construcción manual de una nueva pila que se utilizará hasta que se llame a la macro end_replacement. La siguiente imagen ilustra lo que sucede internamente:
Aunque teóricamente no sería necesario comenzar una nueva pila desde cero, he decidido implementar el proceso de esta manera para garantizar la estabilidad y evitar que algo se rompa.
Ahora, supongamos que nuestra función ExportedA realiza varias llamadas a otras dos funciones internas. Estas dos funciones internas se encargan de reemplazar/restaurar la dirección de retorno original que apuntará a algún lugar dentro de ExportedA, rompiendo la pila de llamadas a menos que nos encarguemos de ello. Este proceso de reemplazo implica envolver el código de nuestra función interna entre las macros replace_and_continue y 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
}
Finalmente, tanto internal_a como internal_b hacen uso de algunas funcionalidades de la API de Windows. Para mantener la pila de llamadas desenrollable, estas llamadas deben realizarse a través de las macros replace_and_call (llamada normal) o replace_and_syscall (syscall indirecto).
#[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
}
Dado que esta es una característica en desarrollo, se deben tener en cuenta algunas cosas:
start_stack_replace la dirección base del módulo. Ahora mismo no podrá encontrarla por sí sola (se resolverá en la próxima actualización).jmp rbx + frame de ocultación que la técnica SilentMoonWalk. Esto ocurre solo cuando se usan las macros replace_and_call y replace_and_syscall, y está previsto cambiarlo en la próxima actualización.replace_and_call como replace_and_syscall devuelven un *mut c_void que puede utilizarse para recuperar el valor devuelto por la función ejecutada a través de ellas. Este es el mismo comportamiento descrito para las macros call_function e indirect_syscall.replace_and_call y replace_and_syscall permiten hasta 11 argumentos.Por favor, infórmame de cualquier bug que pueda surgir al usar esta característica.