
Call stack spoofing for Rust
Unwinder fornisce una piena weaponizzazione della tecnica SilentMoonWalk, consentendo di ottenere uno spoofing completo e stabile della call stack in Rust.
Questa tecnica presenta le seguenti caratteristiche:
un ringraziamento ai creatori della tecnica SilentMoonWalk:
E naturalmente un enorme ringraziamento a namazso per il thread su Twitter che ha ispirato l'intero progetto.
Importa questa crate nel tuo progetto aggiungendo la seguente riga al tuo cargo.toml e compila in modalità release:
[dependencies]
unwinder = "=0.1.4"
La funzionalità principale di questa crate è stata racchiusa in due macro:
call_function!() consente di eseguire qualsiasi funzione arbitraria con una call stack pulita.indirect_syscall!() esegue la syscall indiretta specificata con una call stack pulita.Per utilizzare una qualsiasi di queste macro è necessario importare il tipo di dato std::ffi::c_void.
Entrambe le macro restituiscono un *mut c_void che può essere utilizzato per recuperare il valore restituito dalla funzione eseguita. Maggiori dettagli nella sezione esempi.
Questa macro viene utilizzata per chiamare qualsiasi funzione desiderata con una call stack pulita. La macro richiede i seguenti parametri:
usize, isize o puntatore.Questa macro viene utilizzata per eseguire qualsiasi syscall indiretta desiderata con una call stack pulita. La macro richiede i seguenti parametri:
Per passare argomenti di tipi diversi a queste due macro, è necessario tenere in considerazione le seguenti indicazioni:
usize (u8-u64, i8-i64, bool, ecc.) può essere passato direttamente alle macro.&str e String) devono essere passate come puntatore.ptr::null(), ptr::null_mut(), ecc.) vengono passati come 0 (indipendentemente dal fatto che siano u8, u16, i32 o qualsiasi altro).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);
Nota che la macro restituisce un *mut c_void che può essere convertito direttamente in un HANDLE poiché entrambi i tipi di dato hanno la stessa dimensione. Questo consente di accedere al valore restituito da OpenProcess, ovvero il nuovo handle al processo target.
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);
Nota che la macro restituisce un *mut c_void che può essere utilizzato per recuperare l'NTSTATUS restituito da NtDelayExecution.
Il processo di spoofing può essere concatenato un numero qualsiasi di volte senza un incremento anomalo della dimensione della call stack. Anche il flusso di esecuzione verrà preservato. Il codice seguente è un esempio:
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.
}
}
Se imposti il secondo parametro a true (in entrambe le macro), il processo di spoofing proverà a mantenere nella call stack il frame dell'indirizzo di inizio del thread per aumentare la legittimità.

A volte, la funzione di inizio del thread non esegue una call a una funzione successiva (ad esempio viene eseguita un'istruzione jmp), il che significa che non viene pushato alcun indirizzo di ritorno nello stack. In quello scenario (e anche se imposti il secondo parametro a false), la call stack spoofata inizierà dal frame di BaseThreadInitThunk.

Per testare l'implementazione della tecnica, è stato utilizzato PE-sieve con il flag /threads. I risultati del test mostrano come l'ispezione della call stack non riveli la presenza del payload quando vengono utilizzate le funzionalità di questa crate. Come si può vedere nella seconda immagine, il payload viene rilevato quando unwinder non viene utilizzato.

Questa è un'alternativa a SilentMoonWalk per lo spoofing della call stack che consente di mantenere una call stack pulita durante l'esecuzione del programma. L'idea principale alla base di questa tecnica è che ogni funzione chiamata all'interno del modulo si occupa dell'indirizzo di ritorno precedentemente pushato, trovando a runtime una funzione legittima con la stessa dimensione del frame dell'indirizzo di ritorno da spoofare. Una volta individuata una funzione legittima con la stessa dimensione del frame, viene calcolato un offset al suo interno e l'indirizzo finale viene utilizzato per sostituire l'ultimo indirizzo di ritorno, nascondendo qualsiasi voce anomala nella call stack e mantenendola unwindable. L'indirizzo di ritorno originale viene memorizzato da unwinder e viene riposizionato al posto giusto nello stack prima dell'esecuzione di un'istruzione di ritorno, consentendo di continuare il normale flusso del programma.
Questa è una funzionalità sperimentale che, sebbene sia completamente funzionante, è ancora in fase di sviluppo e ricerca, quindi assicurati di testare il tuo codice se decidi di integrarla.
Per utilizzare la funzionalità di sostituzione dello stack, aggiungi la seguente riga al tuo cargo.toml e compila in modalità release:
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}
La funzionalità principale di questa caratteristica è stata racchiusa nelle seguenti macro:
start_stack_replacement!()/end_replacement!() indica a unwinder di avviare/terminare il processo di sostituzione dello stack. Queste due macro devono essere chiamate nel punto di ingresso del codice (ad esempio nelle funzioni esportate della dll).replace_and_continue!()/restore!() esegue la sostituzione/ripristino dell'ultimo indirizzo di ritorno.replace_and_call!()/replace_and_syscall!() viene utilizzata per eseguire la sostituzione dello stack quando si vogliono chiamare funzioni al di fuori del modulo corrente (ad esempio quando si usa l'API Windows o si chiama codice di altre dll). Entrambe queste macro restituiscono un *mut c_void contenente il valore restituito dalla funzione chiamata in questo modo (cioè funzionano allo stesso modo descritto per le macro call_function e indirect_syscall usate per eseguire SilentMoonWalk).Per usare queste macro è necessario importare il tipo di dato std::ffi::c_void.
Tutte le funzioni che utilizzano una di queste macro dovrebbero essere marcate con gli attributi #[no_mangle] o #[inline(never)] per impedire al compilatore Rust di inlinearle durante il processo di ottimizzazione.
Prima di addentrarci in un esempio pratico che mostra come usare tutto questo, diamo una rapida occhiata alla coppia di macro replace_and_call/replace_and_syscall e a come passare loro gli argomenti previsti.
Questa macro viene utilizzata per chiamare qualsiasi funzione desiderata al di fuori del modulo corrente con una call stack pulita, utilizzando la sostituzione dello stack. La macro richiede i seguenti parametri:
usize, isize o puntatore.Questa macro viene utilizzata per eseguire qualsiasi syscall indiretta desiderata con una call stack pulita, utilizzando la sostituzione dello stack. La macro richiede i seguenti parametri:
Penso che il modo migliore per mostrare come vengono usate queste macro sia un esempio pratico. Supponiamo di creare una dll che verrà iniettata riflessivamente in memoria. Questa dll esporterà due funzioni ExportA e ExportB, quindi considereremo queste due funzioni come punti di ingresso del modulo. Entrambe devono chiamare la macro start_stack_replacement subito all'inizio e devono anche chiamare la macro inversa end_replacement prima di restituire. La macro start_stack_replacement si aspetta come argomento l'indirizzo di base del modulo, oppure puoi passare 0 se non conosci quell'indirizzo a runtime; la macro proverà a scoprirlo da sola.
#[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
}
Avviare il processo di sostituzione dello stack comporta la creazione manuale di una nuova stack che verrà utilizzata fino alla chiamata della macro end_replacement. L'immagine seguente illustra cosa succede dietro le quinte:
Anche se teoricamente non sarebbe necessario creare una nuova stack da zero, ho deciso di implementare il processo in questo modo per garantire stabilità e prevenire qualsiasi rottura.
Ora, supponiamo che la nostra funzione ExportedA faccia diverse chiamate ad altre due funzioni interne. Queste due funzioni interne sono responsabili della sostituzione/ripristino dell'indirizzo di ritorno originale che punterà a qualche punto all'interno di ExportedA, spezzando la call stack a meno che non ce ne occupiamo noi. Questo processo di sostituzione implica avvolgere il codice della nostra funzione interna tra le macro replace_and_continue e 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
}
Infine, entrambe le funzioni internal_a e internal_b fanno uso di alcune funzionalità dell'API Windows. Per mantenere la call stack unwindable, queste chiamate dovrebbero essere effettuate tramite le macro replace_and_call (chiamata normale) o replace_and_syscall (syscall indiretta).
#[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
}
Poiché questa è una funzionalità in via di sviluppo, occorre tenere in considerazione alcune cose:
start_stack_replace l'indirizzo di base del modulo. Al momento non sarà in grado di trovarlo da sola (verrà risolto nel prossimo aggiornamento).jmp rbx + frame di occultamento della tecnica SilentMoonWalk. Questo accade solo quando si usano le macro replace_and_call e replace_and_syscall ed è previsto che venga modificato nel prossimo aggiornamento.replace_and_call che la macro replace_and_syscall restituiscono un *mut c_void che può essere utilizzato per recuperare il valore restituito dalla funzione eseguita tramite esse. Questo è lo stesso comportamento descritto per le macro call_function e indirect_syscall.replace_and_call e replace_and_syscall accettano fino a 11 argomenti.Per favore segnalami qualsiasi bug possa sorgere utilizzando questa funzionalità.