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 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 #include #include #include #include #include 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 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 (https://github.com/TheWover/DInvoke),是一个开源的 C# 项目旨在代替 P/Invoke。相比 P/Invoke,D/Invoke 具有这些优势:无需通过 P/Invoke 调用非托管代码,将非托管 PE 文件手动映射至内存并且调用其入口或导出函数,为原生 API 生成 syscall 包装器。 下载或编译 DInvoke.dll,在项目中添加对 DInvoke.dll 的引用。 修改 DllImport 属性,并将方法设置为 delegate 类型 (委托是一种类型,表示对具有特定参数列表和返回类型的方法的引用)。然后,把 API 的参数保存在一个对象数组中,然后通过 DynamicAPIInvoke 方法传递参数。最终代码如下: using System; using System.Runtime.InteropServices; using DInvoke.DynamicInvoke; class Program { [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError =true)] public delegate int messagebox_dinvoke(IntPtr hWnd,string lpText,string lpCaption,uint uType); static void Main(string[] args) { object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 }; var result = (int)Generic.DynamicAPIInvoke("user32.dll", "MessageBoxW", typeof(messagebox_dinvoke), ref parameters); } } MessageBox 被成功调用了。 目前,在 DynamicAPIInvoke 方法中,还是能看到模块名以及 API 名称的明文字符串。对于函数名,我们依旧可以用序数来代替。 using System; using System.Runtime.InteropServices; using DInvoke.DynamicInvoke; class Program { [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError =true)] public delegate int messagebox_dinvoke(IntPtr hWnd,string lpText,string lpCaption,uint uType); static void Main(string[] args) { object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 }; var api = Generic.GetLibraryAddress("user32.dll", 2158); var result = (int)Generic.DynamicFunctionInvoke(api, typeof(messagebox_dinvoke), ref parameters); } } 依旧能成功调用。 我们还可以通过 API Hashing 来进一步避免对于字符串的使用。基于给定的密钥,我们可以得到字符串的哈希值,用于代替明文模块名以及 API 名称。可以使用工具 CSharpRepl (https://github.com/waf/CSharpRepl) 在外部获得目标模块名以及 API 名称的哈希。 不过,我们从 https://github.com/TheWover/DInvoke/tree/main 得到的 D/Invoke 尚未支持以哈希值访问模块。在 DInvoke\Dinvoke\DynamicInvoke\Generic.cs 中加入下列代码并编译,加入新的引用。 public static IntPtr GetLoadedModuleAddress(string hashedDllName, long key) { using var process = Process.GetCurrentProcess(); foreach (ProcessModule module in process.Modules) { var hashedName = GetAPIHash(module.ModuleName, key); if (hashedName.Equals(hashedDllName)) return module.BaseAddress; } return IntPtr.Zero; } 我们可以使用方法 GetLoadedModuleAddress 以及 GetExportAddress 通过哈希以及密钥来分别获得对模块以及 API 的句柄最终,我们的代码如下: using System; using System.Runtime.InteropServices; using System.Xml.Linq; using DInvoke.DynamicInvoke; using static DInvoke.Injection.RemoteThreadCreate; class Program { [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)] public delegate int messagebox_dinvoke(IntPtr hWnd, string lpText, string lpCaption, uint uType); static void Main(string[] args) { object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 }; var hModule = Generic.GetLoadedModuleAddress("787F1BE411268C41CFA155DC0B7E522E", 0xabcddcba); var hMessage = Generic.GetExportAddress(hModule, "AB07F73F98F6A8C21DDF027070E5AC54", 0xabcddcba); var result = (int)Generic.DynamicFunctionInvoke(hMessage, typeof(messagebox_dinvoke), ref parameters); } } 调用正常!