
Call stack spoofing for Rust
O Unwinder fornece uma weaponização completa da técnica SilentMoonWalk, permitindo obter spoofing de pilha de chamadas completo e estável em Rust.
Esta técnica possui as seguintes características:
Créditos aos criadores da técnica SilentMoonWalk:
E, claro, um enorme agradecimento a namazso pelo thread no Twitter que inspirou todo este projeto.
Importe esta crate para o seu projeto adicionando a seguinte linha ao seu cargo.toml e compile no modo release:
[dependencies]
unwinder = "=0.1.4"
A funcionalidade principal desta crate foi encapsulada em duas macros:
call_function!() permite executar qualquer função arbitrária com uma pilha de chamadas limpa.indirect_syscall!() executa o syscall indireto especificado com uma pilha de chamadas limpa.Para usar qualquer uma destas macros, é necessário importar o tipo de dados std::ffi::c_void.
Ambas as macros retornam um *mut c_void que pode ser usado para recuperar o valor retornado pela função executada. Informações mais detalhadas na seção de exemplos.
Esta macro é usada para chamar qualquer função desejada com uma pilha de chamadas limpa. A macro espera os seguintes parâmetros:
usize, isize ou um ponteiro.Esta macro é usada para realizar qualquer syscall indireto desejado com uma pilha de chamadas limpa. A macro espera os seguintes parâmetros:
Para passar argumentos de diferentes tipos para estas duas macros, as seguintes considerações devem ser levadas em conta:
usize (u8-u64, i8-i64, bool, etc.) pode ser passado diretamente para as macros.&str e String) devem ser passadas como um ponteiro.ptr::null(), ptr::null_mut(), etc.) são passados como 0 (não importa se é u8, u16, i32 ou qualquer outro).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);
Observe que a macro retorna um *mut c_void que pode ser convertido diretamente para um HANDLE, já que ambos os tipos de dados têm o mesmo tamanho. Isso permite acessar o valor retornado por OpenProcess, que é o novo handle para o processo alvo.
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);
Observe que a macro retorna um *mut c_void que pode ser usado para recuperar o NTSTATUS retornado por NtDelayExecution.
O processo de spoofing pode ser concatenado qualquer número de vezes sem um incremento anormal no tamanho da pilha de chamadas. O fluxo de execução também será preservado. O código a seguir é um exemplo disso:
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 você definir o segundo parâmetro como true (em ambas as macros), o processo de spoofing tentará manter o frame do endereço de início da thread na pilha de chamadas para aumentar a legitimidade.

Às vezes, a função de início da thread não executa um call para uma função subsequente (por exemplo, uma instrução jmp é executada em vez disso), o que significa que não há endereço de retorno empurrado para a pilha. Nesse cenário (e também se você definir o segundo parâmetro como false), a pilha de chamadas com spoofing começará no frame de BaseThreadInitThunk.

Para testar a implementação da técnica, o PE-sieve foi usado com a flag /threads. Os resultados do teste mostram como a inspeção da pilha de chamadas não revela a presença do payload quando as funcionalidades desta crate são usadas. Como pode ser visto na segunda imagem, o payload é detectado quando o unwinder não é usado.

Esta é uma alternativa de spoofing de pilha de chamadas ao SilentMoonWalk que permite manter uma pilha de chamadas limpa durante a execução do seu programa. A ideia principal por trás desta técnica é que cada função chamada dentro do seu módulo cuida do endereço de retorno previamente empurrado, encontrando em tempo de execução uma função legítima com o mesmo tamanho de frame do endereço de retorno a ser falsificado. Uma vez localizada uma função legítima com o mesmo tamanho de frame, um offset dentro dela é calculado e o endereço final é usado para substituir o último endereço de retorno, ocultando qualquer entrada anômala na pilha de chamadas e mantendo-a passível de desenrolamento (unwindable). O endereço de retorno original é armazenado por unwinder e é movido de volta para a posição correta na pilha antes que uma instrução de retorno seja executada, permitindo continuar o fluxo normal do programa.
Este é um recurso experimental que, apesar de totalmente funcional, ainda está em desenvolvimento e pesquisa, portanto, certifique-se de testar seu código se decidir integrar esta técnica nele.
Para usar a funcionalidade de substituição de pilha, você deve adicionar a seguinte linha ao seu cargo.toml e compilar no modo release:
[dependencies]
unwinder = {version = "0.1.4", features = ["Experimental"]}
A funcionalidade principal deste recurso foi encapsulada nas seguintes macros:
start_stack_replacement!()/end_replacement!() indica ao unwinder para iniciar/encerrar o processo de substituição de pilha. Essas duas macros devem ser chamadas no ponto de entrada do seu código (por exemplo, nas funções exportadas da sua dll).replace_and_continue!()/restore!() realiza a substituição/restauração do último endereço de retorno.replace_and_call!()/replace_and_syscall!() é usado para realizar a substituição de pilha quando queremos chamar funções fora do módulo atual (por exemplo, ao usar a API do Windows ou chamar código de qualquer outra dll). Ambas as macros retornam um *mut c_void contendo o valor retornado pela função chamada dessa forma (ou seja, elas operam da mesma maneira que foi descrita para as macros call_function e indirect_syscall usadas para executar o SilentMoonWalk).Para usar essas macros, é necessário importar o tipo de dados std::ffi::c_void.
Todas as funções que usam qualquer uma dessas macros devem ser rotuladas com os atributos #[no_mangle] ou #[inline(never)] para impedir que o compilador Rust as inline durante o processo de otimização.
Antes de mergulhar em um exemplo prático mostrando como usar tudo isso, apenas uma rápida inspeção do par de macros replace_and_call/replace_and_syscall e de como passar a eles os argumentos esperados.
Esta macro é usada para chamar qualquer função desejada fora do módulo atual com uma pilha de chamadas limpa enquanto usa a substituição de pilha. A macro espera os seguintes parâmetros:
usize, isize ou um ponteiro.Esta macro é usada para realizar qualquer syscall indireto desejado com uma pilha de chamadas limpa enquanto usa a substituição de pilha. A macro espera os seguintes parâmetros:
Eu acho que a melhor maneira de mostrar como essas macros são usadas é por meio de um exemplo prático. Vamos supor que estamos criando uma dll que será injetada reflexivamente na memória. Essa dll exportará duas funções ExportA e ExportB, então consideraremos essas duas funções como os pontos de entrada do módulo. Ambas devem chamar a macro start_stack_replacement logo no início e também devem chamar a macro reversa end_replacement antes de retornar. A macro start_stack_replacement espera como argumento o endereço base do módulo, ou você pode passar 0 se não souber esse endereço em tempo de execução; a macro tentará descobri-lo sozinha.
#[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 o processo de substituição de pilha envolve a criação manual de uma nova pilha que será usada até que a macro end_replacement seja chamada. A imagem a seguir ilustra o que está acontecendo nos bastidores:
Embora teoricamente não fosse necessário iniciar uma nova pilha do zero, decidi implementar o processo dessa forma para garantir estabilidade e evitar que algo quebre.
Agora, vamos supor que nossa função ExportedA faça várias chamadas a outras duas funções internas. Essas duas funções internas são responsáveis por substituir/restaurar o endereço de retorno original que apontará para algum lugar dentro de ExportedA, quebrando a pilha de chamadas a menos que cuidemos disso. Esse processo de substituição envolve envolver o código da nossa função interna entre as macros 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
}
Finalmente, tanto a função internal_a quanto a internal_b fazem uso de alguma funcionalidade da API do Windows. Para manter a pilha de chamadas passível de desenrolamento, essas chamadas devem ser realizadas por meio das macros replace_and_call (chamada normal) ou replace_and_syscall (syscall indireto).
#[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
}
Como este é um recurso em desenvolvimento, algumas coisas devem ser levadas em conta:
start_stack_replace o endereço base do módulo. No momento, ela não conseguirá encontrá-lo sozinha (a ser resolvido na próxima atualização).jmp rbx + quadro de ocultação (concealment frame) da técnica SilentMoonWalk. Isso acontece apenas quando se usa as macros replace_and_call e replace_and_syscall e está planejado para ser alterado na próxima atualização.replace_and_call e replace_and_syscall retornam um *mut c_void que pode ser usado para recuperar o valor retornado pela função executada através delas. Este é o mesmo comportamento descrito para as macros call_function e indirect_syscall.replace_and_call e replace_and_syscall permitem até 11 argumentos.Por favor, reporte-me qualquer bug que possa surgir ao usar este recurso.