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,代表宽字符

指针

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


ANSI 与 Unicode

大多数 Windows API 函数都有以 A 或 W 结尾的两个版本。 例如,有 CreateFileACreateFileW。其中以 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

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

不出意外,A 与 W 版的 MessageBox 都成功弹出。

image.png

image.png


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

在 C# 中调用 API 需要多出一些步骤,我们先讨论 P/Invoke 方法调用 MessageBox。平台调用 (P/Invoke) 允许我们从托管代码 (例如 C#,VBA)访问非托管库 (例如 C/C++,Rust 等语言)中存在的结构和函数。顺便一提,托管代码是在托管运行环境中执行的,例如 .NETJVM 等,运行环境处理内存管理、类型检测、异常处理等任务。而非托管代码,则是直接运行在机器硬件或者操作系统 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();
    }
}

image.png

而底层的 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);
    }
}

运行结果是完全一样的。

image.png

对于其他诸多有用的 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)。

image.png

因为 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);
    }
}

调用成功。

image.png

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,如果是恶意软件常用的那些,那么便可能引发检测。

image.png

MessageBoxW 也存在于二进制文件的字符串列表。

image.png

为了缓解上述问题,除了 API 的名称,我们还可以通过 API 的序数来指定。对于例如 user32.dll 等非托管 DLL,我们可以轻松地使用 PE-Bear 之类的 PE 文件分析器查看其导出列表 (用于给其他程序导入所用),我们可以看到 MessageBoxW 的序数为 86E,也就是十进制的 2158。关于函数名称序数函数 RVA名称 RVA 等属性之间的关系,我们在稍后小节深入讨论,但目前我们得知序数便可。

image.png

我们给 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);
    }
}

于是

image.png

再次使用 pestudio 检查文件,我们发现更名后的 MessageBoxW 看起来是个合法的函数,不过依旧能看到 P/Invoke 的使用,并且序数 2158 也得以显示。

image.png


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

动态调用,即 D/Invoke,是一个开源的 C# 项目旨在代替 P/Invoke。相比 P/Invoke,D/Invoke 具有这些优势:无需通过 P/Invoke 调用非托管代码将非托管 PE 文件手动映射至内存并且调用其入口或导出函数为原生 API 生成 syscall 包装器