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));
在使用完分配的缓存后,为了避免内存泄漏,我们最好手动释放。根据分配内存时所使用的函数,释放函数也有所不同。malloc 分配的内存需要用 free 函数释放,HeapAlloc 分配的用 HeapFree 释放等。
Windows API
介绍
Windows API 为开发人员提供了一种让他们的应用程序与 Windows 操作系统交互的方法。例如,如果应用程序需要在屏幕上显示某些内容、修改文件或查询注册表,所有这些操作都可以通过 Windows API 完成。
数据类型
Windows API 涉及了很多种数据类型,是在我们所熟知的 int, float 等类型之外的,我们可以查看官方文档 https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types。
数据类型 | 含义 |
DWORD | 32位无符号整数型,数值范围从 0 到 2^32-1 |
size_t | 对象的尺寸,无符号整数型,范围根据操作系统位数有所不同 |
VOID | 空类型 |
PVOID | 指向任何数据类型的指针 |
HANDLE | 操作系统所管理的对象,如文件、进程、线程 |
HMODULE | 模块的句柄,例如 DLL 的基地址 |
LPCSTR/PCSTR | 以 null 结尾的 8 位 Windows ANSI 字符常量字符串的指针 |
LPSTR/PSTR | 类似于 LPCSTR/PCSTR,但不是常量,可写 |
LPCWSTR/PCWSTR | 以 null 结尾的 16 位 Windows Unicode 字符常量字符串的指针 |
PWSTR/LPWSTR | 类似于 LPCWSTR/PCWSTR,但不是常量,可写 |
wchar_t | 同 wchar,代表宽字符 |
指针
Windows API 允许开发人员直接声明数据类型或指向数据类型的指针,在数据类型名称中,以 P 开头的数据类型代表指向实际数据类型的指针,而不以 P 开头的数据类型代表实际数据类型本身。例如,PHANDLE 与 HANDLE* 相同,PSIZE_T 与 SIZE_T* 相同,PDWORD 与 DWORD* 相同。
ANSI 与 Unicode
大多数 Windows API 函数都有以 A 或 W 结尾的两个版本。 例如,有 CreateFileA 和 CreateFileW。其中以 A 结尾的函数表示 ANSI,而以 W 结尾的函数表示 Unicode 或 Wide。在适用的情况下,ANSI 函数采用 ANSI 数据类型作为参数,而 Unicode 函数将采用 Unicode 数据类型。例如,CreateFileA 的第一个参数是一个 LPCSTR,它是一个指向以 null 结尾的 8 位 Windows ANSI 字符常量字符串的指针,而 CreateFileW 的第一个参数是 LPCWSTR,它是一个指向以 null 结尾的 16 位 Unicode 字符常量字符串的指针。
此外,所需的字节数将根据使用的版本而有所不同。
char str1[] = "dler"; // 5 个字节(dler + 空字节)。
wchar str2[] = L"dler"; // 10字节,每个字符2字节(空字节也是2字节)
In 和 Out
Windows API 有输入和输出参数,IN 参数是传递给函数并用于输入的参数,而 OUT 参数是用于将值返回给函数调用者的参数。输出参数通常通过指针按引用传递。以我们之前讨论过的一个 API,LogonUserA 为例,诸如用户名、域名、密码等参数都是需要输入的参数,而该 API 调用成功后,有效令牌的句柄会被填充给 phToken 参数。
BOOL LogonUserA(
[in] LPCSTR lpszUsername,
[in, optional] LPCSTR lpszDomain,
[in, optional] LPCSTR lpszPassword,
[in] DWORD dwLogonType,
[in] DWORD dwLogonProvider,
[out] PHANDLE phToken
);
调用 API
在本课程中,我们主要使用 C++ 与 C# 编写恶意软件或工具,在不同的时候,这 2 种语言各有优势。对于要使用的 API 的用法,我们可以参考微软官方文档,例如 MessageBox 的 https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebox。 接下来,我们来看看如何分别在这 2 种语言里调用 Windows API。
在 C 中调用 MessageBox
打开 Visual Studio,新建 C++ 项目,选择 Console App。
在 C++ 中调用 WinAPI 十分直接与方便,这也是使用 C++ 编写恶意软件的优势之一。我们分别尝试 ANSI 与 Unicode 版的 MessageBox。
#include <windows.h>
int main() {
// MessageBoxA is the ASCII version of the function.
MessageBoxA(NULL, "Dler Security 2022", "Message", MB_OK);
// MessageBoxW is the wide character (Unicode) version of the function.
MessageBoxW(NULL, L"Dler Security 2023", L"Message", MB_OK);
return 0;
}
不出意外,A 与 W 版的 MessageBox 都成功弹出。
在 C# 中使用 P/Invoke中 调用 MessageBox
在 C# 中调用 API 需要多出一些步骤,我们先讨论 P/Invoke 方法调用 MessageBox。平台调用 (P/Invoke) 允许我们从托管代码 (例如 C#,VBA)访问非托管库 (例如 C/C++,Rust 等语言)中存在的结构和函数。顺便一提,托管代码是在托管运行环境中执行的,例如 .NET、JVM 等,运行环境处理内存管理、类型检测、异常处理等任务。而非托管代码,则是直接运行在机器硬件或者操作系统 API 之上,而无需中间运行环境管理代码的执行。在我们恶意软件开发过程中所常接触的语言里,C/C++ 代码是非托管的,而 C# 是托管的。
用 C/C++ 编写的应用程序和库被编译为机器代码,并且是非托管代码的案例。编程者必须手动进行内存管理等任务,例如每当他们分配内存时,之后必须记得释放回去。相反, C# 托管代码运行在 CLR。C# 等语言编译为中间语言 ,CLR 随后在运行时将其转换为机器代码。CLR 还处理垃圾收集和各种运行时检测。那么,为什么我们需要 P/Invoke 呢? 以 .NET 为例,.NET 运行时已经在底层使用了 P/Invoke,并为我们提供了运行在顶层的抽象。例如,要在 .NET 中启动一个进程,我们可以使用 System.Diagnostics.Process 类中的 Start 方法。如果我们在运行时跟踪此方法,我们将看到它使用 P/Invoke 来调用 CreateProcess API。但是,它并没有提供允许我们自定义传递到 STARTUPINFO 结构体数据的方法,这使我们无法执行诸如在挂起状态下启动进程之类的操作。
用 C# 的 System.Diagnostic 类来实现运行计算器,代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
class Program
{
static void Main()
{
// Create new instance of the Process class
Process process = new Process();
// Specify the executable to run
process.StartInfo.FileName = "calc.exe";
// Start the process
process.Start();
}
}
而底层的 API 则是 CreateProcess。
BOOL CreateProcessA(
[in, optional] LPCSTR lpApplicationName,
[in, out, optional] LPSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCSTR lpCurrentDirectory,
[in] LPSTARTUPINFOA lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
typedef struct _STARTUPINFOA {
DWORD cb;
LPSTR lpReserved;
LPSTR lpDesktop;
LPSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
DWORD dwYSize;
DWORD dwXCountChars;
DWORD dwYCountChars;
DWORD dwFillAttribute;
DWORD dwFlags;
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
HANDLE hStdOutput;
HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;
如果从 API 层面实现运行计算器,代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
[DllImport("kernel32.dll")]
public static extern bool CreateProcessA(
string lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
[In] ref STARTUPINFOA lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct STARTUPINFOA
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
static void Main()
{
STARTUPINFOA si = new STARTUPINFOA();
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
si.cb = Marshal.SizeOf(si);
CreateProcessA(null, "calc.exe", IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi);
}
}
运行结果是完全一样的。
对于其他诸多有用的 WinAPI,在 .NET 中没有公开,因此我们需要手动进行 P/Invoke。以 MessageBox 为例,我们可以访问 https://www.pinvoke.net/default.aspx/user32.messagebox 来查看 C# 中的特征。
[DllImport("user32.dll", SetLastError = true, CharSet= CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
新建 C# 项目,选择 Console App (.NET Framework)。
因为 C# 没有类似于 C++ 的头文件,我们需要手动声明所有的 Win API 以及结构体。首先,我们需要使用 DllImport 来声明该 API,意思是从 user32.dll 中导入函数 MessageBoxW。返回值类型是整数型, 参数有字符串类型以及无符号整型。考虑到 C++ 与 C# 的数据类型并不是一一对应的,因此我们需要做一些类型转换。最终代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType);
static void Main(string[] args)
{
MessageBoxW(IntPtr.Zero, "MessageBox!", "P/Invoke", 0);
}
}
调用成功。
P/Invoke 已经帮我们自动进行了类型转换,但有的时候可能也需要手动转换。例如对于 lpText 与 lpCaption 参数,我们可以通过 MarshalAs(UnmanagedType.LPWStr) 将其转换为 C# 里的 string 类型。
static extern int MessageBoxW(IntPtr hWnd, [MarshalAs(UnmanagedType.LPWStr)] string lpText, [MarshalAs(UnmanagedType.LPWStr)] string lpCaption, uint uType);
但当我们在恶意软件中使用了较为敏感的 API 的话,是可以通过例如 pestudio(https://www.winitor.com/download) 等工具来分析出 IoC 的。使用 pestudio 我们可以看到使用 P/Invoke 导入的 API,如果是恶意软件常用的那些,那么便可能引发检测。
MessageBoxW 也存在于二进制文件的字符串列表。
为了缓解上述问题,除了 API 的名称,我们还可以通过 API 的序数来指定。对于例如 user32.dll 等非托管 DLL,我们可以轻松地使用 PE-Bear 之类的 PE 文件分析器查看其导出列表 (用于给其他程序导入所用),我们可以看到 MessageBoxW 的序数为 86E,也就是十进制的 2158。关于函数名称、序数、函数 RVA、名称 RVA 等属性之间的关系,我们在稍后小节深入讨论,但目前我们得知序数便可。
我们给 DllImport 部分加入 EntryPoint 属性,并且自定义了函数名称,最终代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
[DllImport("user32.dll", EntryPoint="#2158", CharSet = CharSet.Unicode)]
static extern int demo(IntPtr hWnd, [MarshalAs(UnmanagedType.LPWStr)] string lpText, [MarshalAs(UnmanagedType.LPWStr)] string lpCaption, uint uType);
static void Main(string[] args)
{
demo(IntPtr.Zero, "Just a demo!", "P/Invoke", 0);
}
}
于是
再次使用 pestudio 检查文件,我们发现更名后的 MessageBoxW 看起来是个合法的函数,不过依旧能看到 P/Invoke 的使用,并且序数 2158 也得以显示。
在 C# 中使用 D/Invoke 调用 MessageBox
动态调用,即 D/Invoke,是一个开源的 C# 项目旨在代替 P/Invoke。相比 P/Invoke,D/Invoke 具有这些优势:无需通过 P/Invoke 调用非托管代码,将非托管 PE 文件手动映射至内存并且调用其入口或导出函数,为原生 API 生成 syscall 包装器。