☰
从内存加载DLL:手动映射PE镜像与无文件加载实战
2026/9/29 16:16:22 网站建设 项目流程

简介:这是一份面向Windows逆向与安全开发学习者的内存加载DLL完整实现代码,解决的是如何绕过常规磁盘加载流程、直接从内存或资源中加载DLL并调用其导出函数的问题,适合具备一定C/C++与PE结构基础的中高级开发者研究。资源包共22个文件,约87KB,包含h头文件、cpp源码、dll动态库、lib导入库、dsp/dsw工程文件及rc资源脚本等,其中MemoryModule.c与MemoryModule.h是核心加载模块,xDll为测试用DLL,testLoadDll为实际调用示例,编译产物与工程配置一应俱全。核心接口包括MemoryLoadLibrary()负责从内存加载、MemoryGetProcAddress()查找导出函数、MemoryFreeLibrary()完成释放,形成完整闭环。目前已有1506人学习下载,读者可据此理解PE手动映射、重定位与导入表修复思路,并直接编译运行验证,是研究无文件加载技术的实用参考。

1. 从内存加载 DLL:绕过文件落地的加载方式到底解决了什么问题

做过 Windows 安全对抗、外挂检测、红队工具或者加壳保护的人,大概率都碰过同一个场景:手头有一个 DLL,但不想让它以文件形式躺在磁盘上被扫描、被静态分析、被GetModuleFileName反查路径。常规的LoadLibrary要求传入一个磁盘路径,Windows 加载器会走完整的 PE 映射流程,文件句柄、模块链表、加载器锁一个都跑不掉。而「从内存加载 DLL」这件事,本质就是把这套加载器干的活自己用代码重写一遍:手动解析 PE 头、按节区把镜像映射到内存、修复导入表、处理重定位、执行 TLS 回调、最后调用DllMain。整条链路走完,DLL 就"活"在了一块自己申请的内存里,磁盘上什么都没有。这篇要讲的就是这套完整代码怎么写、每个参数为什么这么设、以及实际跑起来会在哪些地方翻车。适合有 C/C++ 和 Win32 基础、想搞明白 PE 加载机制或者需要做无文件加载的从业者。

2. 手动映射 PE 镜像:从LoadLibrary到自实现加载器的完整链路

2.1 为什么不能直接memcpy完事

很多人第一反应是:把 DLL 文件读进内存,然后直接跳过去调用导出函数不就行了?这个想法会立刻翻车。磁盘上的 PE 文件是"未展开"状态,节区在文件里的偏移(PointerToRawData)和它期望被加载到的内存偏移(VirtualAddress)通常不一致。加载器会把每个节区按VirtualAddress放到镜像基址加偏移的位置,并且按SizeOfImage申请一整块内存,节区之间的空隙用零填充。你直接memcpy整个文件,节区位置全错,导入表里的函数地址还是 RVA 而不是真实地址,一调用就崩。

所以手动加载的核心步骤是固定的:读取文件 → 校验 PE 签名 → 按SizeOfImage分配内存 → 拷贝 PE 头和每个节区到正确偏移 → 修复导入表 → 处理重定位 → 设置内存页保护属性 → 执行 TLS → 调用入口点。下面这段是最小可用的映射骨架。

#include <windows.h> #include <stdio.h> // 把磁盘上的 DLL 文件读进堆内存,返回缓冲区指针和大小 static BYTE* ReadFileToBuffer(const char* path, SIZE_T* outSize) { HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile == INVALID_HANDLE_VALUE) return NULL; DWORD fileSize = GetFileSize(hFile, NULL); BYTE* buf = (BYTE*)malloc(fileSize); DWORD read = 0; ReadFile(hFile, buf, fileSize, &read, NULL); CloseHandle(hFile); *outSize = fileSize; return buf; } // 核心:把 PE 镜像映射到内存,返回镜像基址 static BYTE* MapImageToMemory(BYTE* rawData) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)rawData; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL; // 校验 MZ IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(rawData + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL; // 校验 PE // 按 SizeOfImage 申请可读写内存,这是整个镜像的总大小 SIZE_T imageSize = nt->OptionalHeader.SizeOfImage; BYTE* imageBase = (BYTE*)VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!imageBase) return NULL; // 先拷贝 PE 头(含 DOS 头、NT 头、节表) memcpy(imageBase, rawData, nt->OptionalHeader.SizeOfHeaders); // 逐节区拷贝到 VirtualAddress 指定的偏移 IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt); for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) { if (sec->SizeOfRawData == 0) continue; memcpy(imageBase + sec->VirtualAddress, rawData + sec->PointerToRawData, sec->SizeOfRawData); } return imageBase; }

SizeOfHeaders是 PE 头和节表的总大小,通常 0x400 对齐;SizeOfImage是整个镜像展开后的大小,按SectionAlignment(一般 0x1000)对齐。这两个值必须从OptionalHeader里读,不能自己算。VirtualAlloc这里先用PAGE_READWRITE,因为后面要改导入表和重定位,等全部修完再按节区属性改回PAGE_EXECUTE_READ之类。

2.2 导入表修复:IAT 里填的到底是什么

映射完只是第一步,此时镜像里的导入表(Import Address Table)还是空的或者指向 RVA。加载器要遍历导入目录,对每个依赖的 DLL 调用LoadLibrary,再用GetProcAddress拿到函数真实地址,写回 IAT。这一步不做,任何调用外部 API 的代码都会跳到野地址。

// 修复导入表:遍历每个导入描述符,加载依赖 DLL 并填充 IAT static BOOL FixImports(BYTE* imageBase) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); // 导入表目录是数据目录的第 1 项(索引 1) IMAGE_DATA_DIRECTORY impDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (impDir.VirtualAddress == 0) return TRUE; // 没有导入表,直接过 IMAGE_IMPORT_DESCRIPTOR* imp = (IMAGE_IMPORT_DESCRIPTOR*)(imageBase + impDir.VirtualAddress); for (; imp->Name != 0; imp++) { const char* dllName = (const char*)(imageBase + imp->Name); HMODULE hDep = LoadLibraryA(dllName); // 依赖 DLL 仍走系统加载 if (!hDep) return FALSE; // OriginalFirstThunk 指向名字表,FirstThunk 指向要填的 IAT IMAGE_THUNK_DATA* origThunk = (IMAGE_THUNK_DATA*)(imageBase + imp->OriginalFirstThunk); IMAGE_THUNK_DATA* iatThunk = (IMAGE_THUNK_DATA*)(imageBase + imp->FirstThunk); for (; origThunk->u1.AddressOfData != 0; origThunk++, iatThunk++) { FARPROC funcAddr; if (origThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) { // 按序号导入 funcAddr = GetProcAddress(hDep, (LPCSTR)(origThunk->u1.Ordinal & 0xFFFF)); } else { // 按名字导入 IMAGE_IMPORT_BY_NAME* ibn = (IMAGE_IMPORT_BY_NAME*)(imageBase + origThunk->u1.AddressOfData); funcAddr = GetProcAddress(hDep, (LPCSTR)ibn->Name); } if (!funcAddr) return FALSE; iatThunk->u1.Function = (ULONG_PTR)funcAddr; // 写回真实地址 } } return TRUE; }

这里有个容易搞混的点:OriginalFirstThunk和FirstThunk在文件里可能指向同一份数据,但加载后FirstThunk会被改写成真实地址,所以遍历名字必须用OriginalFirstThunk。如果某个 DLL 编译时把OriginalFirstThunk置零(少见但存在),就得退回用FirstThunk当名字表读,这是血泪经验,遇到再说。

2.3 重定位处理:为什么你的代码在别的进程里必崩

如果 DLL 编译时带了重定位表(.reloc节),说明它支持被加载到任意基址。手动映射时你申请的imageBase几乎不可能等于OptionalHeader.ImageBase(默认 0x10000000 之类),所以所有写死的绝对地址都得按差值修正。不做这一步,凡是引用全局变量、字符串常量、虚函数表的地方全崩。

// 处理重定位:按 delta 修正所有需要重定位的地址 static void FixRelocations(BYTE* imageBase, ULONG_PTR preferredBase) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); IMAGE_DATA_DIRECTORY relocDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir.VirtualAddress == 0) return; // 无重定位表 // delta = 实际基址 - 首选基址,可能为负,用有符号类型 LONG_PTR delta = (LONG_PTR)imageBase - (LONG_PTR)preferredBase; IMAGE_BASE_RELOCATION* reloc = (IMAGE_BASE_RELOCATION*)(imageBase + relocDir.VirtualAddress); BYTE* relocEnd = (BYTE*)reloc + relocDir.Size; while ((BYTE*)reloc < relocEnd && reloc->SizeOfBlock != 0) { // 每个块头 8 字节,后面跟 (SizeOfBlock-8)/2 个 WORD 项 int count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* items = (WORD*)(reloc + 1); for (int i = 0; i < count; i++) { int type = items[i] >> 12; // 高 4 位是类型 int offset = items[i] & 0x0FFF; // 低 12 位是页内偏移 if (type == IMAGE_REL_BASED_DIR64) { // 64 位下最常见的类型,直接加 delta ULONG_PTR* patch = (ULONG_PTR*) (imageBase + reloc->VirtualAddress + offset); *patch += delta; } else if (type == IMAGE_REL_BASED_HIGHLOW) { // 32 位类型,加 32 位 delta DWORD* patch = (DWORD*) (imageBase + reloc->VirtualAddress + offset); *patch += (DWORD)delta; } // IMAGE_REL_BASED_ABSOLUTE(0) 是填充项,跳过 } reloc = (IMAGE_BASE_RELOCATION*)((BYTE*)reloc + reloc->SizeOfBlock); } }

delta一定要用有符号的LONG_PTR,因为实际基址可能比首选基址低,用无符号会算错。类型判断里IMAGE_REL_BASED_DIR64是 64 位程序的主力,IMAGE_REL_BASED_HIGHLOW是 32 位的,两个都处理上基本够用。

3. 收尾三件事:内存属性、TLS 回调与DllMain调用顺序

3.1 按节区属性改内存保护

映射时整块内存是PAGE_READWRITE,但代码节需要可执行,只读数据节不该可写。加载器会按每个节区的Characteristics设置保护属性。不改的话,要么代码执行触发 DEP 崩溃,要么留下可写可执行的内存被安全软件盯上。

// 按节区 Characteristics 设置内存保护属性 static void ProtectSections(BYTE* imageBase) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt); for (int i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) { DWORD protect = PAGE_READONLY; DWORD ch = sec->Characteristics; if (ch & IMAGE_SCN_MEM_EXECUTE) { protect = (ch & IMAGE_SCN_MEM_WRITE) ? PAGE_EXECUTE_READWRITE : PAGE_EXECUTE_READ; } else if (ch & IMAGE_SCN_MEM_WRITE) { protect = PAGE_READWRITE; } DWORD old; VirtualProtect(imageBase + sec->VirtualAddress, sec->Misc.VirtualSize, protect, &old); } }

Misc.VirtualSize是节区在内存里的实际大小,可能比SizeOfRawData大(因为对齐),改保护要用它。IMAGE_SCN_MEM_EXECUTE和IMAGE_SCN_MEM_WRITE组合决定最终属性,逻辑和系统加载器一致。

3.2 TLS 回调:被忽略就会出玄学问题

带 TLS(线程本地存储)的 DLL 在入口点之前会先执行 TLS 回调。手动加载如果跳过这步,某些依赖 TLS 初始化的代码(比如 C++ 全局对象的构造、某些 CRT 初始化)会拿到未初始化的数据,表现为随机崩溃或者功能异常,非常难查。

// 执行 TLS 回调,必须在 DllMain 之前 static void RunTLSCallbacks(BYTE* imageBase) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); IMAGE_DATA_DIRECTORY tlsDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir.VirtualAddress == 0) return; IMAGE_TLS_DIRECTORY* tls = (IMAGE_TLS_DIRECTORY*)(imageBase + tlsDir.VirtualAddress); // AddressOfCallBacks 指向一个函数指针数组,以 NULL 结尾 PIMAGE_TLS_CALLBACK* cb = (PIMAGE_TLS_CALLBACK*)tls->AddressOfCallBacks; if (!cb) return; for (; *cb; cb++) { // 第二个参数 DLL_PROCESS_ATTACH,第三个参数保留为 NULL (*cb)((PVOID)imageBase, DLL_PROCESS_ATTACH, NULL); } }

注意AddressOfCallBacks在文件里存的是 VA(虚拟地址),重定位处理完之后它才指向正确位置。所以 TLS 回调必须在重定位之后执行,顺序不能乱。

3.3 调用入口点:DllMain的返回值要检查

最后一步是调用 DLL 的入口点,也就是DllMain。它的签名是BOOL DllMain(HINSTANCE, DWORD, LPVOID),手动加载时传DLL_PROCESS_ATTACH。返回值如果是FALSE,说明 DLL 初始化失败,整个加载流程要回滚。

// 调用 DLL 入口点,返回 DllMain 的执行结果 static BOOL CallEntryPoint(BYTE* imageBase) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); // AddressOfEntryPoint 是 RVA,加上基址得到真实地址 DWORD entryRva = nt->OptionalHeader.AddressOfEntryPoint; if (entryRva == 0) return TRUE; // 没有入口点,视为成功 typedef BOOL (WINAPI *DllMainFn)(HINSTANCE, DWORD, LPVOID); DllMainFn dllMain = (DllMainFn)(imageBase + entryRva); return dllMain((HINSTANCE)imageBase, DLL_PROCESS_ATTACH, NULL); }

完整的加载顺序是:MapImageToMemory→FixRelocations→FixImports→ProtectSections→RunTLSCallbacks→CallEntryPoint。顺序错了任何一步都会出问题,尤其是重定位必须在导入表修复之前,因为导入表本身也可能需要重定位。

4. 避坑与排查:内存加载 DLL 最常见的 5 个翻车现场

4.1 现象:调用导出函数直接访问违例,崩在第一条指令

原因通常是重定位没做或者做错了。DLL 被加载到了非首选基址,但代码里的绝对地址还是按首选基址算的,跳过去就是野地址。排查方法是打印实际imageBase和OptionalHeader.ImageBase,如果两者不等而你没处理重定位,那就是它。解决就是老老实实实现FixRelocations,并且确认delta用的是有符号类型。

4.2 现象:DllMain里调用LoadLibrary或创建线程时死锁

这是 Windows 加载器锁的经典问题。手动加载时你没有持有加载器锁,但DllMain里如果调用了会触发加载器操作的 API,在某些时序下会和系统加载器互相等待。规避办法是尽量让DllMain只做最简单的初始化,把复杂逻辑挪到导出函数里显式调用。如果 DLL 不是你能改的,那就得接受这个限制,别在DllMain里做重活。

4.3 现象:32 位 DLL 在 64 位进程里加载失败

PE 架构不匹配。IMAGE_FILE_MACHINE_I386的 DLL 不能被 64 位进程映射,反之亦然。加载前先检查FileHeader.Machine字段,和当前进程架构比对。跨架构加载只能靠起一个对应位数的子进程,没有别的办法。

4.4 现象:导入表修复成功但调用某个 API 时崩溃

大概率是OriginalFirstThunk为空的情况没处理。有些加壳或特殊编译的 DLL 会把OriginalFirstThunk置零,只保留FirstThunk。这时遍历名字表要退回用FirstThunk,但要注意FirstThunk在填充过程中会被改写,所以得先把名字读出来再填地址,不能边读边填。稳妥做法是先判断OriginalFirstThunk是否为 0,为 0 就用FirstThunk当源。

4.5 现象:加载后功能正常,但过一段时间随机崩溃

检查 TLS 回调是不是漏了。依赖 TLS 的全局对象如果没初始化,前期可能碰巧能跑,一旦访问到未初始化数据就崩。另一个可能是内存保护属性没设对,代码节被当成数据改了,或者数据节被当成代码执行触发 DEP。用调试器看崩溃地址落在哪个节区,对照ProtectSections的设置就能定位。

5. 进阶技巧:导出函数转发、延迟导入与内存加载的边界

把基础流程跑通之后,有几个进阶点值得单独处理。第一个是导出函数转发(Export Forwarding),有些 DLL 的导出表项不指向本模块的代码,而是写着OTHERDLL.FuncName这样的字符串,表示转发到别的 DLL。手动加载时如果直接按 RVA 算地址,转发项会算出一个垃圾地址。正确做法是解析导出目录时判断AddressOfFunctions里的 RVA 是否落在导出目录范围内,如果是就说明是转发,需要解析字符串再GetProcAddress。

// 解析导出表时处理转发项 static FARPROC ResolveExport(BYTE* imageBase, const char* funcName) { IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)imageBase; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(imageBase + dos->e_lfanew); IMAGE_DATA_DIRECTORY expDir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; if (expDir.VirtualAddress == 0) return NULL; IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(imageBase + expDir.VirtualAddress); DWORD* names = (DWORD*)(imageBase + exp->AddressOfNames); WORD* ordinals = (WORD*)(imageBase + exp->AddressOfNameOrdinals); DWORD* functions = (DWORD*)(imageBase + exp->AddressOfFunctions); for (DWORD i = 0; i < exp->NumberOfNames; i++) { const char* name = (const char*)(imageBase + names[i]); if (strcmp(name, funcName) != 0) continue; DWORD funcRva = functions[ordinals[i]]; // 判断是否落在导出目录范围内,是则为转发 if (funcRva >= expDir.VirtualAddress && funcRva < expDir.VirtualAddress + expDir.Size) { const char* forwarder = (const char*)(imageBase + funcRva); char dllName[256]; // 转发字符串格式 "DLL.Func",拆开分别加载 const char* dot = strchr(forwarder, '.'); if (!dot) return NULL; size_t len = dot - forwarder; memcpy(dllName, forwarder, len); dllName[len] = 0; HMODULE h = LoadLibraryA(dllName); return h ? GetProcAddress(h, dot + 1) : NULL; } return (FARPROC)(imageBase + funcRva); } return NULL; }

第二个是延迟导入(Delay Import),数据目录索引 13。延迟导入的 DLL 在第一次调用其函数时才加载,手动加载时可以选择立即解析或者保留延迟语义。如果目标 DLL 依赖延迟导入且你没处理,第一次调用相关函数会跳到一段存根代码,存根里可能引用了未初始化的辅助结构,同样会崩。稳妥做法是遍历延迟导入描述符,按和普通导入一样的方式提前填好。

第三个边界是:内存加载的 DLL 不会出现在EnumProcessModules或CreateToolhelp32Snapshot的模块列表里,GetModuleHandle也找不到它。这意味着依赖模块枚举做初始化的代码会失效,比如某些 CRT 的atexit注册、异常处理链的建立。如果你的 DLL 需要这些,得手动补上,或者接受功能受限。这也是内存加载最本质的取舍——你换来了隐蔽性,代价是脱离了系统加载器的完整生态。

我自己踩得最深的一次是忘了处理 TLS,一个带全局std::string的 DLL 加载后前几次调用都正常,跑到某个日志函数时突然崩,查了两天才定位到是全局对象没构造。从那以后我养成的习惯是:任何手动映射的 DLL,先在调试器里单步走完MapImageToMemory到CallEntryPoint全流程,确认每一步的返回值,再谈功能。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询