模块 9 - 动态链接库 (DLL)
恶意软件开发课程 - 动态链接库 (DLL)
模块 9 - 动态链接库 (DLL)#
[!IMPORTANT] 本知识库声明
本知识库由本人整理自互联网 MalDev Academy 泄露资源,并由本人手动翻译为中文,过程中增加了大量关键技术提示与实践心得。
- 内容完整性:并未修改任何核心代码与技术逻辑,仅做汉化与注释加强。
- 版权归属:原始知识产权归原作者/官方所有。
- 支持正版:本仓库仅供内部学习交流,如果您有经济能力,请务必支持正版课程。
- 权利申诉:如相关内容侵犯了您的权益,请联系我,我将立即核实并删除。
简介#
.exe 和 .dll 文件类型都被认为是 PE 可执行格式,但两者之间存在差异。例如,一个可以立即注意到的主要区别是 .exe 文件可以通过双击来执行,而 .dll 文件则不能这样做。本模块将概述这两种文件类型之间的其他差异。
💡 初学者提示:EXE vs DLL
简单类比:
plaintext.exe = 完整的餐厅 - 可以独立运营 - 客户直接来就餐 - 有自己的入口(main函数) .dll = 中央厨房 - 不能独立运营 - 为多个餐厅提供服务 - 被其他餐厅(.exe)调用关键区别表:
特性 EXE DLL 独立运行 ✅ 可以 ❌ 不可以 入口点 main/WinMain DllMain(可选) 双击执行 ✅ 可以 ❌ 不可以 被调用 启动进程 LoadLibrary 用途 完整程序 函数库 PE特征 IMAGE_FILE_EXECUTABLE_IMAGE IMAGE_FILE_DLL
什么是 DLL?#
DLL 是可执行函数或数据的共享库,可以被多个应用程序同时使用。它们用于导出函数供进程使用。与 EXE 文件不同,DLL 文件不能自行执行代码。相反,DLL 库需要被其他程序调用才能执行代码。如前所述,CreateFileW 从 kernel32.dll 导出,因此如果进程想要调用该函数,它首先需要将 kernel32.dll 加载到其地址空间。
一些 DLL 默认会自动加载到每个进程中,因为这些 DLL 导出的函数是进程正确执行所必需的。这些 DLL 的几个示例包括 ntdll.dll、kernel32.dll 和 kernelbase.dll。下图显示了当前由 explorer.exe 进程加载的几个 DLL。

📚 知识扩展:为什么需要DLL?
设想没有DLL的世界:
plaintext如果每个程序都包含所有需要的代码: notepad.exe (10 MB) 包含文件操作代码 calc.exe (10 MB) 包含文件操作代码 chrome.exe (100 MB) 包含文件操作代码 问题: 1. 重复代码 → 浪费磁盘空间 2. 重复加载 → 浪费内存 3. 更新困难 → 需要更新所有程序使用DLL的世界:
plaintextnotepad.exe (1 MB) → 调用 kernel32.dll calc.exe (1 MB) → 调用 kernel32.dll chrome.exe (50 MB) → 调用 kernel32.dll kernel32.dll (1 MB) 多个程序共享 优点: 1. 节省空间 → 代码只存储一次 2. 节省内存 → 系统级共享 3. 易于更新 → 只更新DLL
系统级 DLL 基地址#
Windows 操作系统使用系统级 DLL 基地址在给定机器上所有进程的虚拟地址空间中的相同基地址加载某些 DLL,以优化内存使用和提高系统性能。下图显示了 kernel32.dll 在多个正在运行的进程中以相同的地址(0x7fff9fad0000)加载。

💡 初学者提示:共享DLL基地址
为什么多个进程中DLL地址相同?
内存共享机制:
plaintext进程A的虚拟内存: 进程B的虚拟内存: 0x7fff9fad0000 0x7fff9fad0000 ↓ ↓ └─────────┬───────────────┘ ↓ 物理内存中的 kernel32.dll (一份)优点:
- 内存效率:100个进程共享一个DLL,只占用一份物理内存
- 性能提升:DLL已在内存中,加载更快
- 地址一致:简化某些操作(如ROP)
并非所有DLL都共享:
c// 系统DLL - 共享基地址 ntdll.dll → 0x7ffff0000000 kernel32.dll → 0x7fff9fad0000 user32.dll → 0x7fff9e000000 // 自定义DLL - 可能不同 mydll.dll → 进程A: 0x12340000 进程B: 0x56780000 (ASLR)在恶意软件开发中的意义:
- 可以硬编码系统DLL的函数地址(在同一版本Windows上)
- ROP gadget 地址在不同进程中可能相同
- 但要注意Windows更新可能改变这些地址
为什么使用 DLL?#
在 Windows 中经常使用 DLL 有几个原因:
- 代码模块化 - 与其拥有一个包含全部功能的庞大可执行文件,不如将代码分成几个独立的库,每个库专注于特定功能。模块化使开发人员在开发和调试期间更容易。
- 代码重用 - DLL 促进代码重用,因为一个库可以被多个进程调用。
- 高效的内存使用 - 当多个进程需要相同的 DLL 时,它们可以通过共享该 DLL 来节省内存,而不是将其加载到进程的内存中。
📚 知识扩展:DLL的三大优势详解
1. 代码模块化示例:
plaintext大型项目结构: myapp.exe (主程序) ├── ui.dll (界面相关) ├── network.dll (网络功能) ├── crypto.dll (加密功能) └── database.dll (数据库功能) 开发优势: - 不同团队负责不同DLL - 单独测试每个模块 - 更新一个DLL不影响其他2. 代码重用示例:
c// crypto.dll 导出加密函数 __declspec(dllexport) void EncryptData(void* data, size_t len); // 多个程序使用: myapp1.exe → LoadLibrary("crypto.dll") → EncryptData() myapp2.exe → LoadLibrary("crypto.dll") → EncryptData() myapp3.exe → LoadLibrary("crypto.dll") → EncryptData() 好处: - 加密逻辑只写一次 - 修复bug只需更新crypto.dll - 所有程序自动受益3. 内存效率计算:
plaintext场景:10个程序都需要crypto.dll (5MB) 不使用DLL(静态链接): - 每个程序包含crypto代码 - 总内存:10 × 5MB = 50MB 使用DLL(动态链接): - 所有程序共享一个crypto.dll - 总内存:1 × 5MB = 5MB - 节省:90%的内存!
使用 Visual Studio 创建 DLL 文件#
要创建 DLL 文件,启动 Visual Studio 并创建一个新项目。当给定项目模板时,选择 Dynamic-Link Library (DLL) 选项。

接下来,选择保存项目文件的位置。保存项目后,应出现 dllmain.cpp,其中包含默认的 DLL 代码。

DLL 入口点#
回想一下,DLL 由应用程序加载(例如 .exe 文件)。因此,DLL 可以指定一个入口点函数,在发生特定操作时执行代码。入口点被调用有 4 种可能性:
DLL_PROCESS_ATTACH- 进程正在加载 DLL。DLL_THREAD_ATTACH- 进程正在创建新线程。DLL_THREAD _DETACH- 线程正常退出。DLL_PROCESS_DETACH- 进程正在卸载 DLL。
💡 初学者提示:DLL入口点详解
DllMain 函数就像DLL的”生命周期钩子”:
cBOOL APIENTRY DllMain( HMODULE hModule, // DLL的模块句柄 DWORD ul_reason_for_call, // 调用原因 LPVOID lpReserved // 保留参数 ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: // DLL被加载到进程时调用(一次) printf("DLL加载!\n"); // 初始化操作:分配资源、创建线程等 break; case DLL_THREAD_ATTACH: // 进程创建新线程时调用 printf("新线程!\n"); // 线程本地存储初始化 break; case DLL_THREAD_DETACH: // 线程退出时调用 printf("线程退出\n"); // 清理线程资源 break; case DLL_PROCESS_DETACH: // DLL被卸载时调用(一次) printf("DLL卸载\n"); // 清理操作:释放资源、关闭句柄等 break; } return TRUE; // TRUE = 成功,FALSE = 失败 }执行时机示例:
plaintextmyapp.exe 启动: 1. LoadLibrary("mydll.dll") → DllMain(DLL_PROCESS_ATTACH) 被调用 2. 在myapp.exe中 CreateThread(...) → DllMain(DLL_THREAD_ATTACH) 被调用 3. 线程退出 → DllMain(DLL_THREAD_DETACH) 被调用 4. FreeLibrary(hDll) → DllMain(DLL_PROCESS_DETACH) 被调用恶意软件常用技巧:
cBOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { if (reason == DLL_PROCESS_ATTACH) { // 禁用线程通知以提高性能 DisableThreadLibraryCalls(hModule); // DLL注入后立即执行Payload CreateThread(NULL, 0, MaliciousThread, NULL, 0, NULL); } return TRUE; }
导出函数#
DLL 可以导出函数,然后可以被调用应用程序使用。要导出函数,需要使用关键字 extern 和 __declspec(dllexport) 定义它。下面的 sampleDLL.dll 文件中展示了一个示例导出函数 HelloWorld。
////// sampleDLL.dll //////
#include <Windows.h>
// 导出函数
extern __declspec(dllexport) void HelloWorld(){
MessageBoxA(NULL, "Hello, World!", "DLL Message", MB_ICONINFORMATION);
}
// DLL 的入口点
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
c现在,外部应用程序可以在将 sampleDLL.dll 加载到内存后调用 HelloWorld。
💡 初学者提示:导出函数的语法
导出函数的完整语法:
c// 方式1:使用 __declspec(dllexport) __declspec(dllexport) int Add(int a, int b) { return a + b; } // 方式2:使用 extern "C"(防止C++名称修饰) extern "C" __declspec(dllexport) int Subtract(int a, int b) { return a - b; } // 方式3:定义导出宏(推荐) #ifdef MYDLL_EXPORTS #define MYDLL_API __declspec(dllexport) #else #define MYDLL_API __declspec(dllimport) #endif MYDLL_API void MyFunction();查看导出的函数:
bash# 使用 dumpbin 工具查看DLL导出 dumpbin /exports sampleDLL.dll # 输出示例: ordinal hint RVA name 1 0 00001000 HelloWorld 2 1 00001050 Add 3 2 000010A0 Subtract导入函数的对应方式:
c// 在使用DLL的程序中 // 方式1:隐式链接(编译时) #pragma comment(lib, "sampleDLL.lib") __declspec(dllimport) void HelloWorld(); // 使用 HelloWorld(); // 方式2:显式链接(运行时)- 下一节讲解
动态链接#
可以使用 LoadLibrary、GetModuleHandle 和 GetProcAddress Windows API 从 DLL 导入函数。这被称为动态链接 ↗。这是一种在运行时加载和链接代码(DLL)的方法,而不是在编译时使用链接器和导入地址表链接它们。使用动态链接有几个优势;这些在 Microsoft 这里 ↗有文档记录。
接下来的部分将逐步介绍加载 DLL、检索 DLL 的句柄、检索导出函数的地址,然后从外部二进制文件调用该函数的步骤。
📚 知识扩展:动态链接 vs 静态链接
两种链接方式对比:
特性 静态/隐式链接 动态/显式链接 链接时机 编译时 运行时 需要.lib ✅ 需要 ❌ 不需要 IAT可见 ✅ 在IAT中 ❌ 不在IAT中 代码 简单 稍复杂 灵活性 低 高 检测难度 容易 困难 恶意软件常用 ❌ ✅ 静态链接示例:
c// 编译时需要 user32.lib #pragma comment(lib, "user32.lib") // 直接使用函数 MessageBoxA(NULL, "Hello", "Title", MB_OK); // IAT 中会显示 MessageBoxA动态链接示例(本模块重点):
c// 运行时加载 HMODULE hUser32 = LoadLibraryA("user32.dll"); // 获取函数地址 typedef int (WINAPI* fnMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT); fnMessageBoxA pMessageBoxA = (fnMessageBoxA)GetProcAddress(hUser32, "MessageBoxA"); // 调用 pMessageBoxA(NULL, "Hello", "Title", MB_OK); // IAT 中只显示 LoadLibraryA 和 GetProcAddress为什么恶意软件喜欢动态链接?
- 隐藏API调用:IAT 看起来很干净
- 绕过沙箱:某些沙箱只检查IAT
- 反分析:静态分析看不到真实的API调用
- 灵活性:可以在运行时决定调用哪些API
步骤 1 - 加载 DLL#
在这一步和接下来的步骤中,我们将切换到 EXE 文件。这是因为我们的 EXE 文件将加载 sampleDLL.dll 并调用 HelloWorld 函数。因此,创建一个新的 Win32 控制台应用程序并按照以下步骤调用 HelloWorld。
在应用程序中调用诸如 MessageBoxA ↗ 之类的函数将强制 Windows 操作系统将导出 MessageBoxA 函数的 DLL 加载到调用进程的内存地址空间中,在本例中为 user32.dll。当进程启动时,操作系统会自动加载 user32.dll,而不是由代码加载。
但是,对于我们的自定义 DLL(如 sampleDLL.dll),DLL 不会加载到内存中。由于应用程序没有将 sampleDLL.dll 加载到内存中,因此需要使用 LoadLibrary ↗ Windows API,如下所示。
#include <windows.h>
int main() {
// 加载 DLL
HMODULE hModule = LoadLibraryA("sampleDLL.dll"); // hModule 现在包含 sampleDLL.dll 的句柄
}c💡 初学者提示:LoadLibrary详解
LoadLibrary做什么?
- 搜索DLL文件(按特定顺序)
- 将DLL映射到进程内存
- 调用DLL的DllMain(DLL_PROCESS_ATTACH)
- 返回DLL的模块句柄(HMODULE)
DLL搜索顺序(安全模式):
plaintext1. 程序所在目录 2. 系统目录 (C:\Windows\System32) 3. 16位系统目录 (C:\Windows\System) 4. Windows目录 (C:\Windows) 5. 当前目录 6. PATH环境变量中的目录完整示例(带错误处理):
c#include <Windows.h> #include <stdio.h> int main() { // 方式1:相对路径(在当前目录或PATH中搜索) HMODULE hDll = LoadLibraryA("sampleDLL.dll"); // 方式2:绝对路径(推荐,避免DLL劫持) // HMODULE hDll = LoadLibraryA("C:\\MyApp\\sampleDLL.dll"); if (hDll == NULL) { DWORD error = GetLastError(); printf("❌ LoadLibrary失败!错误代码:%d\n", error); // 常见错误: // 126 = ERROR_MOD_NOT_FOUND (找不到DLL) // 193 = ERROR_BAD_EXE_FORMAT (32/64位不匹配) return -1; } printf("✅ DLL加载成功!句柄:0x%p\n", hDll); // 使用DLL... // 卸载DLL FreeLibrary(hDll); return 0; }安全注意事项 - DLL劫持:
c// ❌ 危险:相对路径 LoadLibraryA("kernel32.dll"); // 攻击者可以在程序目录放一个恶意的kernel32.dll // ✅ 安全:使用绝对路径 char sysDir[MAX_PATH]; GetSystemDirectoryA(sysDir, MAX_PATH); strcat(sysDir, "\\kernel32.dll"); LoadLibraryA(sysDir);
步骤 2 - 检索 DLL 的句柄#
如果 sampleDLL.dll 已经加载到应用程序的内存中,可以通过 GetModuleHandle ↗ Windows API 函数检索其句柄,而无需使用 LoadLibrary 函数。
#include <windows.h>
int main() {
// 尝试获取已在内存中的 DLL 的句柄
HMODULE hModule = GetModuleHandleA("sampleDLL.dll");
if (hModule == NULL) {
// 如果 DLL 未加载到内存中,使用 LoadLibrary 加载它
hModule = LoadLibraryA("sampleDLL.dll");
}
}c💡 初学者提示:GetModuleHandle vs LoadLibrary
两者的区别:
函数 作用 引用计数 失败时 LoadLibrary加载DLL 增加引用 返回NULL GetModuleHandle获取已加载DLL句柄 不变 返回NULL 什么时候用哪个?
使用LoadLibrary:
c// 当你确定DLL可能未加载时 HMODULE h = LoadLibraryA("mydll.dll"); // ... 使用 FreeLibrary(h); // 必须释放使用GetModuleHandle:
c// 当你知道DLL已经被加载时 // 例如:ntdll.dll, kernel32.dll 总是被加载 HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); // 不需要FreeLibrary!最佳实践:
cHMODULE GetOrLoadDll(const char* dllName) { // 先尝试获取已加载的 HMODULE h = GetModuleHandleA(dllName); // 如果未加载,则加载它 if (h == NULL) { h = LoadLibraryA(dllName); } return h; } // 使用: HMODULE hDll = GetOrLoadDll("sampleDLL.dll"); if (hDll != NULL) { // ... 使用DLL }查看进程已加载的所有模块:
cHMODULE hMods[1024]; DWORD cbNeeded; if (EnumProcessModules(GetCurrentProcess(), hMods, sizeof(hMods), &cbNeeded)) { for (int i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) { char szModName[MAX_PATH]; if (GetModuleFileNameExA(GetCurrentProcess(), hMods[i], szModName, sizeof(szModName))) { printf("%s\n", szModName); } } }
步骤 3 - 检索函数的地址#
一旦 DLL 加载到内存中并检索到句柄,下一步就是检索函数的地址。这是使用 GetProcAddress ↗ Windows API 完成的,它接受导出函数的 DLL 的句柄和函数名称。
#include <windows.h>
int main() {
// 尝试获取 DLL 的句柄
HMODULE hModule = GetModuleHandleA("sampleDLL.dll");
if (hModule == NULL) {
// 如果 DLL 未加载到内存中,使用 LoadLibrary 加载它
hModule = LoadLibraryA("sampleDLL.dll");
}
PVOID pHelloWorld = GetProcAddress(hModule, "HelloWorld"); /// pHelloWorld 存储 HelloWorld 的函数地址
}c💡 初学者提示:GetProcAddress详解
GetProcAddress做什么?
- 在DLL的导出表中搜索函数名
- 返回函数的内存地址
- 如果找不到,返回NULL
两种查找方式:
c// 方式1:按名称查找(常用) FARPROC pFunc = GetProcAddress(hDll, "FunctionName"); // 方式2:按序号查找(高级) FARPROC pFunc = GetProcAddress(hDll, (LPCSTR)42); // 序号42完整示例(带错误处理):
cHMODULE hDll = LoadLibraryA("sampleDLL.dll"); if (hDll == NULL) { printf("❌ 无法加载DLL\n"); return -1; } // 获取函数地址 FARPROC pHelloWorld = GetProcAddress(hDll, "HelloWorld"); if (pHelloWorld == NULL) { DWORD error = GetLastError(); printf("❌ 找不到函数!错误代码:%d\n", error); // 127 = ERROR_PROC_NOT_FOUND FreeLibrary(hDll); return -1; } printf("✅ 函数地址:0x%p\n", pHelloWorld); // 下一步:类型转换并调用为什么函数可能找不到?
- 名称错误:大小写敏感!
HelloWorld≠helloworld- 名称修饰:C++函数可能有修饰名
- 未导出:函数没有用
__declspec(dllexport)标记- 架构不匹配:32位DLL vs 64位程序
查看C++修饰名:
c// C++导出的函数名可能是: // ?HelloWorld@@YAXXZ // 解决方法:使用 extern "C" extern "C" __declspec(dllexport) void HelloWorld() { // ... }
步骤 4 - 类型转换函数的地址#
一旦 HelloWorld 的地址保存到 pHelloWorld 变量中,下一步就是对该地址执行类型转换,转换为 HelloWorld 的函数指针。需要此函数指针才能调用该函数。
#include <windows.h>
// 构造一个新的数据类型,表示 HelloWorld 的函数指针
typedef void (WINAPI* HelloWorldFunctionPointer)();
int main() {
// 尝试获取 DLL 的句柄
HMODULE hModule = GetModuleHandleA("sampleDLL.dll");
if (hModule == NULL) {
// 如果 DLL 未加载到内存中,使用 LoadLibrary 加载它
hModule = LoadLibraryA("sampleDLL.dll");
}
PVOID pHelloWorld = GetProcAddress(hModule, "HelloWorld"); /// pHelloWorld 存储 HelloWorld 的函数地址
HelloWorldFunctionPointer HelloWorld = (HelloWorldFunctionPointer)pHelloWorld;
return 0;
}c📚 知识扩展:函数指针类型转换详解
为什么需要类型转换?
c// GetProcAddress 返回 FARPROC (通用函数指针) FARPROC pFunc = GetProcAddress(hDll, "HelloWorld"); // FARPROC 定义为: typedef int (FAR WINAPI *FARPROC)(); // 但我们的函数是: void HelloWorld(); // 不同的返回类型和参数! // 所以需要转换为正确的类型函数指针类型定义步骤:
1. 查看原始函数签名:
cvoid HelloWorld() { MessageBoxA(...); }2. 创建对应的函数指针类型:
c// 格式:返回类型 (调用约定* 类型名)(参数列表) typedef void (WIN API* HelloWorldFunctionPointer)(); // ↑ ↑ ↑ ↑ // 返回类型 调用约定 类型名 参数(无)3. 使用类型转换:
cPVOID pAddr = GetProcAddress(hDll, "HelloWorld"); HelloWorldFunctionPointer pHelloWorld = (HelloWorldFunctionPointer)pAddr;更复杂的函数示例:
c// 原始函数: int Add(int a, int b) { return a + b; } // 函数指针类型: typedef int (WINAPI* fnAdd)(int, int); // 使用: fnAdd pAdd = (fnAdd)GetProcAddress(hDll, "Add"); int result = pAdd(5, 3); // result = 8MessageBoxA 示例(完整):
c// 1. 查看MSDN中的函数签名: int MessageBoxA( HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType ); // 2. 创建函数指针类型: typedef int (WINAPI* fnMessageBoxA)( HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType ); // 3. 获取并转换: HMODULE hUser32 = LoadLibraryA("user32.dll"); fnMessageBoxA pMessageBoxA = (fnMessageBoxA)GetProcAddress(hUser32, "MessageBoxA"); // 4. 调用: if (pMessageBoxA != NULL) { pMessageBoxA(NULL, "Hello", "Title", MB_OK); }简化写法(不使用typedef):
c//一行搞定: ((void(WINAPI*)())GetProcAddress(hDll, "HelloWorld"))(); // ^返回 ^调用约定 ^参数 ^获取地址 ^调用 // 但这样可读性很差,不推荐!
整合 - 调用 HelloWorld#
本节现在将把上述所有步骤放入一个名为 call() 的函数中。该函数将基本上执行以下步骤:
- 加载
sampleDLL.dll - 检索
HelloWorld函数的地址 - 类型转换
HelloWorld - 调用
HelloWorld
再次强调,此函数是从我们的 .exe 程序调用的,因为它是加载 DLL 并调用 HelloWorld 函数的程序。
#include <windows.h>
// 构造一个新的数据类型,表示 HelloWorld 的函数指针
typedef void (WINAPI* HelloWorldFunctionPointer)();
void call() {
// 尝试获取 DLL 的句柄
HMODULE hModule = GetModuleHandleA("sampleDLL.dll");
if (hModule == NULL) {
// 如果 DLL 未加载到内存中,使用 LoadLibrary 加载它
hModule = LoadLibraryA("sampleDLL.dll");
}
// pHelloWorld 存储 HelloWorld 的函数地址
PVOID pHelloWorld = GetProcAddress(hModule, "HelloWorld");
// 将 pHelloWorld 类型转换为 HelloWorldFunctionPointer 类型
HelloWorldFunctionPointer HelloWorld = (HelloWorldFunctionPointer)pHelloWorld;
// 调用 HelloWorld
HelloWorld();
}c💡 初学者提示:完整的动态链接流程
完整示例(带所有错误处理):
c#include <Windows.h> #include <stdio.h> // 函数指针类型定义 typedef void (WINAPI* fnHelloWorld)(); BOOL CallDllFunction() { printf("[*] 步骤1:加载DLL\n"); HMODULE hDll = LoadLibraryA("sampleDLL.dll"); if (hDll == NULL) { printf("❌ 无法加载DLL,错误:%d\n", GetLastError()); return FALSE; } printf("✅ DLL加载成功:0x%p\n", hDll); printf("\n[*] 步骤2:获取函数地址\n"); fnHelloWorld pHelloWorld = (fnHelloWorld)GetProcAddress(hDll, "HelloWorld"); if (pHelloWorld == NULL) { printf("❌ 找不到函数,错误:%d\n", GetLastError()); FreeLibrary(hDll); return FALSE; } printf("✅ 函数地址:0x%p\n", pHelloWorld); printf("\n[*] 步骤3:调用函数\n"); pHelloWorld(); // 调用! printf("✅ 函数调用完成\n"); printf("\n[*] 步骤4:清理资源\n"); FreeLibrary(hDll); printf("✅ DLL已卸载\n"); return TRUE; } int main() { printf("=== 动态链接演示 ===\n\n"); if (CallDllFunction()) { printf("\n=== 成功! ===\n"); } else { printf("\n=== 失败! ===\n"); } return 0; }
动态链接示例 - MessageBoxA#
下面的代码演示了动态链接的另一个简单示例,其中调用了 MessageBoxA。代码假设 user32.dll(导出该函数的 DLL)未加载到内存中。回想一下,如果 DLL 未加载到内存中,则需要使用 LoadLibrary 将该 DLL 加载到进程的地址空间。
typedef int (WINAPI* MessageBoxAFunctionPointer)( // 构造一个新的数据类型,将表示 MessageBoxA 的函数指针
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);
void call(){
// 检索 MessageBox 的地址,并将其保存到 'pMessageBoxA'(MessageBoxA 的函数指针)
MessageBoxAFunctionPointer pMessageBoxA = (MessageBoxAFunctionPointer)GetProcAddress(LoadLibraryA("user32.dll"), "MessageBoxA");
if (pMessageBoxA != NULL){
// 如果不为 null,通过其函数指针调用 MessageBox
pMessageBoxA(NULL, "MessageBox's Text", "MessageBox's Caption", MB_OK);
}
}c📚 知识扩展:优化的写法
上面的代码很简洁,但可以更健壮:
c#include <Windows.h> #include <stdio.h> // MessageBoxA 函数指针类型 typedef int (WINAPI* fnMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT); void BetterExample() { // 分步骤,便于调试 HMODULE hUser32 = LoadLibraryA("user32.dll"); if (hUser32 == NULL) { printf("❌ 无法加载 user32.dll\n"); return; } fnMessageBoxA pMessageBoxA = (fnMessageBoxA)GetProcAddress(hUser32, "MessageBoxA"); if (pMessageBoxA == NULL) { printf("❌ 找不到 MessageBoxA\n"); FreeLibrary(hUser32); return; } // 调用 pMessageBoxA(NULL, "这是动态调用的MessageBox", "成功!", MB_OK); // 清理 FreeLibrary(hUser32); }实际上user32.dll通常已加载:
c// 更高效的写法(user32.dll几乎总是已加载) void EfficientExample() { HMODULE hUser32 = GetModuleHandleA("user32.dll"); if (hUser32 == NULL) { hUser32 = LoadLibraryA("user32.dll"); } fnMessageBoxA pMB = (fnMessageBoxA)GetProcAddress(hUser32, "MessageBoxA"); if (pMB) { pMB(NULL, "Text", "Title", MB_OK); } // 注意:如果使用GetModuleHandle,不需要FreeLibrary }
函数指针#
在本课程的其余部分,函数指针数据类型将具有使用
WinAPI 名称前缀 fn 的命名约定,它代表”函数指针”。例如,上面的 MessageBoxAFunctionPointer 数据类型将表示为 fnMessageBoxA。这用于在整个课程中保持简单性和提高清晰度。
💡 初学者提示:命名约定
fn 前缀的含义:
c// 课程使用的命名约定: typedef int (WINAPI* fnMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT); typedef HANDLE (WINAPI* fnCreateFileW)(...); typedef BOOL (WINAPI* fnWriteFile)(...); // fn = function pointer(函数指针)为什么这样命名?
- 清晰:一看就知道是函数指针类型
- 简洁:比
MessageBoxAFunctionPointer短多了- 一致:整个课程统一风格
实际使用示例:
c// 定义类型 typedef BOOL (WINAPI* fnVirtualAlloc)(...); typedef BOOL (WINAPI* fnVirtualProtect)(...); typedef HANDLE (WINAPI* fnCreateThread)(...); // 使用类型 fnVirtualAlloc pVirtualAlloc = (fnVirtualAlloc)GetProcAddress(...); fnVirtualProtect pVirtualProtect = (fnVirtualProtect)GetProcAddress(...); fnCreateThread pCreateThread = (fnCreateThread)GetProcAddress(...);
Rundll32.exe#
有几种方法可以在不使用编程方法的情况下运行导出的函数。一种常见技术是使用 rundll32.exe ↗ 二进制文件。Rundll32.exe 是一个内置的 Windows 二进制文件,用于运行 DLL 文件的导出函数。要运行导出的函数,使用以下命令:
rundll32.exe <dllname>, <function exported to run>c例如,User32.dll 导出函数 LockWorkStation,它会锁定机器。要运行该函数,使用以下命令:
rundll32.exe user32.dll,LockWorkStationc💡 初学者提示:Rundll32实用技巧
Rundll32的用途:
- 测试DLL导出函数
- 执行系统管理任务
- 恶意软件常用技术(LOLBin)
常用命令示例:
bash# 锁定工作站 rundll32.exe user32.dll,LockWorkStation # 打开"运行"对话框 rundll32.exe shell32.dll,#61 # 清空回收站 rundll32.exe InetCpl.cpl,ClearMyTracksByProcess 255 # 打开控制面板项 rundll32.exe shell32.dll,Control_RunDLL desk.cpl # 显示设置 rundll32.exe shell32.dll,Control_RunDLL sysdm.cpl # 系统属性自定义DLL示例:
c// 在 DLL 中导出函数 extern "C" __declspec(dllexport) void TestFunction( HWND hwnd, // rundll32传递的参数 HINSTANCE hinst, LPSTR lpszCmdLine, int nCmdShow ) { MessageBoxA(NULL, "从 rundll32 调用!", "测试", MB_OK); } // 命令行调用: // rundll32.exe mydll.dll,TestFunction安全注意事项:
- ⚠️ 恶意软件经常滥用rundll32来执行恶意DLL
- ⚠️ EDR/AV会监控rundll32的异常行为
- ⚠️ 从可疑位置加载DLL会被标记
删除预编译头#
在使用 Visual Studio 模板创建 DLL 文件时,DLL 模板将附带 framework.h、pch.h 和 pch.cpp,这些被称为预编译头 ↗。这些文件用于加快大型项目的项目编译速度。在这种情况下不太可能需要这些,因此建议使用以下步骤删除这些文件。
首先,像之前展示的那样使用 Visual Studio 的 DLL 模板创建一个新的 DLL 文件。

接下来,打开项目并突出显示 framework.h、pch.h 和 pch.cpp,然后按删除键并选择”删除”选项。
您还需要从 dllmain.cpp 中删除 #include "pch.h" 并将其替换为 #include <Windows.h>。


删除预编译头后,必须更改编译器的默认设置以确认项目中不应使用预编译头。

转到 C/C++ > Precompiled Header

将”Precompiled Header”选项更改为”Not Using Precompiled Headers”并按”Apply”。

最后,将 dllmain.cpp 文件更改为 dllmain.c。这是必需的,因为 Maldev Academy 中提供的代码片段使用 C 而不是 C++。要编译程序,单击 Build > Build Solution,将在 Release 或 Debug 文件夹下创建一个 DLL,具体取决于编译配置。
💡 初学者提示:为什么删除预编译头?
预编译头的作用:
- 加快大型项目的编译速度
- 预先编译常用的头文件(如Windows.h)
- 对小项目来说是额外的复杂性
删除的原因:
- 本课程项目都很小
- 减少复杂性,更易理解
- 避免新手混淆
手动操作步骤总结:
plaintext1. 删除文件: - framework.h - pch.h - pch.cpp 2. 修改 dllmain.cpp: 删除:#include "pch.h" 添加:#include <Windows.h> 3. 项目设置: C/C++ → Precompiled Headers → Not Using Precompiled Headers 4. 重命名: dllmain.cpp → dllmain.c (使用C而不是C++)快速模板(保存备用):
c// dllmain.c - 最简DLL模板 #include <Windows.h> // 导出函数 extern __declspec(dllexport) void MyFunction() { MessageBoxA(NULL, "Hello!", "DLL", MB_OK); } // DLL入口点 BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { switch (reason) { case DLL_PROCESS_ATTACH: // 初始化 break; case DLL_PROCESS_DETACH: // 清理 break; } return TRUE; }
🎯 本模块学习要点
必须掌握的概念:
- ✅ EXE vs DLL 的区别
- ✅ DLL 的加载和卸载(LoadLibrary / FreeLibrary)
- ✅ 动态链接的完整流程
- ✅ 函数指针的定义和使用
- ✅ DllMain 入口点的4种调用时机
- ✅ 导出函数的方法
动态链接四步骤:
plaintext1. LoadLibrary() → 加载DLL 2. GetProcAddress() → 获取函数地址 3. (类型转换) → 转换为函数指针 4. 调用函数 → 执行关键API:
API 作用 返回值 LoadLibraryA/W加载DLL HMODULE GetModuleHandleA/W获取已加载DLL句柄 HMODULE GetProcAddress获取函数地址 FARPROC FreeLibrary卸载DLL BOOL 实践建议:
- 创建简单的DLL并导出函数
- 编写程序动态加载并调用
- 使用rundll32测试DLL
- 观察Process Hacker中的模块加载
常见错误:
- 忘记检查NULL返回值
- 32/64位不匹配
- 函数名拼写错误
- 忘记FreeLibrary导致泄漏
下一步: 学习检测机制和如何绕过