
Call-Stack-Spoofing für Rust
Unwinder bietet eine vollständige Weaponization der SilentMoonWalk-Technik und ermöglicht vollständiges und stabiles Call-Stack-Spoofing in Rust.
Diese Technik weist die folgenden Eigenschaften auf:
Dank an die Ersteller der SilentMoonWalk-Technik:
Und natürlich ein großes Lob an namazso für den Twitter-Thread, der dieses gesamte Projekt inspiriert hat.
Importieren Sie diese Crate in Ihr Projekt, indem Sie die folgende Zeile zu Ihrer cargo.toml hinzufügen und im release-Modus kompilieren:
[dependencies]
unwinder = "=0.1.4"
Die Hauptfunktionalität dieser Crate ist in zwei Makros gekapselt:
call_function!() ermöglicht die Ausführung einer beliebigen Funktion mit einem sauberen Aufrufstapel.indirect_syscall!() führt den angegebenen (indirekten) Syscall mit einem sauberen Aufrufstapel aus.Um eines dieser Makros verwenden zu können, muss der Datentyp std::ffi::c_void importiert werden.
Beide Makros geben einen *mut c_void zurück, der verwendet werden kann, um den von der ausgeführten Funktion zurückgegebenen Wert abzurufen. Detailliertere Informationen finden Sie im Abschnitt Beispiele.
Dieses Makro wird verwendet, um eine beliebige gewünschte Funktion mit einem sauberen Aufrufstapel aufzurufen. Das Makro erwartet die folgenden Parameter:
usize, isize oder als Zeiger übergeben werden.Dieses Makro wird verwendet, um einen beliebigen gewünschten indirekten Syscall mit einem sauberen Aufrufstapel auszuführen. Das Makro erwartet die folgenden Parameter:
Um Argumente verschiedener Typen an diese beiden Makros zu übergeben, müssen die folgenden Überlegungen berücksichtigt werden:
usize konvertiert werden kann (u8-u64, i8-i64, bool usw.), kann direkt an die Makros übergeben werden.&str und String) müssen als Zeiger übergeben werden.ptr::null(), ptr::null_mut() usw.) werden als 0 übergeben (egal ob u8, u16, i32 oder irgendein anderer Typ).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);
Beachten Sie, dass das Makro einen *mut c_void zurückgibt, der direkt in einen HANDLE konvertiert werden kann, da beide Datentypen dieselbe Größe haben. Dadurch kann auf den von OpenProcess zurückgegebenen Wert zugegriffen werden, also auf das neue Handle zum Zielprozess.
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);
Beachten Sie, dass das Makro einen *mut c_void zurückgibt, mit dem der von NtDelayExecution zurückgegebene NTSTATUS abgerufen werden kann.
Der Spoofing-Prozess kann beliebig oft verkettet werden, ohne dass die Größe des Aufrufstapels abnormal zunimmt. Auch der Ausführungsfluss bleibt erhalten. Der folgende Code ist ein Beispiel dafür:
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.
}
}
Wenn Sie den zweiten Parameter auf true setzen (bei beiden Makros), versucht der Spoofing-Prozess, den Frame der Thread-Startadresse im Aufrufstapel zu behalten, um die Legitimität zu erhöhen.

Manchmal führt die Startfunktion des Threads keinen call zu einer nachfolgenden Funktion aus (z. B. wird stattdessen ein jmp-Befehl ausgeführt), was bedeutet, dass keine Rücksprungadresse auf den Stapel gelegt wird. In diesem Szenario (und auch wenn Sie den zweiten Parameter auf false setzen) beginnt der gespoofte Aufrufstapel am Frame von BaseThreadInitThunk.

Um die Implementierung der Technik zu testen, wurde PE-sieve mit dem Flag /threads verwendet. Die Ergebnisse des Tests zeigen, dass die Untersuchung des Aufrufstapels das Vorhandensein der Payload nicht erkennen lässt, wenn die Funktionen dieser Crate verwendet werden. Wie im zweiten Bild zu sehen ist, wird die Payload erkannt, wenn unwinder nicht verwendet wird.

Dies ist eine Alternative zum Call-Stack-Spoofing von SilentMoonWalk, die es ermöglicht, während der Ausführung Ihres Programms einen sauberen Aufrufstapel beizubehalten. Die Grundidee hinter dieser Technik ist, dass jede aufgerufene Funktion innerhalb Ihres Moduls die zuvor gepushte Rücksprungadresse verwaltet, indem sie zur Laufzeit eine legitime Funktion mit derselben Framegröße findet wie die zu spoofende Rücksprungadresse. Sobald eine legitime Funktion mit derselben Framegröße gefunden wurde, wird ein Offset innerhalb dieser Funktion berechnet und die endgültige Adresse verwendet, um die letzte Rücksprungadresse zu ersetzen, wodurch jeder anomale Eintrag im Aufrufstapel verborgen und der Stapel weiterhin abwickelbar bleibt. Die ursprüngliche Rücksprungadresse wird von unwinder gespeichert und vor der Ausführung einer Return-Anweisung an die richtige Position im Stapel zurückverschoben, sodass der normale Programmablauf fortgesetzt werden kann.
Dies ist ein experimentelles Feature, das zwar voll funktionsfähig ist, sich aber noch in Entwicklung und Forschung befindet. Testen Sie Ihren Code daher unbedingt, wenn Sie diese Technik integrieren möchten.
Um die Stack-Ersetzungsfunktionalität zu nutzen, sollten Sie die folgende Zeile zu Ihrer cargo.toml hinzufügen und im release-Modus kompilieren:
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}
Die Hauptfunktionalität dieses Features ist in den folgenden Makros gekapselt:
start_stack_replacement!()/end_replacement!() weist unwinder an, den Stack-Ersetzungsprozess zu starten/beenden. Diese beiden Makros müssen am Einstiegspunkt Ihres Codes aufgerufen werden (z. B. in den exportierten Funktionen Ihrer DLL).replace_and_continue!()/restore!() führt die Ersetzung/Wiederherstellung der letzten Rücksprungadresse durch.replace_and_call!()/replace_and_syscall!() verwendet, um eine Stack-Ersetzung durchzuführen, wenn wir Funktionen außerhalb des aktuellen Moduls aufrufen möchten (z. B. bei Verwendung der Windows-API oder beim Aufruf von Code in anderen DLLs). Beide Makros geben einen *mut c_void zurück, der den auf diese Weise aufgerufenen Rückgabewert enthält (d. h. sie funktionieren genauso wie die Makros call_function und indirect_syscall zur Ausführung von SilentMoonWalk).Um diese Makros verwenden zu können, muss der Datentyp std::ffi::c_void importiert werden.
Alle Funktionen, die eines dieser Makros verwenden, sollten mit den Attributen #[no_mangle] oder #[inline(never)] versehen werden, um zu verhindern, dass der Rust-Compiler sie während der Optimierung inline einfügt.
Bevor wir in ein praktisches Beispiel eintauchen, das zeigt, wie all das verwendet wird, hier ein kurzer Blick auf das Makropaar replace_and_call/replace_and_syscall und darauf, wie die erwarteten Argumente übergeben werden.
Dieses Makro wird verwendet, um eine beliebige gewünschte Funktion außerhalb des aktuellen Moduls mit einem sauberen Aufrufstapel aufzurufen, während die Stack-Ersetzung verwendet wird. Das Makro erwartet die folgenden Parameter:
usize, isize oder als Zeiger übergeben werden.Dieses Makro wird verwendet, um einen beliebigen gewünschten indirekten Syscall mit einem sauberen Aufrufstapel auszuführen, während die Stack-Ersetzung verwendet wird. Das Makro erwartet die folgenden Parameter:
Ich denke, der beste Weg, die Verwendung dieser Makros zu zeigen, ist ein praktisches Beispiel. Nehmen wir an, wir erstellen eine DLL, die reflektiv injiziert wird. Diese DLL exportiert zwei Funktionen, ExportA und ExportB, sodass wir diese beiden Funktionen als Einstiegspunkte des Moduls betrachten. Beide müssen das Makro start_stack_replacement direkt am Anfang aufrufen und vor der Rückkehr das umgekehrte Makro end_replacement aufrufen. Das Makro start_stack_replacement erwartet als Argument die Basisadresse des Moduls; alternativ können Sie 0 übergeben, wenn Sie diese Adresse zur Laufzeit nicht kennen – das Makro versucht dann, sie selbst herauszufinden.
#[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
}
Das Starten des Stack-Ersetzungsprozesses beinhaltet das manuelle Erstellen eines neuen Stacks, der verwendet wird, bis das Makro end_replacement aufgerufen wird. Das folgende Bild veranschaulicht, was intern vor sich geht:
Obwohl es theoretisch nicht notwendig wäre, einen neuen Stack von Grund auf zu starten, habe ich mich entschieden, den Prozess auf diese Weise zu implementieren, um Stabilität zu gewährleisten und zu verhindern, dass etwas bricht.
Nehmen wir nun an, dass unsere Funktion ExportedA mehrere Aufrufe an zwei weitere interne Funktionen macht. Diese beiden internen Funktionen sind dafür verantwortlich, die ursprüngliche Rücksprungadresse zu ersetzen/wiederherzustellen, die auf eine Stelle innerhalb von ExportedA zeigen würde und den Aufrufstapel brechen würde, wenn wir uns nicht darum kümmern. Dieser Ersetzungsprozess beinhaltet, den Code unserer internen Funktionen zwischen den Makros replace_and_continue und restore zu kapseln:
#[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
}
Schließlich verwenden sowohl internal_a als auch internal_b einige Windows-API-Funktionen. Um den abwickelbaren Aufrufstapel zu erhalten, sollten diese Aufrufe über die Makros replace_and_call (normaler Aufruf) oder replace_and_syscall (indirekter Syscall) durchgeführt werden.
#[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
}
Da sich dieses Feature noch in der Entwicklung befindet, muss einiges berücksichtigt werden:
start_stack_replace die Basisadresse des Moduls übergeben. Derzeit kann es die Adresse nicht selbst finden (wird im nächsten Update behoben).jmp rbx + Concealment-Frame wie die SilentMoonWalk-Technik. Dies geschieht nur bei Verwendung der Makros replace_and_call und replace_and_syscall und soll im nächsten Update geändert werden.replace_and_call als auch replace_and_syscall geben einen *mut c_void zurück, mit dem der Rückgabewert der darüber ausgeführten Funktion abgerufen werden kann. Dies entspricht dem Verhalten der Makros call_function und indirect_syscall.replace_and_call und replace_and_syscall erlauben bis zu 11 Argumente.Bitte melden Sie mir jeden Fehler, der bei der Verwendung dieses Features auftreten kann.