
简介这是一份面向Windows逆向与安全开发学习者的内存加载DLL完整实现代码解决的是如何绕过常规磁盘加载流程、直接从内存或资源中加载DLL并调用其导出函数的问题适合具备一定C/C与PE结构基础的中高级开发者研究。资源包共22个文件约87KB包含h头文件、cpp源码、dll动态库、lib导入库、dsp/dsw工程文件及rc资源脚本等其中MemoryModule.c与MemoryModule.h是核心加载模块xDll为测试用DLLtestLoadDll为实际调用示例编译产物与工程配置一应俱全。核心接口包括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全流程确认每一步的返回值再谈功能。希望帮到你。本文还有配套的精品资源点击获取