资讯详情

手写LoadLibrary:从PE结构到内存加载的实现解析

📅 2026/10/23 5:27:45 | 华诺云谱 👁 阅读
手写LoadLibrary:从PE结构到内存加载的实现解析
我入PE加载这一行的起因其实很偶然某次分析一个自制软件的加壳逻辑时发现它在内存里展开了一个DLL但并没有调用系统LoadLibrary而是手工把磁盘上的二进制内容像拼积木一样拼到了进程里。当时我对着调试器看了半天第一反应是这怎么可能跑起来。后来顺着PE结构一步步拆才明白原来系统加载器做的事情并没有那么神秘——DLL能被加载本质上就是把磁盘文件映射到内存、修好基址和指针最后调用入口点。从那以后我就养成了一个习惯遇到陌生的DLL加载逻辑先自己试着写一个mini版的Loader写会了PE结构就通了一大半。这篇文章就是那套手写LoadLibrary的经验总结也是系列文章里把理论落到代码上的关键一篇。手写LoadLibrary不是吃饱了撑的。安全研究里很多场景都要绕过系统API比如分析恶意样本时不想触发系统的DLL加载日志或者在沙箱里模拟一个只有内存镜像的环境做加固、加壳产品时也需要用自己的Loader去加载修改过的PE文件而系统API只认标准格式。哪怕你只想搞懂Cuckoo沙箱的进程注入原理手写Loader也是必经之路。所以这篇文章适合三类人第一类是想彻底吃透PE格式的逆向爱好者第二类是做脱壳机和虚拟化保护的开发者第三类是整天和内存加载打交道的安全从业者。我会尽量用实际操作中遇到的例子来展开而不是照搬规范文档。1. 为什么有人会放弃系统API选择手写Loader1.1 手动映射的典型应用场景系统提供的LoadLibraryW非常好用传入一个路径或句柄就完事但它有几个缺点对某些场景来说是致命的。最常见的一点是系统加载器会在进程里留下完整的映射信息——模块列表、加载路径、引用计数这些数据通过遍历PEB就能被任何进程扫描到。如果你只是在做内存加载实验不想让目标进程看到这个模块系统API就帮不上忙。另一个场景是PE文件本身已经被修改过比如手工把导入表加密了、节区的Raw Data和Virtual Size不一致、甚至把DOS头给藏了。系统加载器遇到这些畸形文件会直接拒绝加载但手写Loader因为所有逻辑都由自己控制可以先解密、先修复、再加载。还有一个很现实的需求加载一个DLL却不希望执行它的DllMain。很多安全研究工具要做静态分析前的动态预加载只想解析导出表或初始化重定位而系统LoadLibrary总会调用DllMain侧刀一剪想要的效果就没了。手写Loader可以把加载镜像和执行入口两步彻底分开想什么时候跑入口就在什么时候跑。1.2 系统Loader与手写Loader的边界在哪里系统Loader本质上做四件绝对必要的事将文件按节区映射到内存、根据加载基址是否变化执行重定位、将导入函数地址填入IAT、调用TLS回调和入口点。除此之外找依赖DLL并递归加载也是系统Loader干的活但手写Loader完全可以自己控制递归策略——比如只加载需要的API函数或者对导入表中不存在的DLL直接抛错而不是去全盘搜索。我们要清楚一个界限手写Loader追求的从来不是复制Windows加载器的百分之百行为而是让你掌握核心流程做到能用、可控、知道每一步在干什么。所以下面讲的重定位、导入表处理都是最小必要版本但每一步都不可省略。如果你的目标是写出一个可以稳定加载任意正常DLL的工具那还需要处理异常目录、安全Cookie、延迟导入等额外内容那些我会在后半部分单独讲到。2. PE文件结构速查从MZ头到节区表的寻址地图2.1 先记住最关键的偏移量而不是背整个文档手写Loader第一步不是写代码而是把PE文件在磁盘上的布局和内存中的布局彻底搞清楚。我通常用两张图帮助记忆一张是磁盘文件视图一张是进程内存视图。磁盘上看到的是DOS头、NT头、节区表、节数据它们按文件偏移连续排列内存里则是把所有节的Raw Data按Virtual Address映射到以ImageBase为起点的一段连续空间里。最常见的错误就是把文件偏移当内存偏移用。PE的节区头里有两个概念必须分开PointerToRawData是节数据在文件中的起始偏移VirtualAddress是该节加载到内存后相对于ImageBase的偏移。磁盘上可能用0x200对齐内存里用0x1000对齐所以同一个节的数据在两种视图下的位置是完全不同的。加载时我们要以SizeOfImage为准在目标进程里预留内存块然后把每个节的RawData拷贝到ImageBase VirtualAddress的位置而不是直接拷贝整个文件到内存顶格放。2.2 手写Loader真正关心的字段清单通过IMAGE_DOS_HEADER找到e_lfanew指向的IMAGE_NT_HEADERS后真正需要逐个使用的字段其实不多。我按操作顺序列一下OptionalHeader.ImageBasePE编译时期望加载的基址。如果实际分配到的基址和它不同就触发重定位。OptionalHeader.SizeOfImage内存中整个镜像的大小用VirtualAlloc分配时以它为基准。OptionalHeader.SectionAlignment和FileAlignment内存对齐和文件对齐计算节区位置时必需的参数。NumberOfSections和节区表遍历每个节用VirtualAddress和PointerToRawData做映射。OptionalHeader.AddressOfEntryPoint入口点RVA加载完成后需要调用它。DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]重定位表的RVA和大小。DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]导入表位置遍历IAT入口。DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]和DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION]TLS回调和异常处理通常在写防御性代码时要处理。提示这里说的RVARelative Virtual Address是相对ImageBase的偏移它在文件头和节区头里大量出现。在Loader里任何RVA要转成内存地址直接加上实际加载基址就行即addr LoadBase rva。很简单但必须时刻记牢否则后续所有指针操作都会错位。3. 核心加载流程五步走一步都不能省3.1 映射镜像像拼乐高一样把各个节放进内存第一步是分配一块足够大的内存。很多新手会直接用VirtualAlloc(NULL, SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)但这样分配出来的内存权限全是RW后面执行代码时如果遇到只读节就会触发访问违规。更严谨的做法是结合各节的Characteristics字段依次提交内存把节数据拷贝进去后再根据Characteristics设置PAGE_EXECUTE_READ或PAGE_READWRITE等保护属性。这里有一个偷懒但常用的做法先一次性VirtualAlloc整块镜像拷贝完所有节并修复好导入、重定位后再遍历节表对每个节单独调用VirtualProtect设置权限。这样做的好处是流程简单坏处是如果你加载的是被加壳过的PE节区很可能有自定义的混合权限逐个设置反而更可控。拷贝节数据时要注意SizeOfRawData和VirtualSize的差异。文件里一个节的Raw Data可能比Virtual Size短也可能短很多比如BSS段。正确处理方式是如果VirtualSize大于SizeOfRawData拷贝完Raw Data后把剩余部分从目标位置向后的VirtualSize - SizeOfRawData字节清零如果VirtualSize小于等于SizeOfRawData就按SizeOfRawData大小拷贝但最后额外清零的范围要扩展到一个对齐单位避免越界访问时踩到脏数据。3.2 修复基址重定位表的遍历与计算如果VirtualAlloc返回的地址碰巧等于ImageBase那所有绝对地址都不需要改。但这种情况可遇不可求我们几乎每次都会遇到基址偏移所以重定位这一步是手写Loader的必修课。重定位表位于DataDirectory[1].VirtualAddress指向的位置它由一系列IMAGE_BASE_RELOCATION块组成。每个块的结构是VirtualAddress一个页基址RVA通常是0x1000对齐、SizeOfBlock、后面跟着若干个16位TypeOffset。其中TypeOffset的低12位是在该页内的偏移高4位是重定位类型。对x64来说需要的是IMAGE_REL_BASED_DIR64也就是高4位为10对x86来说通常是IMAGE_REL_BASED_HIGHLOW高4位为3。修复方式很简单找到目标地址模块基址 BlockVirtualAddress offset把原本在模块基址 原始ImageBase?的额外逻辑搞清楚——计算差值delta ActualBase - ImageBase然后将该地址处的指针值 delta。这里最容易出错的地方是重定位表里的VirtualAddress是RVA不是内存绝对地址而TypeOffset里的低12位是页内偏移两者相加再加模块基址才是需要patch的真实内存位置。我见过不少人直接拿LoadBase VirtualAddress offset去取地址结果读出来是一个垃圾值导致后续操作崩得莫名其妙。至于不需要重定位的节区可以通过节表Characteristics里的IMAGE_SCN_CNT_CODE和IMAGE_SCN_MEM_EXECUTE判断也可以照着系统加载器的逻辑走只修补数据目录里标记过的位置。但保守派做法是对所有节都尝试遍历偏移计算只要TypeOffset合法照样patch一遍代价只是性能低一点但对于手写Loader来说稳定比性能更重要。3.3 修复导入表拿到API地址填进IAT导入表是整个加载过程里操作指针最多、最容易写错的一环。导入表Descriptor数组以全空结构结尾每一个IMAGE_IMPORT_DESCRIPTOR包含OriginalFirstThunkINT、NameDLL名RVA、FirstThunkIAT。遍历它时我们要做的是根据Name找到依赖的DLL名调用LoadLibraryW或GetModuleHandleW把它加载到当前进程拿到它在本进程的模块基址。通过OriginalFirstThunk或FirstThunk如果前者为0就回退到后者找到INT数组。INT里的每一项要么是指向IMAGE_IMPORT_BY_NAME结构结构前两个字节是Hint后面是函数名要么是一个序号标志最高位为1。对名称导入调用GetProcAddress(module, name)拿到实际地址把它写入FirstThunk指向的IAT数组中对应位置对序号导入则用GetProcAddress(module, (LPCSTR)MAKELONG(ordinal, 0))得到地址。注意INT和IAT在PE文件里通常是两个不同的数组但运行时IAT是要被覆盖成实际地址的。手写Loader必须自己维护一份当前已填充的函数地址表不能直接拿OriginalFirstThunk作为目标存储位置因为那个位置往往位于只读节你写了会崩而且它一般是IMAGE_IMPORT_BY_NAME结构的引用写进去会破坏名称信息。3.4 TLS回调和DllMain从生活不能自理到活过来当重定位和导入表都修好之后这个DLL的内存镜像才真正通了可以当成一个合法模块来使用了。但系统Loader在把控制权交给应用层之前还会做两件手写Loader不能忽略的事调用TLS回调再调用AddressOfEntryPoint。TLS回调位于DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]指向的IMAGE_TLS_DIRECTORY结构里其中有AddressOfCallBacks指向一个以0结尾的函数指针数组。这些回调会在DLL加载前逐个执行很多壳和反调试样本就藏在里面。所以如果只是做内存加载但不执行TLS回调也应该被跳过但如果你想模拟完整加载就必须在调用DllMain之前先跑一遍所有TLS回调。顺序是TLS回调按数组顺序执行然后调用入口点也就是((BOOL(WINAPI*)(HINSTANCE,DWORD,LPVOID))(LoadBase AddressOfEntryPoint))(hModule, DLL_PROCESS_ATTACH, NULL)。这里还要注意一点DLL的AddressOfEntryPoint通常不是DllMain本身而是CRT提供的启动函数比如DllMainCRTStartup它会负责初始化全局对象、调用_DllMainCRTStartup最后才调用开发者写的DllMain。手写Loader不需要关心这一层直接跳过去就好但如果你在DllMain之前就要访问这个DLL的全局变量最好确认CRT初始化已经执行完毕否则某些静态对象可能还是半初始化状态。3.5 延迟导入、异常目录与资源分支要完整加载还得再做几件事如果目标DLL用了延迟导入delay import仅靠上面几步是不够的。延迟导入的数据目录是DataDirectory[IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT]它指向的表格结构类似导入表但每个Descriptor里还记录了pfnIAT、pfnINT和辅助表。要么你在Loader里实现完整的延迟加载逻辑要么干脆在加载该模块前把它的延迟导入表也当普通导入表一并修复这样后面调用真实API时才不会碰到桩函数。实际项目中我见过很多手写Loader没有处理延迟导入结果直到运行期才因为找不到辅助函数而崩溃。另外x64的PE还可能依赖DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION]里的UNWIND_INFO来处理结构化异常。Windows加载器会在加载时注册异常句柄如果你希望手写加载的模块能正确展开栈就必须解析这些.pdata段把异常回调注册到进程的异常处理链中。这一步在日常逆向时往往感觉不到但只要涉及C异常或__try/__except就有可能出现问题。初学者可以先跳过等后续遇到崩溃再回头补。4. 一个精简可用的Loader代码骨架4.1 从零构建先定义内核所需的最小数据结构我不建议在初学阶段一上来就套用全套Windows SDK类型反而建议自己定义几个关键结构这样在调试时可以确保你对每个字段都理解到位。下面这段代码伪代码只演示核心逻辑实际工程里可以用winnt.h的原生定义。typedef struct _MANUAL_MAPPED_MODULE { ULONG_PTR LoadBase; SIZE_T ImageSize; PVOID EntryPoint; // 额外记录导入表地址数组等 } MANUAL_MAPPED_MODULE, *PMANUAL_MAPPED_MODULE;大致流程是用CreateFile、GetFileSize、ReadFile把整个PE文件读入缓冲区。校验e_magic IMAGE_DOS_SIGNATURE通过e_lfanew拿到NT头校验Signature。VirtualAlloc分配SizeOfImage大小的内存提交方式为MEM_COMMIT | MEM_RESERVE。拷贝头部把从文件开头到第一个节区头结束的数据拷贝到LoadBase通常等于整个DOS头NT头节区表的大小系统Loader也会这样做。遍历节表逐个拷贝节的Raw Data并清零未初始化区域。如果实际基址不等于OptionalHeader.ImageBase调用修复重定位的函数。修复导入表加载依赖DLL获取API地址填入IAT。处理TLS回调调用入口点。4.2 关键实现重定位修复函数怎么写我给一个可参考的修复重定位的函数框架注重逻辑而非完整错误处理NTSTATUS FixRelocations(PBYTE LoadBase, PBYTE ImageBase, PIMAGE_NT_HEADERS NtHeaders) { PIMAGE_DATA_DIRECTORY relocDir NtHeaders-OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir-Size 0 || relocDir-VirtualAddress 0) return STATUS_SUCCESS; ULONG_PTR delta (ULONG_PTR)LoadBase - (ULONG_PTR)ImageBase; PIMAGE_BASE_RELOCATION pReloc (PIMAGE_BASE_RELOCATION)(LoadBase relocDir-VirtualAddress); while (pReloc-VirtualAddress ! 0 pReloc-SizeOfBlock ! 0) { DWORD count (pReloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); PWORD entries (PWORD)((PBYTE)pReloc sizeof(IMAGE_BASE_RELOCATION)); for (size_t i 0; i count; i) { WORD type (entries[i] 12) 0xF; WORD offset entries[i] 0xFFF; if (type IMAGE_REL_BASED_DIR64) { ULONG_PTR *p (ULONG_PTR *)(LoadBase pReloc-VirtualAddress offset); *p delta; } else if (type IMAGE_REL_BASED_HIGHLOW) { DWORD *p (DWORD *)(LoadBase pReloc-VirtualAddress offset); *p (DWORD)delta; } // 对x86的绝对地址还需要处理 HIGHADJ 等类型但逆向常见DLL基本只需要前两种 } pReloc (PIMAGE_BASE_RELOCATION)((PBYTE)pReloc pReloc-SizeOfBlock); } return STATUS_SUCCESS; }一个细节是delta很大时HIGHLOW类型会溢出32位所以x64下遇到HIGHLOW要谨慎。实际中64位DLL的绝对地址通常是DIR64类型HIGHLOW多出现在32位模块或一些混合程序里。4.3 导入表修复的细节小心重名与转发导入表修复的核心是嵌套遍历流程不复杂但有两类坑很常见。一是DLL导出转发比如KERNEL32里有些函数转发给ntdllGetProcAddress会自动处理转发但你要是自己解析导出表并直接使用解析到的FAT指针就要注意转发记录的地址值小于模块基址实际是一个字符串形式形如NTDLL.RtlAllocateHeap。手写Loader如果只是调GetProcAddressWindows会偷偷帮你处理转发这没问题。二是IAT里的函数可能被重复加载比如USER32.dll和GDI32.dll互相依赖。如果你的Loader在处理导入表时遇到模块已经加载但还没初始化完成的情况一定要避免递归死循环。稳妥做法是维护一个已加载模块的链表每次加载模块前先检查要找的DLL是否已在列表中若在就直接用现有基址不要再次调用LoadLibraryW。5. 实测中踩过的坑对齐、权限、递归加载5.1 节对齐与文件对齐的换算一个字节差全盘崩第一次跑通手写Loader时我犯过一个特别低级的错误直接把节数据从文件偏移0x400搬到内存偏移LoadBase 0x100结果那个DLL一执行入口就访问违规。问题出在我把PointerToRawData和VirtualAddress搞混了。后来我总结了一条铁律加载PE时几乎所有的文件偏移到内存地址的换算都在节区头里进行而不是用固定的差值。具体换算公式是内存地址 LoadBase VirtualAddress (文件偏移 - PointerToRawData)。如果一段数据位于文件偏移RawPos它对应内存位置就是LoadBase VirtualAddress (RawPos - PointerToRawData)。所以当指针需要指向PE内部数据时永远基于RVA而不是文件偏移。写Loader时我会先把每个节的RawOffset和RVA记录成一张表再写一个RvaToFileOff辅助函数在调试崩溃的时候用这个函数去反推某个内存地址对应的文件位置非常方便。5.2 内存保护属性忘记设置EXECUTE会让人怀疑人生另一个典型案例是代码逻辑全对但一调用入口点就抛ACCESS_VIOLATION而且崩溃地址恰好是入口所在页。原因就是我前面说的整个镜像一次性分配成了PAGE_READWRITE数据节没问题代码节可没执行权限。解决办法也不复杂分配内存时可以先用PAGE_READWRITE防止写入受限等所有patch完成后再用VirtualProtect按节设置权限。这一步务必在调用入口点之前完成否则一旦入口处有硬编码跳转就会因执行权限不足而当场崩溃。如果目标DLL存在写自身代码或资源的节比如做运行时破解的样本还会出现运行后代码节被改写的情形。这时按节的原始Characteristics设置权限是合理的但为了兼容某些加壳样本可以适度放宽为PAGE_EXECUTE_READWRITE——这属于攻防对抗里的另一个话题手写Loader在做防御时不适合过度放宽。5.3 当导入表里出现自己递归加载的终结条件真实系统中经常出现一个DLL依赖另一个DLL那个DLL反手又依赖第一个DLL的循环依赖。系统Loader通过模块引用计数避免了无限递归但手写Loader如果只在解析导入表时干巴巴地循环遇到这种情况就会栈溢出。解决思路有两个其一每次加载新模块前先检查当前已加载模块列表里有没有同名DLL不区分路径大小写如果有就直接复用模块基址不再重复加载。其二在递归解析A的导入表时如果遇到A的IAT指向的函数来自A本身直接使用A的导出表解析并写入这不会引发递归。我实际写的Loader里维护了一个简单链表每个节点保存模块名指针和LoadBase既避免了循环依赖也方便后续调用导出表做动态解析。另外要特别留意的是同一个DLL通过不同路径被重复加载会出现双份模块基址如果Loader不处理去重轻则浪费内存重则导出函数解析到错误模块行为错乱。所以手写Loader的模块链表里匹配时同时检查名称和完整路径至少也要检查名称这样能在大多数场景下避免重复加载。6. 还没完延迟导入、异常注册与权限再优化很多人把手写Loader停在上面的五步但真正要在生产工具里安全使用还要补两块内容延迟导入处理和UNWIND异常信息注册。延迟导入表的结构虽然像导入表但它的OriginalFirstThunk和FirstThunk可能指向同一个表并且所有的桩函数thunk如果未被调用Loader是不需要主动解析的。问题在于如果你加载的是VC编译出来的常规DLL延迟导入的模块名和函数名都没有在普通导入表中列举运行期在真正调用前会跳到引导代码。手写Loader要做得像系统一样就应该在加载阶段直接把延迟导入表里的函数全部解析好避免后续因为引导代码需要加载器支持而失败。异常目录这步更隐蔽。x64下每个函数的栈回溯信息写在.pdata段系统加载器会在LdrpInsertModuleToHandlerQueue里注册LdrpDispatchException。手写Loader不注册的话模块内部使用RaiseException或C异常时会发现展开栈失败最后进程直接终止。要想完整支持需要解析DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION]把每个RUNTIME_FUNCTION插入到进程的异常处理链。这个过程的实现细节比较繁琐但对一个真正能当系统Loader替代品的工具来说绕不开。最后再提一个性能层面的优化手写Loader在映射节区时完全可以把每个节用VirtualProtect做逐页权限设置而不是一刀切成PAGE_EXECUTE_READWRITE。粗放做法可能导致某些安全检测工具报警而精细的权限控制既能模拟系统行为又能降低被扫描的风险。我在自己的工具里会加一个配置项让用户选择兼容模式还是严格模式前者放宽权限提高兼容性后者按节区表精确申请权限方便跑自动化测试。整体来看手写LoadLibrary这个活儿是每一层PE知识的总验收你得看懂节表才能做映射你得理清重定位才能让模块落地你得会遍历IAT才能真正安全地调用函数。整个过程走完一遍再回头看系统的LoadLibraryW你会发现自己能预判它在每个异常路径上的行为了。这也正是我强烈建议每一位做PE开发或逆向的朋友无论如何都要手写一次Loader的原因——踩坑越多对Windows加载机制的理解就越深。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑