Skip to main content

Windows API 与编程调用

从这个章节起,我们将学习一些有关编程与二进制的技能,为下个章节的恶意软件以及安全工具开发奠定基础。学习恶意软件开发的原因有多种,从进攻型安全的角度来看,我们通常需要针对客户的环境执行某些恶意任务,当涉及到参与中使用的工具类型时,通常有三个主要选择。第 1 种是开源工具,这些工具通常由安全供应商签名甚至没有签名,并且会在安全性相对任何成熟的组织中被检测到,因此在执行攻击的时候,它们并不总是可靠的。第 2 种是购买工具,预算较大的团队通常会选择购买工具,以便在项目期间节省宝贵的时间。这些工具通常是闭源的,并且具备更加出色的免杀能力。以及,开发定制工具。因为这些工具是定制的,所以它们没有被安全供应商们分析或标记,这使得攻击者在免疫检测方面具有优势,这就是恶意软件开发知识对于更成功的红队行动至关重要的地方。


Windows 架构

Windows 架构

为了学习较为底层的理论,我们首先需要了解 Windows 架构。运行 Windows 操作系统的机器内的处理器可以在两种不同的模式下运行:用户模式 (又称为 Ring 3) 和内核模式 (又称为 Ring 0)

image.png

应用程序运行在用户模式下,操作系统组件运行在内核模式下。大多数用户活动将发生在用户模式,但应用程序也会在需要时过渡到内核模式。Win32 API (例如 kernel32.dll) 旨在成为开发人员的第一个调用端口。 然后这些 API 将调用较低级别的 API,例如 ntdll.dll。Microsoft 有意不记录大多数 NTAPI,并且可以随时对其进行更改。他们可能会更改其他用户模式 DLL 与 NTDLL 交互的方式,只要原始用户模式 DLL 接口不变即可 

当应用程序想要完成一项任务时,例如创建文件,它无法自行完成。 唯一可以完成任务的实体是内核,因此应用程序必须遵循特定的函数调用流程。

image.png

我们来看看流程中所涉及到的重要概念:

用户进程:由用户执行的应用程序,例如记事本、Chrome 浏览器或 Microsoft Word。


子系统 DLL:包含用户进程调用的 API 函数的 DLL,例如 kernel32.dll 导出 CreateFile Windows API 函数,其他常见的子系统 DLL 例如 ntdll.dlladvapi32.dlluser32.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

image.png

打开 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

image.png

前进一步,继而进入 Kernelbase 中的 CreateFileW API。

image.png

CreateFileWNtCreateFile 中间还有着大量的指令,但最终我们看到了调用 Ntdll 中 NtCreateFile 的指令。

image.png

给该位置设置断点,继续执行,果然到达了 NtCreateFile API 的入口。

image.png

下图,则是 NtCreateFile 的 syscall stub (执行 syscall 的代码段),而高亮的 55h,这里就是对应的 syscall 编号了。

image.png

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 的块,称为

image.png

在进程虚拟地址空间中的页可以处于 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 版本中,都是默认开启的。

image.png

数据执行保护 (DEP):DEP 是一种系统级内存保护功能,如果页保护选项设置为 PAGE_READONLY,则 DEP 将阻止代码在这片内存区域中执行。例如在缓冲区溢出的漏洞利用中,开启 DEP 后将不能在栈区域执行 Shellcode

地址空间布局随机化 (ASLR):ASLR 随机分配进程关键数据区域的地址空间位置,包括可执行文件的基址、栈、堆、库的位置。在恶意软件开发与漏洞利用开发中,这意味着我们不能硬编码特定 API 的地址


内存操作

与内存交互的第一部便是分配内存。以 C 语言为例,我们有多种方式实现内存分配:C 中的 mallocHeapAllocLocalAlloc 函数,以及 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;
}

这些函数执行成功后会返回被分配的内存的起始地址,之后根据内存的保护措施,该指针可被用于后续的行动,例如读、写、执行。我们看到,分配的内存可能包含一些随机字符,有的函数支持初始化特定的字符。

image.png

然后,我们可以对内存进行写操作。在 C 里,我们用 memcpy 函数来实现。

    const char* cString = "abcdefgh";
    memcpy(pAddress1, cString, strlen(cString));

image.png

在使用完分配的缓存后,为了避免内存泄漏,我们最好手动释放。根据分配内存时所使用的函数,释放函数也有所不同。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,代表宽字符
ULONG_PTR
 
指针

 

Windows API 允许开发人员直接声明数据类型或指向数据类型的指针,在数据类型名称中,以 P 开头的数据类型代表指向实际数据类型的指针,而不以 P 开头的数据类型代表实际数据类型本身。例如,PHANDLE HANDLE* 相同,PSIZE_T SIZE_T* 相同,PDWORDSIZE_T* 相同,PDWORD 与 DWORD* 相同。

 

AANSIWUnicode

大多数 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 种语言各有优势。我们来看看如何分别在这 2 种语言里调用 Windows API。

在 C 中调用 MessageBox

新建 C++ 项目,选择 Console App。

image.png

在 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;
}

image.png

image.png

 

在 C# 中使用 P/Invoke中 调用 MessageBox

新建 C# 项目,选择 Console App (.NET Framework)。

image.png

 

在 C# 中使用 D/Invoke 调用 MessageBox