3 个版本

0.1.2 2024 年 5 月 19 日
0.1.1 2024 年 1 月 20 日
0.1.0 2023 年 11 月 28 日

#9 in #call-stack

Download history 7/week @ 2024-04-21 145/week @ 2024-05-19 5/week @ 2024-05-26 3/week @ 2024-06-09 1/week @ 2024-06-16

198 个月下载量
shelter 中使用

MIT 许可证

120KB
1.5K SLoC

Rust 1.5K SLoC // 0.1% comments Assembly 267 SLoC // 0.0% comments

内容

SilentMoonWalk

描述

Unwinder 为 SilentMoonWalk 技术提供了完整的武器化,允许在 Rust 中实现完整且稳定的调用栈欺骗。

该技术具有以下特点

  • 支持运行带有最多 11 个参数的任何任意函数。
  • 支持运行带有最多 11 个参数的间接系统调用(无需额外的堆分配)。
  • 该库允许检索通过它调用的函数返回的值。
  • 欺骗过程可以连接任意次数,而不会增加调用栈大小。
  • 在欺骗过程中使用 TLS 以提高效率。
  • 使用 dinvoke_rs 来执行库所需的任何 Windows API 调用。

致谢

感谢 SilentMoonWalk 技术的创造者

当然,还要向 namazso 表示敬意,他的 Twitter 线程 启发了这个项目的整个构思。

用法

通过在您的 cargo.toml 中添加以下行将此 crate 导入到您的项目中,并在 release 模式下编译

[dependencies]
unwinder = "0.1.2"

此 crate 的主要功能已封装在两个宏中

  • call_function!() 宏允许以干净的调用栈运行任何任意函数。
  • indirect_syscall!() 宏在干净的调用栈上执行指定的(间接)系统调用。

要使用这些宏之一,需要导入 std::ffi::c_void 数据类型。

这两个宏都返回一个 *mut c_void,可用于检索执行函数返回的值。更详细的信息请参阅示例部分。

call_function 宏

此宏用于调用任何所需的函数并带有干净的调用栈。该宏期望以下参数

  • 第一个参数是在欺骗调用栈之后调用的内存地址。此参数应作为 usizeisize 或一个指针传递。
  • 第二个参数是一个布尔值,表示是否保留起始函数帧。如果您不确定这一点,请将其设置为 false,这始终可以保证良好的调用栈。
  • 以下参数是在欺骗调用栈后发送给函数的参数。

间接syscall宏

此宏用于执行任何所需的间接syscall,具有干净的调用栈。宏期望以下参数

  • 第一个参数是一个包含您要执行的syscall的NT函数名称的字符串。
  • 第二个参数是一个布尔值,表示是否保留起始函数帧。如果您不确定这一点,请将其设置为 false,这始终可以保证良好的调用栈。
  • 以下参数是要发送给NT函数的参数。

参数传递

为了将这些宏传递不同类型的参数,必须注意以下事项

  • 任何可以转换为 usize(u8-u64、i8-i64、bool等)的基本数据类型可以直接传递给宏。
  • 大小为8、16、32或64位的结构和联合被视为同大小的整数传递。
  • 大于64位的结构和联合必须作为指针传递。
  • 字符串(&strString)必须作为指针传递。
  • 空指针(ptr::null()ptr::null_mut()等)作为0(无论它是u8u16i32还是其他任何类型)传递。
  • 目前不支持浮点数和双精度参数。
  • 任何其他数据类型必须作为指针传递。

示例

调用Sleep

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);

调用OpenProcess

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: *mut c_void = unwinder::call_function!(open_process, false, desired_access, inherit, pid);
let handle: HANDLE = std::mem::transmute(handle);
println!("Handle id: {:x}", handle.0);

注意,该宏返回一个 *mut c_void,它可以直接转换为 HANDLE,因为这两种数据类型具有相同的大小。这允许访问 OpenProcess 返回的值,即目标进程的新句柄。

作为间接syscall调用NtDelayExecution

let large = 0x8000000000000000 as u64; // Sleep indefinitely
let large: *mut i64 = std::mem::transmute(&large);
let alertable = false;
let ntstatus: *mut c_void = unwinder::indirect_syscall!("NtDelayExecution", false, alertable, large);
println!("ntstatus: {:x}", ntstatus as usize);

注意,该宏返回一个 *mut c_void,可以用来检索 NTSTATUS,它是 NtDelayExecution 返回的。

连接宏调用

欺骗过程可以多次拼接,而不会导致调用栈大小异常增加。执行流程也将得到保留。以下代码是一个示例

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.
	}
}

注意事项

初始帧

如果您将第二个参数设置为 true(两个宏),欺骗过程将尝试在调用栈中保留线程起始地址的帧,以增加合法性。

Call stack spoofed keeping the main module.

有时,线程的起始函数不会执行后续函数的 call(例如,执行 jmp 指令),这意味着没有返回地址被压入栈中。在这种情况下(以及如果您将第二个参数设置为 false),欺骗的调用栈将始于 BaseThreadInitThunk 的帧。

Call stack spoofed without main module.

PoC

为了测试该技术的实现,使用了带有标志 /threadsPE-sieve。测试结果显示,在使用该程序的功能时,通过检查调用栈并不能发现有效载荷的存在。如图二所示,当不使用回溯器时,可以检测到有效载荷。

PE-sieve results when unwinder is used. PE-sieve results when unwinder is not used.

堆栈替换

技术描述

这是一种替代 SilentMoonWalk 的调用栈欺骗技术,可以在程序执行期间保持干净的调用栈。该技术背后的主要思想是,模块内部被调用的每个函数都负责处理之前压入的返回地址,在运行时找到一个与要欺骗的返回地址具有相同帧大小的合法函数。一旦找到具有相同帧大小的合法函数,就会计算其中的偏移量,并使用最终的地址来替换最后的返回地址,从而隐藏调用栈中的任何异常条目,并保持其可展开性。原始的返回地址被 unwinder 存储,并在执行返回指令之前将其移回堆栈的正确位置,从而允许程序继续正常执行。

这是一个实验性功能,尽管它已经完全功能化,但仍处于开发和研究中,因此如果您决定将其集成到代码中,请确保测试您的代码。

如何使用它

要使用堆栈替换功能,您应该在您的 cargo.toml 中添加以下行,并在 release 模式下编译

[dependencies]
unwinder = {version = "0.1.2", features = ["Experimental"]}

此功能的主要功能已被以下宏封装

  • start_stack_replacement!()/end_replacement!() 这对宏指示 unwinder 开始/结束堆栈替换过程。这两个宏必须在您的代码的入口点(例如,在您的 dll 的导出函数中)调用。
  • replace_and_continue!()/restore!() 这对宏执行最后一个返回地址的替换/恢复。
  • 最后,replace_and_call!()/replace_and_syscall!() 这对宏用于在需要调用当前模块之外的功能时执行堆栈替换(例如,使用 Windows API 或调用其他 dll 的代码)。这两个宏都会返回一个 *mut c_void,其中包含以这种方式调用的函数返回的值(即,它们与用于执行 SilentMoonWalk 的宏 call_functionindirect_syscall 的操作方式相同)。

要使用这些宏,需要导入 std::ffi::c_void 数据类型。所有使用这些宏的函数都应该用 #[no_mangle]#[inline(never)] 属性标记,以防止 Rust 编译器在优化过程中内联它们。

在深入实际示例以展示如何使用所有这些内容之前,先快速检查一下 replace_and_call/replace_and_syscall 这对宏以及如何向它们传递预期的参数。

replace_and_call

此宏用于在当前模块外部调用任何所需的函数,同时使用堆栈替换,并且具有干净的调用堆栈。此宏期望以下参数

  • 第一个参数是要调用的函数的内存地址。此参数应以 usizeisize 或指针的形式传递。
  • 以下参数是要发送到指定函数的参数。它们遵循 参数传递 部分中指定的相同规则。

replace_and_syscall

此宏用于使用堆栈替换执行任何所需的间接系统调用,同时具有干净的调用堆栈。此宏期望以下参数

  • 第一个参数是一个包含您要执行的syscall的NT函数名称的字符串。
  • 以下参数是要发送到 NT 函数的参数。它们遵循 参数传递 部分中指定的相同规则。

示例

我认为通过实际示例展示这些宏的使用是最好的方法。假设我们正在创建一个将被 反射注入 到内存的 dll。此 dll 将导出两个函数 ExportAExportB,因此我们将这两个函数视为模块的入口点。它们都必须在开始时调用 start_stack_replacement 宏,并且在返回之前也必须调用逆 end_replacement 宏。start_stack_replacement期望模块的基本地址作为参数,或者如果您不知道在运行时该地址,可以传递 0,宏将尝试自行解决。

#[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
}

开始堆栈替换过程涉及手动创建一个新堆栈,该堆栈将在调用 end_replacement 宏之前使用。

尽管从理论上讲,从头开始创建新堆栈可能不是必需的,但我已经决定以这种方式实现过程,以确保稳定性和防止任何东西损坏。

现在,假设我们的 ExportedA 函数调用了其他两个内部函数。这两个内部函数负责替换/恢复原始返回地址,该地址将指向 ExportedA 内的某个位置,除非我们小心处理,否则将破坏调用堆栈。此替换过程涉及将我们的内部函数的代码包裹在 replace_and_continuerestore 宏之间。

#[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
} 

最后,internal_ainternal_b 函数都使用了某些 Windows API 功能。为了保持可恢复的调用堆栈,这些调用应通过 replace_and_call(常规调用)或 replace_and_syscall(间接系统调用)宏来执行。

#[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::get_module_base_address("kernel32.dll");
    let load_library = 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
} 

备注

由于这是一个开发中的功能,必须注意一些事情

  • 如果在加载过程中删除PE的头信息,必须在start_stack_replace宏中传递模块的基本地址。目前,它无法自己找到它(将在下一个更新中解决)。
  • 如果您对此感到好奇,堆栈替换使用与SilentMoonWalk技术相同的jmp rbx + 隐藏帧的组合。这仅在使用replace_and_callreplace_and_syscall宏时发生,并计划在下一个更新中进行更改。
  • replace_and_callreplace_and_syscall宏返回一个*mut c_void,可以用来检索通过它们执行的功能返回的值。这与为call_functionindirect_syscall宏所描述的行为相同。
  • replace_and_callreplace_and_syscall宏允许最多11个参数。

请报告使用此功能时可能出现的任何错误。

依赖项

134MB
~2M SLoC