sRDI 0 to 1

sRDI 对 ReflectiveDLLInjection 技术进行了改进,实现了一种将 DLL 转换为 shellcode 的技术,我们可以通过分析 sRDI 的技术原理来学习如何在 Windows 中无文件执行 PE 文件。本篇文章会介绍 sRDI 的发展史和技术原理,以及如何使用 sRDI 来武器化我们的工具。

项目地址:https://github.com/monoxgas/sRDI

Blog:https://www.netspi.com/srdi-shellcode-reflective-dll-injection/

RDI 技术的历史

简单回顾一下恶意软件如何在 Windows 中执行自定义代码:

最原始的方法是上传一个 exe 文件到某个目录并执行,这种方法简单粗暴,缺点也很明显:会创建一个新进程,因此特征较为明显。当恶意软件不再希望创建一个新进程,想要转为在远程进程中执行代码,DLL 注入技术便开始被使用。传统的 DLL 注入技术依赖 LoadLibrary 函数加载 DLL 到内存中,并调用 CreateRemoteThread 执行代码。DLL 注入也存在一些问题:一方面 LoadLibrary 仅支持从文件读取 DLL,不支持内存加载,随着安全软件对恶意 PE 文件的静态检测能力逐渐增强,将恶意代码直接写入文件系统的风险越来越大。这时候便需要一种通过内存加载执行 PE 文件的技术,于是有人开始研究 PE 文件格式,手动实现了 PE 文件的解析、加载和执行。此时 PE 文件的执行不再依赖 LoadLibrary,不再需要写入文件系统,转为了更难检测的无文件执行技术。

DLL 的内存执行技术分为两部分:加载器和 DLL。具体实现上有两个分支:一种是 MemoryModule,在加载器中实现了 MemoryLoadLibrary 加载 DLL;另一种是 ReflectiveDLLInjection(以下简称 RDI),在 DLL 中实现 ReflectiveLoader 函数,在加载器中实现 GetReflectiveLoaderOffset 函数计算 ReflectiveLoader 的偏移,然后执行 ReflectiveLoader 函数加载 DLL 并调用 DLLMain。著名的 Metasploit 就大量使用了 RDI 技术,它太过著名以至于将 RWX 内存分配变成了恶意行为的一重要指标。RDI 原作者已不再更新,现在由 Rapid7 和 Metasploit 社区维护:https://github.com/rapid7/ReflectiveDLLInjection。之后 Dan Staples 推出了 ImprovedReflectiveDLLInjection,通过添加引导程序增加了 RDI 调用导出函数、参数传递和跨架构注入等功能。

最后是本文中的 sRDI,相对于 RDI 技术,在灵活性和易用性上更进一步,改进了 RDI 的一些缺陷:位置无关的 shellcode 可以以多种方式用少量代码加载执行,shellcode 可以调用 DllMain 之外的导出函数,并向导出函数传递参数;RDI 每次修改都需要同 RDI 项目一同编译,加载器也需要包含特定代码,需要熟悉 RDI 技术才能使用;sRDI 只需要编写常规 DLL,不需要了解技术细节,生成 shellcode 只需要一个脚本;sRDI 分配了适当的内存权限,不再出现大量 RWX 内存,同时可以选择在内存中清除 PE 头,内存特征更少。

sRDI 的项目结构

sRDI 项目分为以下几个部分:

sRDI 的使用方法

快速上手

如果想要快速上手 sRDI,最简单的方式是使用 ConvertToShellcode.py。

准备一个 MyDLL_x86.dll,然后 python ConvertToShellcode.py MyDLL_x86.dll 即可得到 shellcode,一个 .bin 文件,添加其他参数可控制 shellcode 的行为。

命令行参数

input_dll,-h,-v:这三个参数的作用显而易见,分别是需要被转换的 DLL、显示 help 信息、显示版本。

-c,-i,-d:用于控制 shellcode 的加载逻辑。

项目作者对可选功能的说明:(ConvertToShellcode.py 中不包含对 CLEARMEMORY 的支持)

The PE loader code uses flags argument to control the various options of loading logic:

  • SRDI_CLEARHEADER [0x1]: The DOS Header and DOS Stub for the target DLL are completley wiped with null bytes on load (Except for e_lfanew). This might cause issues with stock windows APIs when supplying the base address as a psuedo HMODULE.

  • SRDI_CLEARMEMORY [0x2]: After calling functions in the loaded module (DllMain and any exports), the DLL data will be cleared from memory. This is dangerous if you expect to continue executing code out of the module (Threads / GetProcAddressR).

  • SRDI_OBFUSCATEIMPORTS [0x4]: The order of imports in the module will be randomized before starting IAT patching. Additionally, the high 16 bits of the flag can be used to store the number of seconds to pause before processing the next import. For example, flags | (3 << 16) will pause 3 seconds between every import.

-f,-u:sRDI 支持在调用 DllMain 后额外调用一个 DLL 的 “导出函数”

这里的 “导出函数” 加了引号是因为它不同于一般的导出函数,沿用源码中的名字我称它为 exportFunc

exportFunc 与常规导出函数有两处不同:

示例:

python ConvertToShellcode.py -f myfuction -u data -c -d 5 MyDLL_x86.dll

这会生成一个调用 DllMain 后调用 myfuction,参数为 data,加载时清除 PE 头,随机导入调用函数,每个导入延迟为 5 秒的 shellcode。

sRDI 的技术原理

ConvertToShellcode

首先看这个项目的核心函数 ConvertToShellcode,它通过将以下 4 部分合并为一个 blob,实现 DLL to Shellcode 的功能:

sRDI

Bootstrap

引导程序的功能:获取当前内存位置,向 RDI 传递参数。

32 位

位置独立:

call-pop:call next instruction 会将 eip/rip 值入栈,再 pop 将值取出就可以得到当前内存位置。

计算参数:

参数入栈:

栈指针和帧指针:

leave 等同于:

ebp 寄存器通常被用作帧指针,程序通过 ebp + 偏移量 的方式定位参数,但这不是必须的。

比如 MSVC 中 /Oy[-] 控制是否省略帧指针,没有帧指针的程序更难调试,但不影响执行。

调用 RDI,清理堆栈:

32 位 Bootstrap 采用 cdecl,对应 ShellcodeRDI 中调用约定的配置,ShellcodeRDI 中的调用约定为默认配置。

/Gd, the default setting, specifies the __cdecl calling convention for all functions except C++ member functions and functions that are marked __stdcall, __fastcall, or __vectorcall.

64 位

前两条指令同 32 位:

设置寄存器参数:x64 调用约定

保存寄存器和堆栈对齐:

寄存器的易失性与非易失性:rsi 和 rsp 是非易失性寄存器,更改之前需要先保存,并在过程结束时恢复。

shadow space:

x64 调用约定中的堆栈

The parameter area is always at the bottom of the stack (even if alloca is used), so that it will always be adjacent to the return address during any function call. It contains at least four entries, but always enough space to hold all the parameters needed by any function that may be called. Note that space is always allocated for the register parameters, even if the parameters themselves are never homed to the stack; a callee is guaranteed that space has been allocated for all its parameters. Home addresses are required for the register arguments so a contiguous area is available in case the called function needs to take the address of the argument list (va_list) or an individual argument. This area also provides a convenient place to save register arguments during thunk execution and as a debugging option (for example, it makes the arguments easy to find during debugging if they are stored at their home addresses in the prolog code). Even if the called function has fewer than 4 parameters, these 4 stack locations are effectively owned by the called function, and may be used by the called function for other purposes besides saving parameter register values. Thus the caller may not save information in this region of stack across a function call.

AMD 转换示例

设置堆栈参数:

调用 RDI,清理堆栈,恢复寄存器:

GetProcAddressR

GetProcAddressR 实现了类似 GetProcAddress 的功能,传入模块句柄和函数名,返回函数指针。

解析导出表:nt 头 --> 数据目录 --> 导出目录 --> 遍历导出目录

ShellcodeRDI

在阅读这部分代码之前,需要先了解两个话题:

Windows 中的 Shellcode vs PE

将代码编译为 PE 文件调用 Windows API 的基本流程是:

按照 stdcall 或者 x64 调用约定处理完参数后接一条 call 指令,call 指令后的地址指向导入表,通过加载器修改过的导入表中的地址跳转到对应的函数(这里 32/64 位的处理不尽相同,这里就不写出相关细节了)。在执行 PE 文件时由系统的 PE 加载器解析导入表,加载对应的 DLL 文件到内存中,解析 DLL 文件导出表,定位对应的导出函数。与 PE 文件不同,shellcode 是一段汇编指令,执行 shellcode 不会有 PE 加载器的参与,以上这些操作需要由 shellcode 自身实现,所以 Windows 中的 shellcode 一般通过 PEB 获取 DLL 基地址 + 解析导出表获取导出函数地址。

由于 ShellcodeRDI 这个项目最终通过编译为 PE 文件后提取 .TEXT 段的方式将 C 代码转换为 shellcode,因此这里的代码是 “shellcode 式” 的 C 代码,不依赖 .TEXT 段以外的数据:

API Hash

API Hash 是一种隐藏 Windows API 调用的技术,API Hash 实现类似于 GetProcAddress 的功能,但不使用字符串而是 hash。

传统的 API 调用方式会把信息存储在导入表中,使用 LoadLibrary + GetProcAddress 可以避免,但是这种方法会硬编码函数名字符串,依然容易检索。如果希望完全不使用函数名来调用 API,就需要使用 API Hash。

API Hash 的思路:选一种 hash 算法,确保常用 DLL 的导出函数计算 hash 后不冲突。当需要调用 API 时,遍历导出表,将每个导出函数名通过 hash 算法计算出 hash,与预先计算好需要调用的 API 函数名的 hash 对比,hash 相同就是指定的 API。

GetProcAddressWithHash

GetProcAddressWithHash 是一个通过 PEB 获取进程中 DLL 信息 + 对比哈希实现对 API 动态调用的函数。

获取 PEB 的地址:

通过 PEB 获取所有已加载模块的信息:DLL 基地址、DLL 名、DLL 导出目录 RVA。

遍历每个模块的导出目录,获取导出函数名地址。

将得到的 DLL 名和函数名进行 ROR13 运算得到 dwFunctionHash。

将 dwFunctionHash 与传入的参数 dwModuleFunctionHash 进行比较,如果相同,则返回对应导出函数的地址。

LoadDLL

ShellcodeRDI.c 中的 LoadDLL 实现了 DLL 的解析和加载,函数原型如下:

在 LoadDLL 的开始,首先调用了两次 GetProcAddressWithHash,获取了指向 LdrLoadDll 和 LdrGetProcedureAddress 的函数指针。

为什么是 LdrLoadDll 和 LdrGetProcedureAddress:这两个函数是 ntdll.dll 的导出函数,ntdll.dll 是进程初始化时第一个加载的 DLL,在任何进程中都可以保证 ntdll.dll 已加载,Kernel32.dll 则不然,在功能上分别对应 LoadLibrary 和 GetProcAddress。

通过以上两个函数,获取 Kernel32.dll 的模块句柄,获取相关 API 的函数指针。

分配内存

确认文件完整性,内存页对齐,分配读写权限内存。

处理 Header

根据 flags 判断如何处理 header,这里对应可选功能 SRDI_CLEARHEADER,ConvertToShellcode.py 的 -c 参数。

加载文件的其他部分

加载段,处理重定位,处理导入表、延迟导入表。

处理导入表

这里对应可选功能 SRDI_OBFUSCATEIMPORTS,ConvertToShellcode.py 的 -i 参数。

内存保护属性

设置内存保护属性,这里和 RDI 不同。

原始 RDI 直接分配了一大块 RWX 内存,这也成为了检测 RDI 的一个标志。如果去看系统上常见的进程中的内存保护属性,是很少有 RWX 的,因此检测 RWX 内存也不会出现大量数据。所以在编写工具的时候应该尽量避免 RWX 的出现。

dllMain,exportFunc

调用入口点和一个指定导出函数:

在这里我们可以看到,exportFunc 是有限制的,其函数原型必须为:

不能使用多个参数控制函数行为,如果需要多个参数,可以通过函数内拆解 userdata 来实现。

Loader.cpp 中的这部分可以体现它的用法:

SayHello 就是 (HMODULE)rdi() 执行时在 DllMain 后执行的 exportFunc。Uninstall 则是一个不必遵循函数原型限制的导出函数,因为它是在 shellcode 执行后通过 GetProcAddressR 获取地址后调用的。

Why exportFunc

既然有 GetProcAddressR 了,还没有限制,为什么还需要 DllMain 后调用一个函数这个功能?

这里我的理解是,作者希望遵循 Dynamic-Link Library Best Practices。当我们不需要 GetProcAddressR 时,只想执行一次 shellcode 就收工,在这种场景下,可以遵循最佳实践,在 DllMain 中做尽量少的事情,将主要工作交给 SayHello 这样的函数,还有一个好处就是 DllMain 有些行为是完全不能做的,导出函数不会有这样的限制。

这里涉及到 ConvertToShellcode.py 的 -f-u 参数,如果使用了这两个参数,需要在 DLL 中编写一个符合函数原型的导出函数,-f 后接函数名,-u 后接参数。

flags 参数传递

在 LoadDLL 中,通过参数 flags 决定是否使用可选功能。

可以在 FunctionTest 中找到 flag 传递参数的方式:

这里和 VirtualAlloc 的参数传递方式是一样的,如何实现涉及到一些位运算的东西。

阅读涉及到 flags 的代码后,我提取了 flags 控制函数行为的逻辑,实现了一个有同样参数传递机制的函数 test,同时显示了 flag 的各个位的变化。

对一个 32 位的 DWORD 数进行位操作,高 16 位存储 flag3 所需的数据,低 16 位中的 3 位用于表示相应的 flag 参数是否传入,这也解释了为什么 flags 是 124。

运行:

flags

sRDI 的修改与扩展

sRDI 虽然代码量并不是很大,但是由于包含多种编程语言和多个组件,扩展和修改并不是一目了然的事。

开发和构建流程

首先需要做的事就是整合项目中的各个组件的功能,还原作者的开发和构建流程:

至此大体上开发完成,剩下收尾工作。

理清开发和构建流程之后,我们做想要的修改就容易了。因为作者的开发流程是很工程化的,很多手动的的东西都做成了自动化脚本。

添加SRDI_CLEARMEMORY

前面提到过 ConvertToShellcode.py 中不包含对 CLEARMEMORY 的支持。

查看 SRDI_CLEARMEMORY 这部分代码:

向前追溯,发现这两个函数指针初始化为 NULL 后就没有再赋值了,所以 flags 包含了 SRDI_CLEARMEMORY 也不会触发这段代码的执行。

添加功能:同其他 API 一样,使用 FILL_STRING_WITH_BUF + pLdrGetProcAddress 获取函数指针。

可选:

修改后重新构建,得到 ShellcodeRDI_x64.bin 和 ShellcodeRDI_x86.bin。

使用 EncodeBlobs.py 将 bin 文件嵌入源代码:

这里有一点小问题:ConvertTo-Shellcode.ps1 没有替换成功,查看文件编码为带有 BOM 的 UTF,修改为 UTF-8 解决。

修改 ConvertToShellcode.py 添加 -m 参数:

现在在使用 ConvertToShellcode.py 是添加 -m 参数就会在 shellcode 运行后清除 dllData 了。