你将学到什么:
-
WinAPI函数手册位置及汇编代码
-
PEB 结构和 PEB_LDR_DATA
-
PE文件结构
-
相对虚拟地址计算
-
导出地址表 (EAT)
-
Windows x64 调用约定实践
-
像真正的 Giga-Chad 一样用汇编语言写作......
shellcode 有什么限制?
Shellcode 必须与位置无关。它不能假设任何固定地址。因此,shellcode 无法访问我们通常用一行代码在 C 中执行的函数。Shellcode 必须在任何地方都能正常工作,没有任何依赖关系!
上述陈述使我们得出一个显而易见的结论:在 shellcode 中我们不能简单地使用GetProcAddress()和获取任何 WinAPI 函数的地址……因为我们不知道GetProcAddress()函数本身的地址。
WinExec()在这篇文章中,我们将研究手动查找函数地址kernel32.dll并执行calc.exe程序(Windows 内置计算器)以确认一切正常。
概述:如何手动查找 WinAPI 函数?
所有基本的 WinAPI 函数都可以在这个kernel32.dll文件中找到。这个模块会自动加载到 Windows 中每个新创建的进程内存中。但是,模块在内存中的基地址kernel32.dll可能是随机的。
Windows 上的每个进程还在其内存中包含一个PEB(进程环境块)结构。该结构的地址是已知的,一切都从它开始。该结构包含有关进程的大量信息,包括有关所有已加载模块(包括kernel32.dll)的数据。我们可以在其中找到kernel32.dll模块的基内存地址等信息。
然后,通过读取 PE 文件的结构(kernel32.dll),我们得到导出地址表,其中包含文件导出的所有函数的名称和地址。
这样我们就找到了 WinAPI 函数的地址(WinExec)。然后我们就可以根据 x64 调用约定和 Microsoft 的文档来执行它。
简而言之,这就是整个过程:跳过内存结构和指针来寻找我们的函数。让我们看看它的具体内容...
获取PEB结构地址
第一步是找到 PEB 结构的地址。PEB (进程环境块)包含有关当前进程的大量信息。PEB 的关键属性包括:
-
已加载模块(LDR字段:我们想要这个!)
-
环境变量
-
命令行参数
-
有关该过程的其他信息
PEB 结构存储在进程的用户空间中。这意味着,无需任何系统调用即可手动读取它。对于 x64 架构,PEB 地址存储在gs寄存器 + 0x60 偏移量中。fs和gs段寄存器没有硬件定义的特定用途,因此在这种情况下,Windows 内部使用它们来保存重要地址。
mov rbx, gs:[0x60] ; Get address of PEB struct
获取PEB_LDR_DATA的地址
PEB_LDR_DATA(PEB 加载器数据)包含有关进程已加载模块的信息。我们需要访问此结构来获取地址kernel32.dll。
typedef struct _PEB {
BYTE Reserved1[2]; // 2 bytes
BYTE BeingDebugged; // 1 byte
BYTE Reserved2[1]; // 1 byte
PVOID Reserved3[2]; // 16 bytes
PPEB_LDR_DATA Ldr; // <-- We want this
// ...
}
从 PEB 结构的开头到该LDR字段有 20 个字节。然而,事实并非如此!这一切都是因为一种称为数据结构对齐的编译现象。在 64 位 Windows 上,内存结构的对齐通常为 16 个字节。在这种情况下这并不重要。但重要的是 64 位指针与 8 字节边界对齐。这意味着,内存中指针的地址不能不同于的乘积0x8。让我们数一下PVOID Reserved3字段前的字节数:4 个字节!Reserved4指针必须与 4 个字节对齐才能将其地址四舍五入为0x8字节。阅读有关数据结构对齐的更多信息。
最终的 PEB 结构如下所示(包含填充):
struct _PEB {
BYTE Reserved1[2]; // 2 bytes
BYTE BeingDebugged; // 1 byte
BYTE Reserved2[1]; // 1 byte
BYTE Padding[4]; // 4 bytes
PVOID Reserved3[2]; // 16 bytes
PPEB_LDR_DATA Ldr; // <-- We want this
// ...
};
现在我们可以清楚地看到,我们需要 24 个字节(0x18)来获取 LDR 字段。我们通过取消引用(方括号)来提取该字段的值:
mov rbx, [rbx+0x18] ; Get PEB_LDR_DATA address
获取已加载模块的地址
现在我们有了 PEB_LDR_DATA 结构,我们需要该字段的地址InMemoryOrderModuleList:
struct _PEB_LDR_DATA {
BYTE Reserved1[8]; // 8 bytes
PVOID Reserved2[3]; // 24 bytes
LIST_ENTRY InMemoryOrderModuleList;
};
add rbx, 0x20 ; Get address of InMemoryOrderModuleList
LIST_ENTRY实际上是双链表。第一个字段(我们刚刚提取的字段)是指向下一个列表条目的指针。通过取消引用地址,我们可以获取列表中的各个项目。沿着双链表向下走:
mov rbx, [rbx] ; 1st entry in InMemoryOrderModuleList (ntdll.dll)
mov rbx, [rbx] ; 2st entry in InMemoryOrderModuleList (kernelbase.dll)
mov rbx, [rbx] ; 3st entry in InMemoryOrderModuleList (kernel32.dll)
第三个条目是kernel32.dll。我不确定这是否有保证,但几个世纪以来人们一直都是这样做的。我有什么资格质疑这一点……
struct _LDR_DATA_TABLE_ENTRY {
..
LIST_ENTRY InMemoryOrderLinks; // 16 bytes
PVOID Reserved2[2]; // 16 bytes
PVOID DllBase;
...
};
当我们沿着双向链表向下移动时(LIST_ENTRY),我们已经处于结构开头的偏移量。现在我们必须获取指针DllBase。DllBase是内存中 DLL 的地址!。偏移量为 32 字节(0x20):
mov r8, [rbx+0x20] ; Get the kernel32.dll address
现在我们有了kernel32.dll基地址。
获取ExportTable(kernel32.dll)的地址
我们需要进入ExportTable模块kernel32.dll来获取有关其导出的 WinAPI 函数的信息。
PE文件结构(简化):
-
IMAGE_DOS_HEADER(我们需要获取e_lfanew RVA)
-
DOS Stub(跳过此部分)
-
PE 头(kernel32.dll基地址 + e_lfanew RVA)
-
ExportTable(PE Headers addr 的偏移量 = 0x70)
这是我们需要走的路:
让我们看一下IMAGE_DOS_HEADER。它是任何 PE 文件的第一个结构:
typedef struct _IMAGE_DOS_HEADER { // DOS Header
WORD e_magic; // Magic number (2)
WORD e_cblp; // Bytes on last page of file (2)
WORD e_cp; // Pages in file (2)
WORD e_crlc; // Relocations (2)
WORD e_cparhdr; // Size of header in paragraphs (2)
WORD e_minalloc; // Minimum extra paragraphs needed (2)
WORD e_maxalloc; // Maximum extra paragraphs needed (2)
WORD e_ss; // Initial (relative) SS value (2)
WORD e_sp; // Initial SP value (2)
WORD e_csum; // Checksum (2)
WORD e_ip; // Initial IP value (2)
WORD e_cs; // Initial (relative) CS value (2)
WORD e_lfarlc; // File address of relocation table (2)
WORD e_ovno; // Overlay number (2)
WORD e_res[4]; // Reserved words (8)
WORD e_oemid; // OEM identifier (for e_oeminfo) (2)
WORD e_oeminfo; // OEM information; e_oemid specific (2)
WORD e_res2[10]; // Reserved words (20)
LONG e_lfanew; // File address of new exe header (4)
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
我们需要获取字段的值。它包含 PE Headers(或新 EXE Headers e_lfanew )的 RVA 。
相对虚拟地址:PE 文件结构中的许多地址都以相对虚拟地址(RVA)的形式书写。这意味着它们相对于内存中文件的开头(基地址)。要计算(绝对)虚拟地址,我们需要将 RVA 地址添加到基地址kernel32.dll。
mov ebx, [r8+0x3c] ; RBX = kernel32.IMAGE_DOS_HEADER.e_lfanew (PE hdrs offset)
add rbx, r8 ; RBX = PeHeaders offset + &kernel32.dll = &PeHeaders
现在rbx存储 PE Headers 的地址。在0x88 PE Headers 的偏移处ExportTable RVA放置了。它是一个常量值。使用 ExportTable RVA 和kernel32.dll基址,我们就可以访问了ExportTable。
xor rcx, rcx
add cx, 0x88 ; RCX = 0x88 (offset of ExportTable RVA)
add rbx, [rbx+rcx] ; RBX = &PeHeaders + offset of ExportTable RVA = ExportTable RVA
add rbx, r8 ; RBX = ExportTable RVA + &kernel32.dll = &ExportTable
mov r9, rbx ; R9 = &ExportTable
从EAT获取WinAPI函数地址
现在我们有了 EAT 结构的地址。此结构包含有关导出函数的所有信息。我们想使用此结构找到 WinAPI 函数的地址WinExec()。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA
DWORD AddressOfNames; // RVA
DWORD AddressOfNameOrdinals; // RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
在开始搜索之前,我们需要保存包含我们要查找的 WinAPI 函数名称的字符串。我们无法按照传统方式在节read-only data或类似的地方执行此操作,因为我们只有节.text。我们必须将所有内容放在堆栈上!
堆栈向下增长,地址向上读取,所以我们必须将字符串倒置(WinExec�-> �cexEniW)。所有字母都转换为十六进制值。在开始时,我们推送空终止符。
xor rax, rax
push rax ; STACK + null terminator (8)
mov rax, 0x00636578456E6957 ; RAX = function name = � + "cexEniW" (WinExec)
push rax ; STACK + function name address (8)
mov rbx, rsp ; RSI = &function_name
call get_winapi_func
现在我们已经有一个指向函数名称字符串的指针。
警告:现在会变得有点复杂。我不会描述每一行汇编代码。我将介绍一般概念,并在最后粘贴代码片段。
总的来说,一切都归结为遍历指向函数名称的整个指针数组 ( AddressOfNames) 并将它们与指向我们想要的函数名称的指针进行比较。可能最有趣的部分是repe cmpsb命令。它用于比较两个字符串(指针保存在RDI和RSI寄存器中)。
一旦我们找到正确的函数名,我们的计数器(RAX寄存器)就会保存其索引。使用这个索引,我们可以引用数组中的一项AddressOfNameOrdinals。使用从这个数组中提取的序数,我们最终引用数组中的项AddressOfFunctions。在这里我们获得函数的 RVA WinExec,计算 VA 并返回寄存器中的地址RAX。就是这样!我们得到了我们正在寻找的函数的地址。
get_winapi_func:
; Requirements (preserved):
; R8 = &kernel32.dll
; R10 = &AddressOfFunctions (ExportTable)
; R11 = &AddressOfNames (ExportTable)
; R12 = &AddressOfNameOrdinals (ExportTable)
; Parameters (preserved):
; RBX = (char*) function_name
; RCX = (int) length of function_name string
; Returns:
; RAX = &function
;
; IMPORTANT: This function doesn't handle "not found" case!
; Infinite loop and access violation is possible.
xor rax, rax ; RAX = counter = 0
push rcx ; STACK + RCX (8) = preserve length of function_name string
; Loop through AddressOfNames array:
; array item = function name RVA (4 bytes)
loop:
xor rdi, rdi ; RDI = 0
mov rcx, [rsp] ; RCX = length of function_name string
mov rsi, rbx ; RSI = (char*) function_name
mov edi, [r11+rax*4] ; RDI = function name RVA
add rdi, r8 ; RDI = &FunctionName = function name RVA + &kernel32.dll
repe cmpsb ; Compare byte *RDI (array item str) and *RSI (param function name)
je resolve_func_addr ; Jump if exported function name == param function name
inc rax ; RAX = counter + 1
jmp short loop
resolve_func_addr:
pop rcx ; STACK - RCX (8) = remove length of function_name string
mov ax, [r12+rax*2] ; RAX = OrdinalNumber = &AddressOfNameOrdinals + (counter * 2)
mov eax, [r10+rax*4] ; RAX = function RVA = &AddressOfFunctions + (OrdinalNumber * 4)
add rax, r8 ; RAX = &function = function RVA + &kernel32.dll
ret
执行 WinExec 函数
WinExec函数的定义如下所示。我们有一个指向该函数的指针。很酷,不是吗?
UINT WinExec(
LPCSTR lpCmdLine, // => "calc.exe",0x0
UINT uCmdShow // => 0x1 = SW_SHOWNORMAL
);
现在我们只需要执行此功能并牢记一件非常重要的事情:Windows x64 调用约定(文档)。
使用 WinAPI 的三个重要要求:
-
参数寄存器(从左到右):RCX(lpCmdLine),RDX(uCmdShow),,,R8然后R9堆栈...
-
16 字节堆栈对齐:and rsp, -16
-
影子空间——堆栈上分配的 32 字节长的空白空间,供 WinAPI 内部使用:sub rsp, 32
牢记上述规则,我们开始准备参数。同样,将要执行的程序的名称字符串 ( calc.exe) 推送到堆栈上,并将其地址传递到第一个参数中。我们将第二个参数设置为SW_SHOWNORMAL值 ( 0x1),这仅表示显示默认进程窗口。
xor rcx, rcx
xor rdx, rdx
push rcx ; STACK + null terminator (8)
mov rcx, 0x6578652e636c6163 ; RCX = "exe.clac" (command string: calc.exe)
push rcx ; STACK + command string (8)
mov rcx, rsp ; RCX = LPCSTR lpCmdLine
mov rdx, 0x1 ; RDX = UINT uCmdShow = 0x1 (SW_SHOWNORMAL)
and rsp, -16 ; 16-byte Stack Alignment
sub rsp, 32 ; STACK + 32 bytes (shadow space)
call r13 ; WinExec("calc.exe", SW_SHOWNORMAL)
成后,我们可以开始编译了!
编译和执行
这里就不多说了。我用 Python 写了一个简单的脚本(shellcoder.py),将 NASM 代码编译成可执行的 EXE 格式。这样一来,我们就可以非常轻松地调试我们的“shellcode”,只需单击一下即可更正并再次编译。
编译成功后,我们就可以运行了!
好的。
结论
学习从 Assembly 中对 WinAPI 函数的低级访问具有极大的发展意义。它可以让您更好地了解恶意软件,而 shellcode 现在通常是恶意软件的主要组成部分之一。不幸的是,出于某种原因,如今很少有人参与编写 shellcode。但那些自己编写 shellcode 的人都是 Giga-Chads。
~Print3M
原文始发于微信公众号(Ots安全):[Shellcode x64] 使用 Assembly 查找并执行 WinAPI 函数
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论