Windows API 与编程调用
从这个章节起,我们将学习一些有关编程与二进制的技能,为下个章节的恶意软件以及安全工具开发奠定基础。学习恶意软件开发的原因有多种,从进攻型安全的角度来看,我们通常需要针对客户的环境执行某些恶意任务,当涉及到参与中使用的工具类型时,通常有三个主要选择。第 1 种是开源工具,这些工具通常由安全供应商签名甚至没有签名,并且会在安全性相对任何成熟的组织中被检测到,因此在执行攻击的时候,它们并不总是可靠的。第 2 种是购买工具,预算较大的团队通常会选择购买工具,以便在项目期间节省宝贵的时间。这些工具通常是闭源的,并且具备更加出色的免杀能力。以及,开发定制工具。因为这些工具是定制的,所以它们没有被安全供应商们分析或标记,这使得攻击者在免疫检测方面具有优势,这就是恶意软件开发知识对于更成功的红队行动至关重要的地方。
Windows 架构
Windows 架构
为了学习较为底层的理论,我们首先需要了解 Windows 架构。运行 Windows 操作系统的机器内的处理器可以在两种不同的模式下运行:用户模式 (又称为 Ring 3) 和内核模式 (又称为 Ring 0)。
应用程序运行在用户模式下,操作系统组件运行在内核模式下。大多数用户活动将发生在用户模式,但应用程序也会在需要时过渡到内核模式。Win32 API (例如 kernel32.dll) 旨在成为开发人员的第一个调用端口。 然后这些 API 将调用较低级别的 API,例如 ntdll.dll。Microsoft 有意不记录大多数 NTAPI,并且可以随时对其进行更改。他们可能会更改其他用户模式 DLL 与 NTDLL 交互的方式,只要原始用户模式 DLL 接口不变即可
当应用程序想要完成一项任务时,例如创建文件,它无法自行完成。 唯一可以完成任务的实体是内核,因此应用程序必须遵循特定的函数调用流程。
我们来看看流程中所涉及到的重要概念:
用户进程:由用户执行的应用程序,例如记事本、Chrome 浏览器或 Microsoft Word。
子系统 DLL:包含用户进程调用的 API 函数的 DLL,例如 kernel32.dll 导出 CreateFile Windows API 函数,其他常见的子系统 DLL 例如 ntdll.dll、advapi32.dll、user32.dll 等。
ntdll.dll:系统范围的 DLL,它是用户模式下可用的最低层。这是一个特殊的 DLL,用于创建实现从用户模式到内核模式的转换,这通常称为原生 API (Native API)或 NTAPI。
执行内核:这就是所谓的 Windows 内核,它调用内核模式中可用的其他驱动程序和模块来完成任务。Windows 内核部分存储在 C:\Windows\System32 下名为 ntoskrnl.exe 的文件中。
函数调用
以创建文件为例子,应用程序可以调用 kernel32.dll 中的 CreateFileW 从磁盘打开文件,其中 Kernel32.dll 是一个关键的 DLL,被大多数应用程序加载。然后 CreateFileW 将调用对应的 NTAPI 函数: ntdll.dll 中的 NtCreateFile,而 ntdll.dll 又使用系统调用 (syscall) 转换到内核 (ntoskrnl.exe) 以访问文件系统硬件,从调用堆栈的角度来看,这类似于 UserApp.exe -> kernel32.dll -> ntdll.dll -> ntoskrnl.exe。
打开 WinDBG (当前为演示目的,稍后小节教授具体用法),附加到 Notepad.exe 进程。给 kernel32 的 CreateFileW 设置断点,随即,我们便到达了调用该 WinAPI 的地址。不过跟我们想象的略有不一样,这个地址并没有执行 CreateFileW 的实际代码,而是跳转到了 kernelbase.dll 的 CreateFileW。这是因为在最近几年的 Windows 版本里,advapi32.dll 与 kernel32.dll 中的部分 API 被迁移到了新的 DLL 文件 kernelbase.dll 中。
0:002> bp kernel32!createfilew
0:002> g
Breakpoint 0 hit
KERNEL32!CreateFileW:
00007ffd`2ba60760 ff25da2c0600 jmp qword ptr [KERNEL32!_imp_CreateFileW (00007ffd`2bac3440)] ds:00007ffd`2bac3440={KERNELBASE!CreateFileW (00007ffd`2a0f4f80)}
0:000> p
KERNELBASE!CreateFileW:
00007ffd`2a0f4f80 488bc4 mov rax,rsp
前进一步,继而进入 Kernelbase 中的 CreateFileW API。
从 CreateFileW 到 NtCreateFile 中间还有着大量的指令,但最终我们看到了调用 Ntdll 中 NtCreateFile 的指令。
给该位置设置断点,继续执行,果然到达了 NtCreateFile API 的入口。
下图,则是 NtCreateFile 的 syscall stub (执行 syscall 的代码段),而高亮的 55h,这里就是对应的 syscall 编号了。
syscall stub 的模式如下:
mov r10, rcx
mov eax, <syscall 编号>
syscall
ret
syscall 编号,即 SSN,在不同版本直接可能有所不同,我们可以在 https://j00ru.vexillium.org/syscalls/nt/64/ 网站上查询各个操作系统版本的 SYSCALL 列表。
实际上应用程序可以直接调用 syscall,而无需通过 WinAPI,WinAPI 只是充当原生 API 的包装器。话虽如此,但原生 API 更难使用,因为它们没有被 Microsoft 正式记录。此外,微软也建议不要直接使用原生API 函数,因为它们会在没有告知的情况下随时更改。
Windows 内存管理
该部分会介绍 Windows 内存的基础知识,因为了解 Windows 如何处理内存对于编写高级恶意软件至关重要。
内存与分页
现代操作系统中的内存是不直接映射到物理内存 (RAM) 的,相反,进程使用的虚拟内存地址由映射到物理内存地址的进程使用。 这,有几多个原因,但最终目标是尽可能多地节省物理内存。 虚拟内存可以映射到物理内存,但也可以存储在磁盘上。 通过虚拟内存寻址,多个进程可以共享同一个物理地址,同时拥有唯一的虚拟内存地址。 虚拟内存依赖于内存分页的概念,它将内存分成 4kb 的块,称为页。
在进程虚拟地址空间中的页可以处于 3 种状态之一:
Free (空闲):页面既未提交也未保留,进程无法访问该页,它可被保留、提交,或同时保留与提交。尝试读取或写入空闲页可能会导致访问异常。
Reserved (保留):该页面已保留供之后使用,地址范围不能被其他分配函数所使用。该页不可访问,并且没有与之关联的物理存储。该页可被提交。
Committed (提交):内用已从 RAM 和磁盘上的分页文件中分配,该页可被访问,并且访问由内存保护常数控制。 只有在第一次尝试读取或写入该页面时,系统才会初始化每个提交的页并将其加载到物理内存中。当进程终止时,系统释放已提交页的存储。
当页被提交后,需要有保护选项被设置,我们可以从微软官方文档 https://learn.microsoft.com/en-us/windows/win32/memory/memory-protection-constants 中看到不同的常量所对应的内存保护设置,一些常用的如下:
名称 | 常数 | 描述 |
PAGE_EXECUTE | 0x10 | 提交的页可执行 |
PAGE_EXECUTE_READWRITE | 0x40 | 提交的页可读可写可执行 |
PAGE_READWRITE | 0x04 | 提交的页可读或可读可写 |
内存保护
现代操作系统通常具有内置的内存保护功能来阻止漏洞利用和攻击,在编写或调试恶意软件时我们很可能会遇到它们。在较新的 Windows 版本中,都是默认开启的。
数据执行保护 (DEP):DEP 是一种系统级内存保护功能,如果页保护选项设置为 PAGE_READONLY,则 DEP 将阻止代码在这片内存区域中执行。例如在缓冲区溢出的漏洞利用中,开启 DEP 后将不能在栈区域执行 Shellcode。
地址空间布局随机化 (ASLR):ASLR 随机分配进程关键数据区域的地址空间位置,包括可执行文件的基址、栈、堆、库的位置。在恶意软件开发与漏洞利用开发中,这意味着我们不能硬编码特定 API 的地址。
内存操作
与内存交互的第一部便是分配内存。以 C 语言为例,我们有多种方式实现内存分配:C 中的 malloc、HeapAlloc、LocalAlloc 函数,以及 VirtualAlloc API。
#include <iostream>
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <Wincrypt.h>
void print_memory(PVOID start, size_t size)
{
unsigned char* ptr = (unsigned char*)start;
for (size_t i = 0; i < size; ++i) {
printf("%02x ", ptr[i]);
if (i % 16 == 15) {
printf("\n");
}
}
printf("\n");
}
int main()
{
// 使用malloc()
PVOID pAddress1 = malloc(100);
// 使用HeapAlloc()
PVOID pAddress2 = HeapAlloc(GetProcessHeap(), 0, 100);
// 使用LocalAlloc()
PVOID pAddress3 = LocalAlloc(LPTR, 100);
// 使用VirtualAlloc API
PVOID pAddress4 = VirtualAlloc(0, 100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
printf("Base address of allocated memory: 0x%p\n", pAddress1);
printf("Base address of allocated memory: 0x%p\n", pAddress2);
printf("Base address of allocated memory: 0x%p\n", pAddress3);
printf("Base address of allocated memory: 0x%p\n", pAddress4);
return 0;
}
这些函数执行成功后会返回被分配的内存的起始地址,之后根据内存的保护措施,该指针可被用于后续的行动,例如读、写、执行。我们看到,分配的内存可能包含一些随机字符,有的函数支持初始化特定的字符。
然后,我们可以对内存进行写操作。在 C 里,我们用 memcpy 函数来实现。
const char* cString = "abcdefgh";
memcpy(pAddress1, cString, strlen(cString));