Skip to content
Published at:

第15章:动态链接库

引言

DLL(Dynamic-Link Library,动态链接库)自 Windows NT 诞生起就是其核心组成部分。其核心设计动机在于进程间共享能力——单个 DLL 副本存在于 RAM 中,所有需要它的进程可共享其代码。在早期内存容量远小于今天的时代,这种节省至关重要,即便现在也意义重大,因为一个典型进程会使用数十个 DLL。

DLL 是可移植可执行(PE,Portable Executable)文件,可包含代码、数据和资源。每个用户模式进程都使用子系统 DLL,如 kernel32.dlluser32.dllgdi32.dlladvapi32.dll,它们实现了有文档记录的 Windows API。ntdll.dll 在每个用户模式进程中都是必需的。

DLL 可以导出函数、全局变量以及资源(菜单、位图、图标等)。DLL 可在进程启动时隐式加载,也可通过调用 LoadLibraryLoadLibraryEx 显式加载。

构建 DLL

通过 Visual Studio 创建 DLL 项目时,可选择适当的项目模板。DLL 项目与 EXE 项目的根本区别在于项目属性中的"配置类型"(Configuration Type)设置为 DLL。

典型的 Visual Studio DLL 项目包含:

  • pch.hpch.cpp:预编译头文件
  • framework.h:包含标准 Windows 头文件
  • dllmain.cpp:包含 DllMain 函数

添加一个名为 IsPrime 的导出函数作为示例。头文件 Simple.h 供调用者包含:

cpp
bool IsPrime(int n);

实现文件 Simple.cpp(不应被调用者看到):

cpp
#include "pch.h"
#include "Simple.h"
#include <cmath>

bool IsPrime(int n) {
    int limit = (int)::sqrt(n);
    for (int i = 2; i <= limit; i++)
        if (n % i == 0)
            return false;
    return true;
}

在同一个解决方案中创建控制台应用程序项目(如 SimplePrimes),包含 Simple.h 并调用 IsPrime(17)。编译时会出现"无法解析的外部符号"(unresolved external symbol)错误,因为链接器找不到函数的实现。

需要在 DLL 项目中右键单击"引用"(References)→"添加引用...",选择对应的 DLL 项目。还需要在 Simple.h 中添加导出声明:

cpp
__declspec(dllexport) bool IsPrime(int n);

__declspec(dllexport) 是 Microsoft 特有的关键字,用于指示编译器和链接器将此函数从 DLL 导出。构建成功后,可以使用 dumpbin.exe 查看导出函数:

dumpbin /exports SimpleDll.dll

输出会显示修饰后的名称(decorated name),如 ?IsPrime@@YA_NH@Z。PE 查看器工具也可以看到导出和导入函数信息。

隐式链接和显式链接

两种基本链接方式:隐式链接(Implicit Linking,最简单,也称静态链接到 DLL)和显式链接(Explicit Linking,更复杂,但对加载/卸载时机有更多控制)。

隐式链接

生成 DLL 时默认会生成伴随的导入库文件(Import Library,.LIB 文件),包含两部分信息:DLL 文件名(不含路径)和导出符号列表。

可以通过 Visual Studio 添加引用、在项目属性中添加 LIB 文件依赖,或在代码中使用 #pragma comment(lib, "...") 方式链接:

cpp
#ifdef _WIN64
#pragma comment(lib, "./x64/Debug/SimpleDll.lib")
#else
#pragma comment(lib, "./Debug/SimpleDll.lib")
#endif

隐式链接的过程如下:编译器遇到函数调用但找不到实现时,向链接器发出指令;链接器在静态库中查找失败后,在导入库(Import Library)中找到该函数在 DLL 中实现的记录;链接器将适当数据添加到 PE 文件中,指示加载程序(Loader)在运行时查找 DLL。

运行时加载程序(位于 ntdll.dll 中)的搜索路径按以下顺序:

  1. 已知 DLL(Known DLLs,注册表中指定)
  2. 可执行文件所在目录
  3. 进程当前目录
  4. 系统目录(如 c:\windows\system32
  5. Windows 目录(如 c:\Windows
  6. PATH 环境变量中列出的目录

如果找不到 DLL,系统会显示错误消息框,进程终止。

已知 DLL(Known DLLs)的注册表项位于 HKLM\System\CurrentControlSet\Control\Session Manager\KnownDLLs,用于防止 DLL 被劫持(DLL Hijacking)进行恶意替换。系统初始化时映射已知 DLL,加载速度更快。

如果一个 DLL 依赖其他 DLL,会递归搜索。所有依赖的 DLL 必须全部成功找到,否则进程终止。

隐式加载的 DLL 在进程启动时加载,在进程退出时卸载。尝试用 FreeLibrary 卸载隐式加载的 DLL 看似成功但实际无效。

隐式链接的开发步骤:

  1. 添加相关的 #include 头文件
  2. 将导入库添加到导入集
  3. 调用导出函数或访问导出变量

导出类的方式:

cpp
class __declspec(dllexport) PrimeCalculator {
public:
    bool IsPrime(int n) const;
    std::vector<int> CalcRange(int from, int to);
};

显式链接

显式链接可以更好地控制加载和卸载时机。DLL 加载失败时进程不会崩溃,可以处理错误并继续运行。常见用途是加载与语言相关的资源。

不使用导入库。显式链接的核心 API 是 LoadLibrary

cpp
HMODULE LoadLibrary(_In_ LPCTSTR lpLibFileName);

该函数只接受文件名或完整路径。如果只指定文件名,则按照隐式加载的相同顺序搜索。如果指定完整路径,则只尝试加载该文件。

在搜索之前,加载程序会检查进程地址空间中是否已加载同名模块。如果是,则返回现有 DLL 句柄(handle)。

成功找到后,DLL 被映射到进程地址空间。返回值是虚拟地址,类型为 HMODULE(也可用 HINSTANCE,两者可互换)。

访问导出函数使用 GetProcAddress

cpp
FARPROC GetProcAddress(
    _In_ HMODULE hModule,
    _In_ LPCSTR lpProcName);

注意函数名称必须是 ASCII 格式。返回值是 FARPROC 类型,调用者需要根据预先了解的函数签名将其转换为适当的函数指针类型。

初次尝试通常会失败,原因是名称修饰(Name Decoration / Name Mangling)。以下是示例代码:

cpp
auto hPrimesLib = ::LoadLibrary(L"SimpleDll.dll");
if (hPrimesLib) {
    using PIsPrime = bool (*)(int);
    auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
    if (IsPrime) {
        bool test = IsPrime(17);
        printf("%d\n", (int)test);
    }
}

失败原因是 C++ 编译器的名称修饰机制。dumpbin 显示实际名称为 ?IsPrime@@YA_NH@Z。使用修饰名可以工作,但不方便。

常见做法是将导出函数转换为 C 风格(C Linkage),以禁用名称修饰:

cpp
extern "C" __declspec(dllexport) bool IsPrime(int n);

这样 GetProcAddress 直接使用 "IsPrime" 即可。但这种方法不能用于类成员函数。

卸载 DLL 使用 FreeLibrary

cpp
BOOL FreeLibrary(_In_ HMODULE hLibModule);

系统为每个 DLL 维护一个进程级的引用计数器(Reference Count),多次调用 LoadLibrary 需要相同次数的 FreeLibrary 才能真正卸载 DLL。

获取已加载 DLL 的句柄使用 GetModuleHandle

cpp
HMODULE GetModuleHandle(_In_opt_ LPCTSTR lpModuleName);

只需传入 DLL 名,不会增加加载计数。模块名为 NULL 时返回可执行文件自身的句柄。

调用约定

调用约定(Calling Convention)表明函数参数如何传递以及由谁清理栈。在 x64 架构下只有一种调用约定,但在 x86 架构下有几种不同的约定。最常见的两种是 __stdcall__cdecl——两者都从右向左将参数压入栈,区别在于:

  • __stdcall:由被调用方(callee)清理栈
  • __cdecl:由调用方(caller)清理栈

__stdcall 的优点:生成代码体积更小,因为栈清理代码只在函数体中出现一次。__cdecl 的优点:可以接受可变数量参数(用省略号 "..." 指定)。

Visual C++ 用户模式项目的默认调用约定是 __cdecl。指定调用约定的方式是在返回类型和函数名之间放置关键字:

cpp
extern "C" __declspec(dllexport) bool __stdcall IsPrime(int n);

定义函数指针时也必须指定正确的调用约定:

cpp
using PIsPrime = bool(__stdcall *)(int);

__stdcall 是大多数 Windows API 使用的调用约定,通常用 WINAPIAPIENTRYPASCALCALLBACK 等宏表示。

__stdcall 函数的名称修饰方式不同——x86 下函数名前加下划线 _,后加 @ 和参数字节数(如 _IsPrime@4),x64 下则只是 IsPrime

解决名称修饰问题的另一种方法是使用模块定义文件(Module-Definition File,.def 文件),添加到 DLL 项目中列出导出符号:

LIBRARY
EXPORTS
IsPrime

DEF 文件名必须与项目名称相同。使用 DEF 文件后,导出函数名将完全按文件中指定的名称导出,不受调用约定和名称修饰的影响。

DLL 搜索和重定向

SetDllDirectory 可以添加自定义搜索路径:

cpp
BOOL SetDllDirectory(_In_opt_ LPCTSTR lpPathName);

指定路径会在可执行文件目录之后查找。lpPathNameNULL 时移除之前设置的路径;传递空字符串时会从搜索列表中移除当前目录。

AddDllDirectory 用于添加多个搜索目录:

cpp
DLL_DIRECTORY_COOKIE AddDllDirectory(_In_ PCTSTR NewDirectory);

返回值是一个不透明指针(opaque pointer)。需要额外调用 SetDefaultDllDirectories 启用这些目录:

cpp
BOOL SetDefaultDllDirectories(_In_ DWORD DirectoryFlags);

标志值(以 LOAD_LIBRARY_SEARCH_ 为前缀)包括:

  • APPLICATION_DIR:可执行文件目录
  • USER_DIRS:通过 AddDllDirectory 添加的目录
  • SYSTEM32:System32 目录
  • DEFAULT_DIRS:以上所有组合
  • DLL_LOAD_DIR:已加载 DLL 所在的目录

移除目录使用 RemoveDllDirectory

cpp
BOOL RemoveDllDirectory(_In_ DLL_DIRECTORY_COOKIE Cookie);

DllMain 函数

DLL 入口点(Entry Point)的原型如下:

cpp
BOOL WINAPI DllMain(HINSTANCE hInstDll, DWORD reason, PVOID reserved);
  • hInstDll:DLL 加载到进程中的虚拟地址,与 LoadLibrary 返回值相同
  • reason:指示 DLL 被调用的原因
  • reserved:指示 DLL 是隐式加载还是显式加载

reason 参数的取值:

  • DLL_PROCESS_ATTACH:DLL 附加到进程时触发
  • DLL_PROCESS_DETACH:DLL 从进程卸载前触发
  • DLL_THREAD_ATTACH:进程中创建新线程时触发
  • DLL_THREAD_DETACH:进程中线程退出前触发

同一 DLL 多次加载(多次 LoadLibrary)时,内部引用计数器会增加,但 DllMain 不会再次被调用。对于 DLL_PROCESS_ATTACH,必须返回 TRUE 表示正确初始化,返回 FALSE 则 DLL 被卸载。

DLL_PROCESS_DETACH 在 DLL 卸载前调用(进程正常关闭或调用 FreeLibrary)。使用 TerminateProcess 强制终止进程不会调用 DllMain

优化方法——使用 DisableThreadLibraryCalls 禁用线程通知:

cpp
BOOL DisableThreadLibraryCalls(_In_ HMODULE hLibModule);

该函数告诉系统不要为线程相关事件(DLL_THREAD_ATTACH / DLL_THREAD_DETACH)调用 DllMain。通常在收到 DLL_PROCESS_ATTACH 时调用。进程中的第一个线程不会触发 DLL_THREAD_ATTACH

reason 还支持第五个值 DLL_PROCESS_VERIFIER(等于 4),用于编写应用程序验证 DLL。

最后一个参数 reserved 指示 DLL 是隐式加载(非 NULL)还是显式加载(NULL)。

Visual Studio 提供的基本 DllMain 示例:

cpp
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
    switch (reason) {
        case DLL_PROCESS_ATTACH:
            ::DisableThreadLibraryCalls(hModule);
            break;
        case DLL_THREAD_ATTACH:
        case DLL_THREAD_DETACH:
        case DLL_PROCESS_DETACH:
            break;
    }
    return TRUE;
}

调用 DllMain 时会持有加载器锁(Loader Lock),类似于临界区(Critical Section)。在 DllMain 中调用某些函数可能导致死锁(Deadlock)。建议在 DllMain 中尽量少做操作,将复杂初始化推迟到 DllMain 返回后调用的显式函数中。应避免使用创建/销毁进程和 DLL 的函数(如 CreateProcessCreateThread 等)。使用堆或虚拟 API、I/O 函数、TLS(Thread Local Storage)及 kernel32.dll 中的大多数其他函数是安全的。

DLL 注入

DLL 注入(DLL Injection)指强制另一个进程加载特定 DLL,使其在目标进程上下文中执行代码。用途包括:

  • 反恶意软件(Anti-malware)挂钩 API
  • 自定义窗口 UI
  • 监测(Monitor)应用程序行为
  • 恶意用途(Malicious purposes)

使用远程线程注入

在目标进程中创建线程来加载所需 DLL。核心思路是创建一个线程,该线程使用要注入的 DLL 路径调用 LoadLibrary

注入器(Injector)项目实现,首先检查命令行参数:

cpp
int main(int argc, const char* argv[]) {
    if (argc < 3) {
        printf("Usage: injector <pid> <dllpath>\n");
        return 0;
    }

打开目标进程句柄:

cpp
HANDLE hProcess = ::OpenProcess(
    PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
    FALSE, atoi(argv[1]));

这里的核心技巧在于:从二进制角度看,LoadLibrary 和线程函数本质相同——都接受一个指针参数。创建运行 LoadLibrary 的线程,其代码已在目标进程中(作为 kernel32.dll 的一部分),因此地址在进程间是相同的。

准备 DLL 路径字符串,用 VirtualAllocEx 在目标进程中分配内存:

cpp
void* buffer = ::VirtualAllocEx(hProcess, nullptr, 1 << 12,
    MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

将路径复制到分配的缓冲区:

cpp
if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
    return Error("Failed to write to target process");

创建远程线程:

cpp
DWORD tid;
HANDLE hThread = ::CreateRemoteThread(hProcess, nullptr, 0,
    (LPTHREAD_START_ROUTINE)::GetProcAddress(
        ::GetModuleHandle(L"kernel32"), "LoadLibraryA"),
    buffer, 0, &tid);

这里利用了 LoadLibrary 和线程函数在二进制层面的等效性。LoadLibraryA 的地址在当前进程和远程进程中相同(因为 kernel32.dll 在每个进程中映射到相同的基地址)。注入的 DLL 必须与目标进程的位数相同——32 位进程不能加载 64 位 DLL,反之亦然。

清理工作:

cpp
printf("Thread %u created successfully!\n", tid);
if (WAIT_OBJECT_0 == ::WaitForSingleObject(hThread, 5000))
    printf("Thread exited.\n");
else
    printf("Thread still hanging around...\n");
::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
::CloseHandle(hThread);
::CloseHandle(hProcess);

等待线程终止不是必需的,但调用 VirtualFreeEx 之前需要给远程线程一些执行时间,以确保 DLL 路径字符串已被读取。

注入的 DLL 的 DllMain 可以显示消息框来验证注入成功:

cpp
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID lpReserved) {
    switch (reason) {
    case DLL_PROCESS_ATTACH:
        wchar_t text[128];
        ::StringCchPrintf(text, _countof(text), L"Injected into process %u",
            ::GetCurrentProcessId());
        ::MessageBox(nullptr, text, L"Injected.Dll", MB_OK);
        break;
    }
    return TRUE;
}

Windows 钩子

Windows 钩子(Windows Hooks)是通过 SetWindowsHookEx API 使用的与 UI 相关的挂钩机制:

cpp
HHOOK SetWindowsHookEx(
    _In_ int idHook,
    _In_ HOOKPROC lpfn,
    _In_opt_ HINSTANCE hmod,
    _In_ DWORD dwThreadId);

挂钩类型(idHook)有几种,各有特定语义。挂钩可以针对特定线程(由 dwThreadId 指定)或全局安装(dwThreadId 为零)。有些类型只能全局安装,包括 WH_JOURNALRECORDWH_JOURNALPLAYBACKWH_MOUSE_LLWH_KEYBOARD_LLWH_SYSMSGFILTER

挂钩函数(Hook Procedure)的原型:

cpp
typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);

如果挂钩是全局使用的,或者用于不同进程的线程,则回调函数必须是 DLL 的一部分并被注入到目标进程中。hmod 是调用者提供的 DLL 句柄。

全局挂钩的缺点:

  • 只能用于加载了 user32.dll 的进程
  • 仅对调用者桌面(Desktop)的所有线程"全局",无法挂钩其他会话(Session)中的进程

使用 SetWindowsHookEx 进行 DLL 注入和挂钩

示例使用 WH_GETMESSAGE 挂钩类型注入 DLL 到记事本进程,监控按键。涉及两个项目:

  • HookInject(可执行文件):负责查找目标线程并安装挂钩
  • HookDll(DLL):通过 SetWindowsHookEx 间接注入到目标进程

查找记事本第一个线程的函数:

cpp
DWORD FindMainNotepadThread() {
    auto hSnapshot = ::CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (hSnapshot == INVALID_HANDLE_VALUE) return 0;
    DWORD tid = 0;
    THREADENTRY32 th32;
    th32.dwSize = sizeof(th32);
    ::Thread32First(hSnapshot, &th32);
    do {
        auto hProcess = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION,
                                      FALSE, th32.th32OwnerProcessID);
        if (hProcess) {
            WCHAR name[MAX_PATH];
            if (::GetProcessImageFileName(hProcess, name, MAX_PATH) > 0) {
                auto bs = ::wcsrchr(name, L'\\');
                if (bs && ::_wcsicmp(bs, L"\\notepad.exe") == 0) {
                    tid = th32.th32ThreadID;
                }
            }
            ::CloseHandle(hProcess);
        }
    } while (tid == 0 && ::Thread32Next(hSnapshot, &th32));
    ::CloseHandle(hSnapshot);
    return tid;
}

主函数调用 FindMainNotepadThread,加载 HookDll,提取两个导出函数 SetNotificationThreadHookFunction

cpp
auto hDll = ::LoadLibrary(L"HookDll");
using PSetNotify = void (WINAPI*)(DWORD, HHOOK);
auto setNotify = (PSetNotify)::GetProcAddress(hDll, "SetNotificationThread");
auto hookFunc = (HOOKPROC)::GetProcAddress(hDll, "HookFunction");

安装挂钩:

cpp
auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);

设置通知线程并强制加载(发送一条空消息确保 DLL 被注入):

cpp
setNotify(::GetCurrentThreadId(), hHook);
::PostThreadMessage(tid, WM_NULL, 0, 0);

等待并处理传入消息:

cpp
MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
    if (msg.message == WM_APP) {
        printf("%c", (int)msg.wParam);
        if (msg.wParam == 13) printf("\n");
    }
}
::UnhookWindowsHookEx(hHook);
::FreeLibrary(hDll);

挂钩 DLL 使用全局共享变量(Shared Data Section)解决跨进程通信问题:

cpp
#pragma data_seg(".shared")
DWORD g_ThreadId = 0;
HHOOK g_hHook = nullptr;
#pragma data_seg()
#pragma comment(linker, "/section:.shared,RWS")

#pragma data_seg(".shared") 创建了一个命名数据段,RWS 标志(Read, Write, Shared)使其在所有加载此 DLL 的进程间共享。

SetNotificationThread 函数将信息写入共享变量:

cpp
extern "C" void WINAPI SetNotificationThread(DWORD threadId, HHOOK hHook) {
    g_ThreadId = threadId;
    g_hHook = hHook;
}

DllMain 实现:

cpp
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID pReserved) {
    switch (reason) {
    case DLL_PROCESS_ATTACH:
        ::DisableThreadLibraryCalls(hModule);
        break;
    case DLL_PROCESS_DETACH:
        ::PostThreadMessage(g_ThreadId, WM_QUIT, 0, 0);
        break;
    }
    return TRUE;
}

挂钩函数捕获按键消息并转发到注入进程:

cpp
extern "C" LRESULT CALLBACK HookFunction(int code, WPARAM wParam, LPARAM lParam) {
    if (code == HC_ACTION) {
        auto msg = (MSG*)lParam;
        if (msg->message == WM_CHAR) {
            ::PostThreadMessage(g_ThreadId, WM_APP, msg->wParam, msg->lParam);
        }
    }
    return ::CallNextHookEx(g_hHook, code, wParam, lParam);
}

CallNextHookEx 让挂钩链中其他挂钩也有机会执行,这是编写 Windows 钩子时必须遵守的规范。

API 挂钩

API 挂钩(API Hooking)指拦截 Windows API 的行为,可以检查参数并可能改变其行为。反恶意软件常用此技术注入 DLL 并挂钩关注的函数,从而监控或修改系统调用。

导入地址表挂钩

每个 PE 映像都有导入表(Import Table),列出依赖的 DLL 和使用的函数。所有调用通过 IAT(Import Address Table,导入地址表)间接进行——加载程序在运行时映射函数后填入最终地址。

IAT 挂钩(IAT Hooking)的原理:替换 IAT 中函数的地址,使其指向另一个自定义函数,同时保存原始地址以便需要时调用原始实现。

以下示例挂钩 user32.dll 中的 GetSysColor API 来更改系统颜色。首先保存原始函数指针:

cpp
decltype(::GetSysColor)* GetSysColorOrg;

获取原始函数并挂钩所有模块:

cpp
void HookFunctions() {
    auto hUser32 = ::GetModuleHandle(L"user32");
    GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(
        hUser32, "GetSysColor");

    auto count = IATHelper::HookAllModules("user32.dll",
        GetSysColorOrg, GetSysColorHooked);
}

自定义挂钩实现——改变按钮文字和窗口文字的颜色:

cpp
COLORREF WINAPI GetSysColorHooked(int index) {
    switch (index) {
    case COLOR_BTNTEXT:
        return RGB(0, 128, 0);    // 绿色按钮文字
    case COLOR_WINDOWTEXT:
        return RGB(0, 0, 255);    // 蓝色窗口文字
    }
    return GetSysColorOrg(index); // 其他颜色调用原始函数
}

IATHelper::HookAllModules 遍历进程当前加载的所有模块:

cpp
int IATHelper::HookAllModules(PCSTR moduleName, PVOID originalProc, PVOID hookProc) {
    HMODULE hMod[1024];
    DWORD needed;
    if (!::EnumProcessModules(::GetCurrentProcess(), hMod, sizeof(hMod), &needed))
        return 0;
    WCHAR name[256];
    int count = 0;
    for (DWORD i = 0; i < needed / sizeof(HMODULE); i++) {
        if (::GetModuleBaseName(::GetCurrentProcess(), hMod[i], name, _countof(name))) {
            count += HookFunction(name, moduleName, originalProc, hookProc);
        }
    }
    return count;
}

HookFunction 使用 PE 解析定位导入表并替换函数地址:

cpp
int IATHelper::HookFunction(PCWSTR callerModule, PCSTR moduleName,
    PVOID originalProc, PVOID hookProc) {
    HMODULE hMod = ::GetModuleHandle(callerModule);
    if (!hMod) return 0;
    ULONG size;
    auto desc = (PIMAGE_IMPORT_DESCRIPTOR)::ImageDirectoryEntryToData(hMod, TRUE,
        IMAGE_DIRECTORY_ENTRY_IMPORT, &size);
    if (!desc) return 0;
    int count = 0;
    for (; desc->Name; desc++) {
        auto modName = (PSTR)hMod + desc->Name;
        if (::_stricmp(moduleName, modName) == 0) {
            auto thunk = (PIMAGE_THUNK_DATA)((PBYTE)hMod + desc->FirstThunk);
            for (; thunk->u1.Function; thunk++) {
                auto addr = &thunk->u1.Function;
                if (*(PVOID*)addr == originalProc) {
                    DWORD old;
                    if (::VirtualProtect(addr, sizeof(void*), PAGE_WRITECOPY, &old)) {
                        *(void**)addr = (void*)hookProc;
                        count++;
                    }
                }
            }
        }
    }
    return count;
}

IAT 挂钩的缺点:

  • 如果稍后加载了新模块,必须重新挂钩这些新模块
  • 可以通过避免使用 IAT(直接使用 GetProcAddress 返回的函数指针调用)轻松绕过

"Detours" 风格的挂钩

Detours 风格挂钩(Detours-style Hooking,也称 Inline Hooking)的步骤:

  1. 找到原始函数地址并保存
  2. 用 JMP 指令替换函数代码的前几个字节(保存被覆盖的旧代码)
  3. 跳转指令将执行流转移到被挂钩函数
  4. 需要调用原始代码时,使用保存的地址(通过跳板 trampoline)
  5. 取消挂钩时恢复被修改的字节

优点:无论函数如何被调用(通过 IAT 还是直接调用),实际函数代码被修改了,无法绕过。缺点:替换代码是平台特定的(x86、x64、ARM、ARM64 各有不同);必须以原子操作完成,因为其他线程可能正在调用该函数。

微软提供了 Detours 库(也可使用 MinHook、EasyHook 等第三方库)。可以通过 NuGet 添加 detours 包。

挂钩示例(挂钩 GetWindowTextLengthWGetWindowTextW,在编辑控件返回内容后附加字符串):

cpp
#include <detours.h>

bool HookFunctions() {
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
    DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
    auto error = DetourTransactionCommit();
    return error == ERROR_SUCCESS;
}

保存原始函数指针:

cpp
decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;

被挂钩的实现——在编辑控件内容后附加 " (Hooked!)"

cpp
static WCHAR extra[] = L" (Hooked!)";

bool IsEditControl(HWND hWnd) {
    WCHAR name[32];
    return ::GetClassName(hWnd, name, _countof(name)) &&
           ::_wcsicmp(name, L"EDIT") == 0;
}

int WINAPI GetWindowTextHooked(
    _In_  HWND   hWnd,
    _Out_ LPWSTR lpString,
    _In_  int    nMaxCount) {
    auto count = GetWindowTextOrg(hWnd, lpString, nMaxCount);
    if (IsEditControl(hWnd)) {
        if (count + _countof(extra) <= nMaxCount) {
            ::StringCchCatW(lpString, nMaxCount, extra);
            count += _countof(extra);
        }
    }
    return count;
}

int WINAPI GetWindowTextLengthHooked(HWND hWnd) {
    auto len = GetWindowTextLengthOrg(hWnd);
    if (IsEditControl(hWnd))
        len += (int)wcslen(extra);
    return len;
}

DetourTransactionBegin / DetourTransactionCommit 提供了事务性语义,确保多个挂钩的安装是原子的。

DLL 基地址

每个 DLL 有首选加载地址(Preferred Base Address),也称为基地址(Base Address),是 PE 头的一部分。可以在 Visual Studio 项目属性中指定。32 位 DLL 的默认值为 0x10000000,64 位 DLL 的默认值为 0x180000000。可通过 dumpbin /headers 验证。

在 ASLR(Address Space Layout Randomization,地址空间布局随机化)出现之前(Windows Vista 之前),DLL 坚持加载到首选地址。若该地址已被占用,DLL 需要重定位(Relocation)——加载程序找到新位置,执行代码修正(fixups),这需要时间和额外内存,因为被重定位的代码页不再能被多个进程共享。

在进程资源管理器(Process Explorer)中,重定位的 DLL 可以通过"映像基址"(Image Base)列与"基址"(Base)列是否不同来识别。可通过 rebase.exe 工具(Windows SDK)或编程方式(ReBaseImage64 函数)解决重定位问题。

大多数现代 DLL 具有"动态基址"(Dynamic Base / DYNAMICBASE)特征标志。在 ASLR 下,加载程序随机选择高位地址,随着加载过程逐渐降低,冲突的可能性极低。

延迟加载 DLL

延迟加载 DLL(Delay-Load DLL)是介于静态链接和动态链接之间的"中间地带"——既有静态链接的便利性,又能在需要时才动态加载。

在链接器选项的"输入"选项卡中添加应延迟加载的 DLL。若支持动态卸载,在链接器"高级"选项卡中添加"卸载延迟加载的 DLL"选项。链接 DLL 的导入库文件,像隐式链接一样使用导出功能。

示例(SimplePrimes2 项目):

cpp
#include "..\SimpleDll\Simple.h"
#include <delayimp.h>

bool IsLoaded() {
    auto hModule = ::GetModuleHandle(L"simpledll");
    printf("SimpleDll loaded: %s\n", hModule ? "Yes" : "No");
    return hModule != nullptr;
}

int main() {
    IsLoaded();
    bool prime = IsPrime(17);
    IsLoaded();
    printf("17 is prime? %s\n", prime ? "Yes" : "No");
    __FUnloadDelayLoadedDLL2("SimpleDll.dll");
    IsLoaded();
    prime = IsPrime(1234567);
    IsLoaded();
    return 0;
}

__FUnloadDelayLoadedDLL2(来自 delayimp.h)用于卸载延迟加载的 DLL。若调用 FreeLibrary,DLL 会被卸载,但之后需要再次使用该 DLL 时,仅调用导出函数无法重新加载,反而会引发访问冲突(Access Violation)。

运行输出演示了 DLL 加载和卸载的状态变化:首次调用 IsPrime 前 DLL 未加载,调用后 DLL 被加载;卸载后再次调用 IsPrime 时 DLL 被重新加载。

调用延迟加载 DLL 的导出函数时,实际调用的是另一个由延迟加载基础架构提供的桩函数(stub),它调用 LoadLibraryGetProcAddress,然后调用目标函数并修正导入表,使后续对同一函数的调用可以直接执行,无需再次查找。

LoadLibraryEx 函数

LoadLibraryExLoadLibrary 的扩展函数:

cpp
HMODULE LoadLibraryEx(
    _In_ LPCTSTR lpLibFileName,
    _Reserved_ HANDLE hFile,   // 必须为 NULL
    _In_ DWORD dwFlags);

支持通过 dwFlags 影响搜索和/或加载方式的标志(Flags):

  • LOAD_LIBRARY_AS_DATAFILE:仅将 DLL 映射到进程地址空间,忽略 PE 映像属性。不调用 DllMainGetModuleHandleGetProcAddress 会失败。
  • LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE:类似,但文件以独占访问方式打开。
  • LOAD_LIBRARY_AS_IMAGE_RESOURCE:作为映像文件加载,不执行初始化。常与 LOAD_LIBRARY_AS_DATAFILE 一起使用,用于提取资源。
  • LOAD_WITH_ALTERED_SEARCH_PATH:如果指定了绝对路径,则该路径用作搜索基础。

使用 LOAD_LIBRARY_AS_IMAGE_RESOURCE 时,返回的句柄可通过 LoadStringLoadBitmapLoadIcon 等 API 提取资源。对于自定义资源,使用 FindResource(或 FindResourceEx)、SizeOfResourceLoadResourceLockResource 这一系列 API。

其他函数

GetModuleFileNameGetModuleFileNameEx

cpp
DWORD GetModuleFileName(
    _In_opt_ HMODULE hModule,
    _Out_ LPTSTR lpFilename,
    _In_ DWORD nSize);

DWORD GetModuleFileNameEx(
    _In_opt_ HANDLE hProcess,
    _In_opt_ HMODULE hModule,
    _Out_ LPWSTR lpFilename,
    _In_ DWORD nSize);

两者都返回已加载模块的完整路径。GetModuleFileNameEx 可以获取其他进程中的模块信息(句柄需有 PROCESS_QUERY_INFORMATIONPROCESS_QUERY_LIMITED_INFORMATION 权限)。hModuleNULL 时返回主模块(可执行文件)的路径。

LoadPackagedLibrary(Windows 8+):

LoadLibrary 的变体,UWP 进程用于加载属于其包的 DLL:

cpp
HMODULE LoadPackagedLibrary(
    _In_ LPCWSTR lpwLibFileName,
    _Reserved_ DWORD Reserved);    // 必须为零

FreeLibraryAndExitThread

解决线程卸载自身代码所在 DLL 时的问题(使用 FreeLibrary 后调用 ExitThread 会导致崩溃,因为代码已经不存在):

cpp
VOID FreeLibraryAndExitThread(
    _In_ HMODULE hLibModule,
    _In_ DWORD dwExitCode);

该函数释放指定模块,然后调用 ExitThread,不会返回。

总结

本章探讨了 DLL 的构建和使用方法,涵盖了隐式链接和显式链接两种加载方式、调用约定对函数导出的影响、DLL 搜索路径与重定向机制、DllMain 入口点的使用规范、DLL 注入技术(远程线程注入和 Windows 钩子注入)、API 挂钩技术(IAT 挂钩和 Detours 风格挂钩)、DLL 基地址与 ASLR、延迟加载 DLL 以及 LoadLibraryEx 等高级函数。DLL 注入到另一个进程的能力使其在目标进程中拥有很大权限,这既是强大的工具,也带来了安全上的考量。下一章将转向安全性这一完全不同的主题。