
Call stack spoofing for Rust
Unwinder fournit une weaponization complète de la technique SilentMoonWalk, permettant d'obtenir un spoofing complet et stable de la pile d'appels en Rust.
Cette technique présente les caractéristiques suivantes :
Merci aux créateurs de la technique SilentMoonWalk :
Et bien sûr, un grand merci à namazso pour le fil Twitter qui a inspiré tout ce projet.
Importez cette crate dans votre projet en ajoutant la ligne suivante à votre cargo.toml et compilez en mode release :
[dependencies]
unwinder = "=0.1.4"
La fonctionnalité principale de cette crate est encapsulée dans deux macros :
call_function!() permet d'exécuter toute fonction arbitraire avec une pile d'appels propre.indirect_syscall!() exécute le syscall (indirect) spécifié avec une pile d'appels propre.Pour utiliser l'une de ces macros, il est nécessaire d'importer le type std::ffi::c_void.
Les deux macros retournent un *mut c_void qui peut être utilisé pour récupérer la valeur retournée par la fonction exécutée. Des informations plus détaillées se trouvent dans la section des exemples.
Cette macro est utilisée pour appeler n'importe quelle fonction souhaitée avec une pile d'appels propre. La macro attend les paramètres suivants :
usize, isize ou un pointeur.Cette macro est utilisée pour effectuer n'importe quel syscall indirect souhaité avec une pile d'appels propre. La macro attend les paramètres suivants :
Afin de passer des arguments de différents types à ces deux macros, les considérations suivantes doivent être prises en compte :
usize (u8-u64, i8-i64, bool, etc.) peut être passé directement aux macros.&str et String) doivent être passées sous forme de pointeur.ptr::null(), ptr::null_mut(), etc.) sont passés comme 0 (peu importe qu'il s'agisse d'un u8, u16, i32 ou autre).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);
Remarquez que la macro retourne un *mut c_void qui peut être directement converti en HANDLE, car les deux types ont la même taille. Cela permet d'accéder à la valeur retournée par OpenProcess, qui est le nouveau handle du processus cible.
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);
Remarquez que la macro retourne un *mut c_void qui peut être utilisé pour récupérer le NTSTATUS retourné par NtDelayExecution.
Le processus de spoofing peut être concaténé un nombre illimité de fois sans augmentation anormale de la taille de la pile d'appels. Le flux d'exécution est également préservé. Le code suivant en est un exemple :
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 vous définissez le deuxième paramètre sur true (pour les deux macros), le processus de spoofing tentera de conserver le cadre de l'adresse de démarrage du thread dans la pile d'appels afin d'accroître la légitimité.

Parfois, la fonction de démarrage du thread n'effectue pas d'appel vers une fonction ultérieure (par exemple, une instruction jmp est exécutée à la place), ce qui signifie qu'aucune adresse de retour n'est poussée sur la pile. Dans ce scénario (et également si vous définissez ce deuxième paramètre sur false), la pile d'appels usurpée commencera au cadre de BaseThreadInitThunk.

Afin de tester l'implémentation de la technique, PE-sieve a été utilisé avec le flag /threads. Les résultats du test montrent comment l'inspection de la pile d'appels ne révèle pas la présence de la charge utile lorsque les fonctionnalités de cette crate sont utilisées. Comme on peut le voir sur la deuxième image, la charge utile est détectée lorsque unwinder n'est pas utilisé.

Il s'agit d'une alternative à SilentMoonWalk pour l'usurpation de la pile d'appels, qui permet de conserver une pile d'appels propre pendant l'exécution de votre programme. L'idée principale derrière cette technique est que chaque fonction appelée à l'intérieur de votre module prend en charge l'adresse de retour précédemment poussée, en trouvant au runtime une fonction légitime ayant la même taille de cadre que celle de l'adresse de retour à usurper. Une fois qu'une fonction légitime avec la même taille de cadre a été localisée, un décalage interne est calculé et l'adresse finale est utilisée pour remplacer la dernière adresse de retour, masquant toute entrée anormale dans la pile d'appels et la maintenant déroulable (unwindable). L'adresse de retour d'origine est stockée par unwinder et est remise à la bonne position dans la pile avant l'exécution d'une instruction de retour, ce qui permet de poursuivre le flux normal du programme.
Il s'agit d'une fonctionnalité expérimentale qui, bien que pleinement fonctionnelle, est encore en cours de développement et de recherche. Assurez-vous donc de tester votre code si vous décidez d'intégrer cette technique.
Pour utiliser la fonctionnalité de remplacement de pile, vous devez ajouter la ligne suivante à votre cargo.toml et compiler en mode release :
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}
La fonctionnalité principale de cette option est encapsulée dans les macros suivantes :
start_stack_replacement!()/end_replacement!() indique à unwinder de démarrer/arrêter le processus de remplacement de pile. Ces deux macros doivent être appelées au point d'entrée de votre code (par exemple, dans les fonctions exportées de votre dll).replace_and_continue!()/restore!() effectue le remplacement/la restauration de la dernière adresse de retour.replace_and_call!()/replace_and_syscall!() est utilisée pour effectuer le remplacement de pile lorsque vous souhaitez appeler des fonctions en dehors du module actuel (par exemple, lors de l'utilisation de l'API Windows ou de l'appel du code d'une autre dll). Ces deux macros retournent un *mut c_void contenant la valeur retournée par la fonction appelée de cette manière (c'est-à-dire qu'elles fonctionnent de la même manière que les macros call_function et indirect_syscall utilisées pour exécuter SilentMoonWalk).Pour utiliser ces macros, il est nécessaire d'importer le type std::ffi::c_void.
Toutes les fonctions utilisant l'une de ces macros doivent être annotées avec les attributs #[no_mangle] ou #[inline(never)] pour empêcher le compilateur Rust de les inliner pendant le processus d'optimisation.
Avant de plonger dans un exemple pratique montrant comment utiliser tout cela, jetons un coup d'œil rapide à la paire de macros replace_and_call/replace_and_syscall et à la manière de leur passer les arguments attendus.
Cette macro est utilisée pour appeler n'importe quelle fonction souhaitée en dehors du module actuel, avec une pile d'appels propre, tout en utilisant le remplacement de pile. La macro attend les paramètres suivants :
usize, isize ou un pointeur.Cette macro est utilisée pour effectuer n'importe quel syscall indirect souhaité avec une pile d'appels propre, tout en utilisant le remplacement de pile. La macro attend les paramètres suivants :
Je pense que la meilleure façon de montrer comment ces macros sont utilisées est de passer par un exemple pratique. Supposons que nous créons une dll qui sera injectée par réflexion en mémoire. Cette dll exportera deux fonctions ExportA et ExportB; nous considérerons donc ces deux fonctions comme les points d'entrée du module. Toutes deux doivent appeler la macro start_stack_replacement au tout début et également appeler la macro inverse end_replacement avant de retourner. La macro start_stack_replacement attend comme argument l'adresse de base du module, ou vous pouvez passer 0 si vous ne connaissez pas cette adresse au runtime; la macro essaiera de la déterminer par elle-même.
#[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
}
Le démarrage du processus de remplacement de pile implique la construction manuelle d'une nouvelle pile qui sera utilisée jusqu'à ce que la macro end_replacement soit appelée. L'image suivante illustre ce qui se passe sous le capot :
Bien que théoriquement il ne soit pas nécessaire de démarrer une nouvelle pile de zéro, j'ai décidé d'implémenter le processus de cette façon pour assurer la stabilité et éviter que quoi que ce soit ne casse.
Maintenant, supposons que notre fonction ExportedA effectue plusieurs appels vers deux autres fonctions internes. Ces deux fonctions internes sont responsables du remplacement/restauration de l'adresse de retour d'origine qui pointera vers un endroit dans ExportedA, cassant la pile d'appels si nous n'y prenons pas garde. Ce processus de remplacement consiste à encapsuler le code de notre fonction interne entre les macros replace_and_continue et 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
}
Enfin, les deux fonctions internal_a et internal_b utilisent certaines fonctionnalités de l'API Windows. Pour conserver une pile d'appels déroulable, ces appels doivent être effectués via les macros replace_and_call (appel normal) ou replace_and_syscall (syscall indirect).
#[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
}
Comme il s'agit d'une fonctionnalité en cours de développement, certaines choses doivent être prises en compte :
start_stack_replace l'adresse de base du module. Actuellement, elle ne sera pas capable de la trouver par elle-même (à résoudre dans la prochaine mise à jour).jmp rbx + cadre de dissimulation que la technique SilentMoonWalk. Cela ne se produit que lors de l'utilisation des macros replace_and_call et replace_and_syscall et il est prévu de le modifier dans la prochaine mise à jour.replace_and_call et replace_and_syscall retournent toutes deux un *mut c_void qui peut être utilisé pour récupérer la valeur retournée par la fonction exécutée via elles. C'est le même comportement que celui décrit pour les macros call_function et indirect_syscall.replace_and_call et replace_and_syscall acceptent jusqu'à 11 arguments.Veuillez me signaler tout bug qui pourrait survenir lors de l'utilisation de cette fonctionnalité.