ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PE导出表解析实战:IMAGE_EXPORT_DIRECTORY与三数组联动

PE导出表解析实战:IMAGE_EXPORT_DIRECTORY与三数组联动 在Windows平台做逆向分析、开发SDK或者编写插件加载器的人迟早会跟PE文件的导出表打交道。你可以把它理解成一个DLL向外界递出的服务菜单——外面的人想知道这个模块到底能调用哪些函数、这些函数叫什么名字、住在哪个地址全靠这张菜单说清楚。我当年第一次手搓PE解析器的时候导入表看得很快轮到导出表却卡了整整一个下午问题就出在那三个数组的联动关系上文档写得含糊照抄代码又跑不通只能自己拿十六进制编辑器一格一格对。这篇就把IMAGE_EXPORT_DIRECTORY从头到尾拆一遍包括结构体每个字段的意义、三个数组怎么互相索引、RVA怎么换算成文件偏移、转发导出怎么识别以及我踩过的那些坑。不管你是刚接触PE的新手还是写过解析器但总在边界情况上翻车的熟手这篇应该都能给你省点来回试错的时间。1. 为什么导出表是PE文件里最外向的数据结构1.1 导出表在整个PE体系中的位置一个PE文件里塞着好几张表导入表、导出表、重定位表、资源表、异常表、TLS表等等它们统一挂在可选头的数据目录DataDirectory里每一项两个DWORD——一个是RVA一个是大小。导出表固定占数据目录的第0项也就是IMAGE_DIRECTORY_ENTRY_EXPORT。这个第0项的位置其实挺有讲究Windows加载器在解析模块时导出表是它最早需要处理的结构之一因为其他模块可能立刻就要通过GetProcAddress来查找符号所以把它排在第一位加载阶段能少绕几步。导出表本身不是一段连续的结构体数据那么简单它由四个部分拼起来一个IMAGE_EXPORT_DIRECTORY主结构体、一张函数地址数组、一张函数名字符串指针数组、一张序号数组再加上一堆散落的函数名字符串。它们在文件里的位置不固定主结构体在中间某个地方三个数组和字符串可能散落在后面彼此之间用RVA互相指着。这点跟导入表那种结构体连续排布、名字紧跟其后的紧凑风格完全不同也是很多人第一次解析导出表时会懵的原因——你顺着一个指针跳过去可能跳到了节区的另一头。我个人的习惯是把导出表看成一个目录树主结构体是根节点告诉你下面有几张表、每张表在哪三个数组是中间节点字符串是叶子。理解了这个树形结构后面解析起来就不会乱。1.2 什么场景下你必须读懂导出表导出表不是给普通用户看的它的服务对象非常明确。第一类是符号查找——当你调用GetProcAddress时系统内部就是在遍历导出表的名称数组做字符串匹配找到名字后通过序号数组定位到函数地址数组里的实际条目。第二类是SDK开发和插件系统——很多程序把主程序编译成EXE并导出核心接口插件DLL通过导入这些符号来跟宿主交互这种反向依赖的模式在大型软件里很常见。第三类是逆向工程——分析一个DLL时导出表往往是你了解它功能最快的入口函数名本身就是最好的注释。还有一类比较小众但实际很常见的场景脱壳与修复。有些加壳程序会打乱或加密导出表脱壳之后需要重建它。此时你必须精确知道原始导出表的布局规律否则重建出来的表加载时直接崩。我在处理这类问题时最大的体会是导出表的三个数组之间是有冗余校验关系的——名称数组和序号数组长度必须相等序号数组的每个值必须小于函数地址数组的长度。重建时只要对不上这三条约束基本可以判断哪一步还原错了。1.3 导出表与导入表的关系很多人会把导出表和导入表弄混因为名字实在太像。简单说导入表回答的是我需要别人什么导出表回答的是我能给别人什么。一个DLL通常以导出表为主、导入表为辅一个EXE通常以导入表为主导出表要么不存在要么只导出极少量符号比如某些需要被注入的回调接口。但两者在结构上有一个本质区别导入表里存的是函数的名称和对应的导入地址表条目加载器负责往IAT里填真实地址导出表里存的则是函数在本模块内的RVA谁需要谁自己去取。也就是说导出表本身并不发送任何东西它只是安静地躺在那等着别人来查。这个认知很重要它意味着导出表在加载时通常不需要被修改除非涉及转发或者某些特殊处理你在磁盘上看到的导出表结构和内存里加载后的结构是一致的只是RVA前面要加上模块基址才能变成真正的虚拟地址。搞清楚了这三件事——导出表在PE里的定位、它服务谁、它和导入表的区别——再去看结构体定义心里就有底了。下面进入正题。2. IMAGE_EXPORT_DIRECTORY结构体逐字段拆解2.1 结构体定义与每个字段的真实含义先上定义这是Windows SDK里的原始结构typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; DWORD Name; DWORD Base; DWORD NumberOfFunctions; DWORD NumberOfNames; DWORD AddressOfFunctions; DWORD AddressOfNames; DWORD AddressOfNameOrdinals; } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;总共40个字节11个字段。注意对齐——两个WORD字段加起来是4字节所以整体没有额外的填充sizeof正好是0x28。Characteristics在当前Windows实现里保留未使用一般读出来是0。别被它的名字误导它跟节区的Characteristics完全没关系。TimeDateStamp是导出信息的生成时间戳理论上可以用于版本判断但很多编译器根本不更新它我见过的DLL里有八成以上这个字段是0或者一个固定值别指望靠它做版本校验。MajorVersion和MinorVersion同样是历史遗留几乎没人用读出来基本是0。真正有用的是后六个字段字段含义关键点Name模块名字符串的RVA指向类似mylib.dll的字符串用于GetModuleFileName之类的场景Base序号基址序号数组的起始编号通常是1但也可能是0或其他值NumberOfFunctions函数地址数组的元素个数决定了AddressOfFunctions数组的长度NumberOfNames名称数组的元素个数与NumberOfFunctions不一定相等AddressOfFunctions函数地址数组的RVADWORD数组每项是一个函数RVAAddressOfNames名称数组的RVADWORD数组每项是名字符串的RVAAddressOfNameOrdinals序号数组的RVAWORD数组每项是对应名字在地址数组中的下标这里最容易出错的是Base和NumberOfFunctions、NumberOfNames这三个字段的组合。很多教程图省事直接说NumberOfFunctions就是导出函数个数这在大多数情况下对但有例外——如果有函数只按序号导出、没有名字它会计入NumberOfFunctions但不计入NumberOfNames。反之不可能出现只计入名字不计入函数的正常情况。2.2 三个数组的联动关系是理解导出表的核心这是全篇最重要的一节如果你只看懂一段就看这段。三个数组的关系可以用一句话概括名称数组和序号数组一一对应、按名字排序序号数组的值指向地址数组的下标地址数组按序号顺序排列。我拿一个具体例子说明。假设某DLL导出了三个函数Alpha序号1、Beta序号2、Gamma序号3。地址数组按序号排列所以顺序是Alpha、Beta、Gamma。但名称数组是按字母顺序排的恰好也是Alpha、Beta、Gamma看起来一致。现在我改成导出Zeta序号1、Alpha序号2、Mid序号3。地址数组依然是Zeta、Alpha、Mid的顺序按序号但名称数组要按字母排成Alpha、Mid、Zeta。此时序号数组的内容就是[1, 2, 0]——第一个名字Alpha对应地址数组下标1也就是第二个元素Mid对应下标2Zeta对应下标0。为什么要这么设计答案是为了查找效率。GetProcAddress按名字查找时可以在名称数组上做二分查找前提是名字按字典序排好了复杂度从O(n)降到O(log n)。而序号数组和名称数组同步重排保证二分查找定位到的名字能立刻通过序号数组拿到地址数组下标。这也是我最初踩的坑我以为序号数组是按序号排列的结果写出来的解析器把名字和地址对错了位。后来打印出来一看序号数组根本就是乱序的它跟着名称数组一起排序。所以你的解析逻辑应该是遍历名称数组的每一个下标i通过序号数组取ordinals[i]再通过地址数组取functions[ordinals[i]]这时候names[i]和functions[ordinals[i]]才是正确配对。2.3 名称、序号、地址的三种映射路径理解上面那个三数组联动之后导出表的查找其实有三条路径用哪条取决于你手里有什么信息。第一条是按名字查地址这是最常见的情形。步骤是在名称数组里找目标字符串记下下标i读取序号数组中第i项得到索引j读取地址数组中第j项得到函数RVA。GetProcAddress走的就是这条路内核里还会先判断名字是否在数组范围内再做二分。第二条是按序号查地址。如果你只有序号那就先算j 序号 - Base直接去地址数组取第j项即可。注意这里要判断j是否越界越界就得返回失败。序号查找比名字查找快因为不用字符串比较只需一次减法加一次数组访问。第三条是遍历全部导出通常用于枚举或分析。这时候要小心你遍历的应该是地址数组按序号的完整列表而不是名称数组。因为地址数组覆盖了所有导出函数包括那些只按序号导出的无名函数。我见过有人遍历名称数组来统计导出函数总数结果比真实数量少就是因为漏了无名函数。三条路径清楚了实际写代码的时候就不会乱。下面进入实际操作环节。3. 手把手定位与解析导出表3.1 从DOS头一路寻址到导出表要解析导出表先得把PE头部那套寻址走一遍。这个过程有点绕但每一步都有明确的目的读文件开头2字节确认是MZ这是DOS头签名。读偏移0x3C处的4字节这是e_lfanew指向NT头的位置。这个字段存在的意义是让DOS头可以任意扩展PE头不一定紧跟其后。跳到e_lfanew读4字节确认是PE\0\00x00004550。跳过20字节的IMAGE_FILE_HEADER到达可选头。可选头起始处的2字节是Magic0x10B表示32位0x20B表示64位。这个判断很关键因为后面数据目录的偏移不同——32位下数据目录从可选头偏移0x60开始64位下从0x70开始。数据目录的第一项偏移0x60或0x70就是导出表读出它的RVA和Size。这里的Magic判断是我强烈建议新手别偷懒的地方写死0x60去读32位会在一半的现代DLL上翻车因为64位程序越来越多。我自己就干过这事解析器在64位模块上读出来的导出表Size永远是垃圾值。拿到导出表的RVA之后还不能直接读文件。RVA是相对虚拟地址是相对模块基址的偏移文件里存的是文件偏移。这两者之间的换算必须经过节表。3.2 RVA转文件偏移的正确姿势节表紧跟在可选头之后个数由IMAGE_FILE_HEADER.NumberOfSections给出每个节表项40字节。每个节表项里有两个关键字段VirtualAddress节在内存中的起始RVA和PointerToRawData节在文件中的起始偏移。换算公式是如果某节的VirtualAddress rva VirtualAddress VirtualSize那么文件偏移 rva - VirtualAddress PointerToRawData。看起来简单但边界情况能让人抓狂。第一个坑是VirtualSize和SizeOfRawData的区别——前者是节在内存中实际占用的对齐后大小后者是节在文件中占用的对齐后大小两者可以不等。判断RVA落在哪一节时严格来说应该用VirtualSize但很多解析器为了容错用max(VirtualSize, SizeOfRawData)我不能说谁绝对对只能说你得根据目标样本的情况调整。第二个坑是头部区域——如果一个RVA小于第一个节的VirtualAddress它可能落在文件头里比如某些高度优化过的样本把导出表放在头部的空洞里这种情况公式要退化成文件偏移 rva因为头部在内存和文件里偏移一致。我写解析器时习惯先实现一个健壮的rva_to_offset函数把所有导出表的指针都用它来换算这样一旦出错就集中在一个地方方便定位。3.3 完整解析代码示例下面是Python的完整实现用struct模块手工解析不依赖任何第三方库方便你理解每一步import struct def parse_pe(fp): data fp.read() # 1. 校验MZ assert data[:2] bMZ, 不是PE文件 # 2. 取e_lfanew e_lfanew struct.unpack_from(I, data, 0x3C)[0] # 3. 校验PE签名 assert data[e_lfanew:e_lfanew4] bPE\x00\x00, PE签名错误 # 4. 读文件头 num_sections struct.unpack_from(H, data, e_lfanew6)[0] opt_offset e_lfanew 24 # 5. 读Magic区分32/64位 magic struct.unpack_from(H, data, opt_offset)[0] is_64 (magic 0x20B) dd_offset opt_offset (0x70 if is_64 else 0x60) export_rva, export_size struct.unpack_from(II, data, dd_offset) if export_rva 0: return None # 6. 读节表 sec_offset opt_offset struct.unpack_from(H, data, e_lfanew20)[0] sections [] for i in range(num_sections): off sec_offset i*40 va, vsize, rsize, praw struct.unpack_from(IIII, data, off12) sections.append((va, vsize, praw, rsize)) def rva2off(rva): for va, vsize, praw, rsize in sections: if va rva va max(vsize, rsize): return rva - va praw return rva # 头部区域 # 7. 解析导出表 exp_off rva2off(export_rva) (characteristics, timestamp, major, minor, name_rva, base, num_funcs, num_names, addr_funcs_rva, addr_names_rva, addr_ordinals_rva) \ struct.unpack_from(IIHHIIIIIII, data, exp_off) dll_name read_cstr(data, rva2off(name_rva)) funcs_off rva2off(addr_funcs_rva) names_off rva2off(addr_names_rva) ords_off rva2off(addr_ordinals_rva) exports {} for i in range(num_names): name_ptr struct.unpack_from(I, data, names_off i*4)[0] ordinal_idx struct.unpack_from(H, data, ords_off i*2)[0] func_rva struct.unpack_from(I, data, funcs_off ordinal_idx*4)[0] fname read_cstr(data, rva2off(name_ptr)) exports[fname] (base ordinal_idx, func_rva) return dll_name, exports def read_cstr(data, off): end data.index(b\x00, off) return data[off:end].decode(ascii, errorsreplace)这段代码我特意把struct.unpack_from的格式串写成完整形式方便你对照偏移。注意导出表主结构体的格式串是IIHHIIIIIII小端总共40字节。跑通之后你会得到每个导出函数的名字、序号和RVA三件套。3.4 用十六进制编辑器做一次人工验证写代码是一回事能不能用眼睛在原始字节里验证是另一回事。我强烈建议至少手动做一次这样的验证你才会真正相信代码。拿一个体积小、导出少的DLL比如系统目录下的version.dll用十六进制编辑器打开先按上面的寻址路径找到导出表的文件偏移。在那里你会看到连续的40字节前8字节通常是0Characteristics和TimeDateStamp接着4字节版本号通常也是0。从偏移0x0C开始是Name的RVA0x10是Base大概率是10x14是NumberOfFunctions0x18是NumberOfNames。记下这三个值然后跳到AddressOfNames指向的位置你会看到一串4字节的RVA每个都指向一段ASCII字符串。随便挑一个RVA用rva_to_offset换算成文件偏移跳过去应该能看到类似GetFileVersionInfoA这样的函数名。对照一下如果名字和序号数组、地址数组都对得上说明你完全理解了这个结构。这个过程说起来啰嗦但做一次大概十分钟收益是后面所有解析工作都不再靠猜。4. 导出转发与序号导出这两个容易踩坑的点4.1 转发导出Forwarder的识别与解析转发导出是导出表里最容易被忽略、也最容易让解析器崩掉的机制。它的含义是某个导出函数的地址并不指向本模块内的代码而是转发给了另一个模块的另一个函数。比如kernel32.dll里的很多函数其实转发给了kernelbase.dll。怎么识别转发看地址数组里某项的RVA。如果这个RVA落在导出表自身的范围内即export_rva rva export_rva export_size那么它不是代码地址而是一个字符串的RVA字符串形如KERNELBASE.RtlAllocateHeap格式是模块名.函数名。很多解析器崩在这里的原因就是没做这个判断直接把转发字符串当代码地址用要么解析出垃圾要么在后续反汇编时越界。修正方法是在取函数地址时加一步判断def resolve_export(data, func_rva, export_rva, export_size, rva2off): if export_rva func_rva export_rva export_size: # 转发导出 fwd read_cstr(data, rva2off(func_rva)) return (forwarder, fwd) return (normal, func_rva)要注意的是导出的Size字段就是用来界定这个范围的所以数据目录里那个看起来多余的Size在这里派上了用场。我以前觉得导出表Size没用直到处理转发才明白它的意义。另外转发字符串的模块名部分可以带扩展名如api-ms-win-core.dll也可以不带函数名部分可以带#123这种序号形式解析时最好都容错处理。4.2 序号导出与名称导出的取舍前面提过NumberOfFunctions和NumberOfNames可以不等。当前者大于后者时说明有函数只按序号导出、没有名字。这种情况在系统DLL里非常普遍很多内部函数故意只留序号不给名字一方面减小体积另一方面增加一点分析难度。对我这种做分析的人来说无名函数恰恰是最值得关注的因为它们往往是被刻意藏起来的。处理办法是遍历完整的地址数组对每个下标j检查是否存在某个i使得ordinals[i] j。存在则说明有名字从名称数组反查不存在就是纯序号导出序号为Base j。这里有个细节地址数组里可能包含空洞——某些下标对应的RVA是0。这表示那个序号被跳过了历史上可能有过函数后来被移除但序号没回收回收会破坏依赖旧序号的程序。解析时必须跳过RVA为0的项否则你会得到一个地址为0的假导出后续脱引用直接崩。我一般会在解析结果里把这类空洞单独记录一份做DLL版本对比时这些保留的序号位置能帮我判断两个版本之间的演化关系。5. 常见问题与排查技巧实录5.1 导出表解析常见问题速查表下面这张表是我这些年积攒下来的问题清单基本覆盖了90%的翻车场景现象可能原因排查方法解析出来的函数名是乱码把转发字符串当成了名字或者字符串编码不是ASCII检查该RVA是否落在导出表范围内名字和地址对不上忽略了名称数组按字母排序序号数组跟随重排按names[i] - ordinals[i] - functions[]三步取得到的函数地址全是0地址数组RVA换算错或者取错了节打印rva2off的中间结果逐节比对找不到导出表Magic判断错或读错了数据目录偏移64位用0x7032位用0x6064位模块解析全错拿32位的数据目录偏移去读64位先读Magic再决定偏移导出函数数量比实际少只遍历了名称数组漏了纯序号导出改为遍历地址数组某个导出RVA越界漏判了地址数组里的0空洞跳过RVA为0的项GetProcAddress能拿到函数但自己解析拿不到目标函数是转发导出按转发逻辑再解析一层5.2 亲手踩过的几个坑和独家心得第一个坑也是我印象最深的早年我写过一个批量导出枚举工具跑了几百个系统DLL都没问题直到遇到ntdll.dll。它里面有大量转发导出工具直接在那一堆转发字符串上崩了。那次之后我才养成了先判断RVA范围的习惯。这个教训让我明白导出表的地址字段其实是个联合体式的语义字段——它既可能真是地址也可能是指向字符串的指针含义由它所在的数值范围决定。这种设计在PE里不止一处处理时不能想当然。第二个坑跟序号基址Base有关。我写工具时想当然地把Base当1结果遇到某个第三方DLL的Base是0导致序号全体偏移一位导出的函数序号全错。后来我固定从结构体里读Base再也没出过这类问题。Base可以是非1的值这一点必须用代码而不是记忆来保证。第三个心得是关于名字数组的排序假设。规范要求名字按字典序排序以便二分查找但现实中确实存在不排序的DLL——尤其是手工构造或者被加壳修改过的样本。如果你直接用二分查找遇到无序数组会返回错误结果但如果用线性查找性能又差。我的做法是先做一次快速的顺序校验扫一遍确认是递增的是就用二分不是就退化成线性并记一条警告。这样既快又稳。第四个心得是大端机器的兼容。PE是小端格式绝大多数场景没歧义但如果你在做跨平台的分析工具读结构体时始终显式指定小端别依赖宿主机字节序。我在用Python的struct时一开始没写在某些环境下就出了问题后来所有格式串都强制加。第五个心得关于解析结果的验证。不要只信任自己算出来的RVA。一个很好的交叉验证方式是解析完导出表后把每个函数的RVA换算成文件偏移然后看该偏移处的字节是不是像一段正常函数的开头比如常见的前几个字节55 8B EC、48 89 5C 24之类。如果大量函数都不像正常代码开头八成是你的换算链路有问题。这个抽样看字节的技巧帮我定位过至少三次节表相关的bug。第六个心得关于导出表和重定位表的关系。有些人以为导出表里的地址会随重定位改变其实不会——导出表存的是RVA是相对地址跟重定位无关无论模块加载到哪个基址导出表里的RVA都是稳定的。真正需要重定位修正的是那些填了绝对地址的地方比如某些数据结构里的指针。搞清楚这点你在处理基址变化时就不会误改导出表。最后一个技巧如果你想写一个真正健壮的导出表解析器建议把三个数组的一致性校验加进去NumberOfNames应该等于名称数组能读到的有效项数每个序号数组元素应小于NumberOfFunctions名称数组里的每个RVA都应能成功换算到文件偏移。这三条只要有一条不满足就直接判定样本异常输出警告而不是硬解。我后来给工具加上这套校验之后遇到畸形PE再也不会输出莫名其妙的垃圾结果而是明确告诉我这个样本的导出表被破坏了。
返回列表