ARTICLE DETAIL

资讯详情

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

PE导出表解析:结构、RVA转换与转发导出实战

PE导出表解析:结构、RVA转换与转发导出实战 1. 导出表到底在PE里承担什么角色从谁调用了谁说起刚开始接触PE格式的时候我把导出表当成一张函数名单就完事了——DLL里能被别人调用的函数名字全在这张表里。真到动手写解析器、或者拿OD、IDA去对符号的时候才发现这个理解漏掉了太多东西。导出表不只是名单它同时承担了三件事给出函数名字、给出函数在内存里的RVA、给出函数的序号。这三样东西由结构体头部加三张并行数组配合完成缺一张都对不上号。举个很常见的场景。你手上有一个第三方DLL没符号、没PDB只有一个二进制文件。你想知道它对外暴露了哪些接口你想把GetProcAddress的参数对上号你想确认某个版本的DLL是不是被人替换过——这三个需求全部要落到导出表上。而导出表偏偏是PE里结构最绕的一张表它不是一坨连续的数据而是头部 三张独立数组彼此之间靠RVA互相指来指去。再换个角度。调用方EXE或者其他DLL在导入表里写的那个函数名最终是怎么落到目标DLL里某个具体函数上的答案就是加载器去读被调用方的导出表按名字在导出名字表里做查找拿到索引再去地址表里取RVA最后加上模块基址得到真实地址。也就是说导出表是DYNAMIC LINKING在文件层面的唯一落地点。理解了导出表你才算真正看懂了LoadLibraryGetProcAddress这套机制底下发生了什么。这篇东西写给两类人。一类是刚开始啃PE格式、能看懂DOS头NT头但一看到DataDirectory[0]就卡住的朋友另一类是已经能跑通解析、但遇到转发导出或者只导序号的DLL就翻车的朋友。我会把结构体逐字段拆开讲、把三张表的联动关系画清楚、再手写一个不依赖第三方库的Python解析器跑通中间把踩过的坑全部倒出来。2. 定位导出表数据目录第0项与RVA到文件偏移的转换2.1 为什么导出表一定是DataDirectory[0]PE可选头IMAGE_OPTIONAL_HEADER的末尾跟着一个固定16项的数组每一项是IMAGE_DATA_DIRECTORY结构8字节形状是{ DWORD VirtualAddress; DWORD Size; }。这16项的索引是硬编码约定的索引常量名指向的结构0IMAGE_DIRECTORY_ENTRY_EXPORTIMAGE_EXPORT_DIRECTORY1IMAGE_DIRECTORY_ENTRY_IMPORTIMAGE_IMPORT_DESCRIPTOR 数组2IMAGE_DIRECTORY_ENTRY_RESOURCE资源目录树3IMAGE_DIRECTORY_ENTRY_EXCEPTION异常处理表.........第0项就是导出表这不是随便排的而是和历史沿革有关——最早的PE设计里导出是DLL最核心的能力所以放在最前面。解析的时候只需要取DataDirectory[0].VirtualAddress如果这个值是0说明这个模块没有导出任何东西大部分EXE就是这种情况。这里有个细节值得单独拎出来说导出表这一项的Size字段不是导出目录结构体的大小固定40字节而是整个导出数据块的地址区间大小。官方文档写得含糊实际含义是导出目录 三张数组 所有字符串合起来在内存中占据的范围。为什么要记这个区间因为后面判断转发导出的时候全靠这个区间来划边界。这是很多人第一次写解析器时会弄错的地方。2.2 手写RVA转文件偏移的每一步数据目录里存的VirtualAddress是RVA相对虚拟地址不是文件偏移。要真正读到字节必须先把RVA翻译成文件偏移FOA。翻译逻辑说穿了就一句话遍历节表看这个RVA落在哪个节的[VirtualAddress, VirtualAddress VirtualSize)区间内然后FOA PointerToRawData (RVA - VirtualAddress)。但这里有几个坑要提前打预防针。第一个坑如果RVA小于SizeOfHeaders说明它落在PE头区域此时FOA就等于RVA本身不需要查节表。有些加壳文件或者手工构造的样本会把导出目录塞在节对齐的空隙里这种时候不查头区就会返回None。第二个坑区间长度用VirtualSize还是SizeOfRawData理论上应该用max(VirtualSize, SizeOfRawData)。因为VirtualSize是内存中的实际大小可能大于文件中的SizeOfRawData尾部是零填充反过来某些链接器会把VirtualSize算得比SizeOfRawData小。两个都考虑能覆盖绝大多数情况。第三个坑节表遍历时用的是NumberOfSections而这个值来自COFF文件头是WORD类型最大值65535。如果文件本身被破坏这个值可能是个垃圾数遍历时要注意加上边界检查别把buf读越界了。2.3 导出表跨越两个节的情况正常情况下整个导出数据块都待在.edata或者.rdata里。但我在实际样本里遇到过导出目录头部在.rdata尾部、数组跑到下一个节的情况——通常是链接脚本被改过或者被工具二次处理过。这种时候如果你的解析器是一次性unpack_from把40字节头读完再按头里的RVA去读数组是不会有问题的因为每次读取都单独做一次RVA转换。真正会出事的是那种先算出导出表FOA然后所有后续访问都基于这个FOA加固定偏移的写法。所有偏移都要从RVA重新翻译别从算出来的FOA往下推这是我踩过最疼的一次坑。3. IMAGE_EXPORT_DIRECTORY 结构体逐字段拆解3.1 40个字节里到底装了什么结构体定义来自winnt.h一共40字节0x28typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; // 0x00 保留通常为0 DWORD TimeDateStamp; // 0x04 导出表生成时间戳 WORD MajorVersion; // 0x08 主版本通常为0 WORD MinorVersion; // 0x0A 次版本通常为0 DWORD Name; // 0x0C RVA - 模块名字符串 DWORD Base; // 0x10 序号起始值 DWORD NumberOfFunctions; // 0x14 EAT条目数 DWORD NumberOfNames; // 0x18 名字表条目数 DWORD AddressOfFunctions; // 0x1C RVA - 地址表 DWORD AddressOfNames; // 0x20 RVA - 名字指针表 DWORD AddressOfNameOrdinals; // 0x24 RVA - 序号表 } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;把字段按用途分组其实只有三类一类是元信息Characteristics、TimeDateStamp、两个Version一类是定位信息Name、三张表的RVA一类是规模信息Base、NumberOfFunctions、NumberOfNames。真正干活的是后两类。Characteristics字段微软文档明确写了保留必须为0我核过几十个系统DLL确实全是0可以直接忽略。TimeDateStamp在开启了/Brepro之后会变成一个哈希值而不是真实时间做版本比对的时候别拿它当准确依据。3.2 Name字段模块名的藏身之处Name是一个RVA指向一段以\0结尾的ASCII字符串内容就是模块本身的名字比如KERNEL32.dll。注意它指的不是被导出函数的名字而是这个DLL自己的名字。有意思的是这个名字和磁盘上的文件名不要求一致。你可以把a.dll改名成b.dll只要文件内容不动导出表里的Name字段依然指向a.dll。加载器做API Set解析、或者是做模块名匹配的时候有时候是按文件路径名走的有时候是按导出名走的两者不一致就可能出问题。这也是为什么某些改名版的DLL会在特定软件里加载失败。另外Name字段指向的字符串位置没有强制规定必须在导出数据块内部但绝大多数编译器都会把它放在导出目录紧后面。少部分加壳物会把它挪到别的节里去解析时按RVA老老实实转换就行。3.3 Base字段序号导出的起点Base是序号基数。如果Base 1那么地址表里第0项对应的序号就是1如果Base 100那么第0项对应的序号就是100。绝大多数用MSVC编译的DLLBase都等于1。但确实存在Base 0的一些驱动和自制样本也见过Base是几百甚至几万的某些商业SDK为了防止别人按序号硬编码调用故意把基数调高。计算规则只有一个公式序号 Base EAT索引。注意EAT索引是从0开始的数组下标别和序号混了。我看到过不少人写解析器时直接把数组下标当序号输出结果一个Base 1的DLL导出的序号全都差了1。4. 三张并行数组的联动EAT、名字表、序号表的配合机制4.1 AddressOfFunctions真正存函数地址的地方AddressOfFunctions指向的这张表叫EATExport Address Table是一串DWORD共NumberOfFunctions项。每一项要么是一个函数的RVA要么是0表示这个槽位未被使用要么是一个转发字符串的RVA下一节详说。关键点EAT的索引是序号槽位不是名字索引。EAT里的项和函数名没有直接对应关系名字的对应关系要靠另外两张表来牵线。EAT里出现0是合法的这意味着这个序号被保留了但没有实际函数。比如某个DLL以前导出过100个函数后来删掉了第50个但没重新编译序号那么EAT第49项可能就是0。所有遍历EAT的代码都必须判0跳过不然后面拿RVA去翻译会直接崩。4.2 AddressOfNames 与 AddressOfNameOrdinals名字到索引的双跳映射AddressOfNames指向一张DWORD数组共NumberOfNames项每一项是一个RVA指向一个以\0结尾的函数名字符串。AddressOfNameOrdinals指向一张WORD数组长度也是NumberOfNames项。第i项的值是一个EAT索引。这两张表必须成对使用。给定名字表的第i项查名字字符串是NameTable[i]指向的内容对应的EAT索引是OrdinalTable[i]对应的函数RVA是EAT[OrdinalTable[i]]对应的序号是Base OrdinalTable[i]。用一段伪代码表达最清楚for (DWORD i 0; i NumberOfNames; i) { const char *funcName RvaToPtr(NameTable[i]); WORD eatIndex OrdinalTable[i]; DWORD funcRva EAT[eatIndex]; DWORD ordinal Base eatIndex; printf(%s %u RVA%08X\n, funcName, ordinal, funcRva); }为什么非要这两张表因为EAT的索引是按序号槽位排的而名字表是按字母序排的。如果直接在EAT里塞名字就没法做二分查找了。分成三张表之后名字表保持有序至少MSVC和大部分链接器会保证加载器就能对名字做二分查找把GetProcAddress的平均查找复杂度压到O(log n)。这是典型的用空间换时间设计。4.3 按名字查找与按序号查找的两条路径查找方式走的路径复杂度典型场景按名字GetProcAddress(h, FuncA)名字表二分 → 序号表 → EATO(log n)常规API调用按序号GetProcAddress(h, MAKEINTRESOURCE(5))直接EAT[5 - Base]O(1)名字被剥离或刻意隐藏时按序号查找省掉了两次表跳转更快而且不依赖名字字符串是否存在。这也是为什么一些只导出序号的DLL比如某些系统DLL的老版本只能靠序号调用。做逆向的时候如果发现导入表里全是Ordinal而没有Name那基本可以确定目标DLL做了名字剥离。5. 从零写一个导出表解析器5.1 骨架设计先跑通RVA翻译再谈解析我不建议一上来就写解析逻辑先把RVA转FOA这段单独封装好用几个已知RVA验证一下。验证方法很简单拿DataDirectory[1]导入表的RVA试一下转出来的偏移处应该能看到一串IMAGE_IMPORT_DESCRIPTOR。或者直接拿导出目录的RVA转转出来的40字节里Name字段转出来的字符串应该是个DLL名。整个解析器不用任何第三方库struct加open就够了。下面这段是我平时调试用的版本加了边界检查能直接跑。5.2 完整可运行代码import struct import sys class PEFile: def __init__(self, path): with open(path, rb) as f: self.buf f.read() if self.buf[:2] ! bMZ: raise ValueError(not a PE file) self.nt_off struct.unpack_from(I, self.buf, 0x3C)[0] if self.buf[self.nt_off:self.nt_off 4] ! bPE\x00\x00: raise ValueError(invalid PE signature) coff self.nt_off 4 (self.machine, self.nsec, _ts, _psym, _nsym, self.opt_size, self.characteristics) struct.unpack_from(HHIIIHH, self.buf, coff) self.opt_off coff 20 magic struct.unpack_from(H, self.buf, self.opt_off)[0] self.is64 (magic 0x20B) # NumberOfRvaAndSizes 和 DataDirectory 的偏移按位数区分 if self.is64: num_rva_off self.opt_off 108 dd_off self.opt_off 112 else: num_rva_off self.opt_off 92 dd_off self.opt_off 96 self.size_of_headers struct.unpack_from(I, self.buf, self.opt_off 60)[0] num_rva struct.unpack_from(I, self.buf, num_rva_off)[0] self.data_dirs [] for i in range(min(num_rva, 16)): rva, size struct.unpack_from(II, self.buf, dd_off i * 8) self.data_dirs.append((rva, size)) # 节表 sec_off self.opt_off self.opt_size self.sections [] for i in range(self.nsec): base sec_off i * 40 if base 40 len(self.buf): break name self.buf[base:base 8].rstrip(b\x00).decode(ascii, replace) vsize, vaddr, rawsize, rawptr struct.unpack_from(IIII, self.buf, base 8) self.sections.append((name, vaddr, vsize, rawsize, rawptr)) def rva_to_off(self, rva): if rva 0: return None if rva self.size_of_headers: return rva for _n, vaddr, vsize, rawsize, rawptr in self.sections: span max(vsize, rawsize) if vaddr rva vaddr span: return rawptr (rva - vaddr) return None def cstr(self, rva): off self.rva_to_off(rva) if off is None or off len(self.buf): return None end self.buf.find(b\x00, off) if end 0: return None return self.buf[off:end].decode(ascii, replace) def parse_exports(self): dd_rva, dd_size self.data_dirs[0] if dd_rva 0: return None, [] off self.rva_to_off(dd_rva) if off is None or off 40 len(self.buf): raise ValueError(export directory out of range) (chars, ts, maj, minr, name_rva, base, num_funcs, num_names, eat_rva, names_rva, ords_rva) struct.unpack_from(IIHHIIIIIII, self.buf, off) eat_off self.rva_to_off(eat_rva) names_off self.rva_to_off(names_rva) ords_off self.rva_to_off(ords_rva) eat list(struct.unpack_from(%dI % num_funcs, self.buf, eat_off)) if eat_off else [] names list(struct.unpack_from(%dI % num_names, self.buf, names_off)) if names_off else [] ords list(struct.unpack_from(%dH % num_names, self.buf, ords_off)) if ords_off else [] # 名字 - EAT索引 的映射 by_index {} for i, n_rva in enumerate(names): by_index[ords[i]] self.cstr(n_rva) results [] for idx, func_rva in enumerate(eat): ordinal base idx fname by_index.get(idx) entry { ordinal: ordinal, rva: func_rva, name: fname, is_forwarder: False, forwarder: None, } # 转发导出判定RVA落在导出目录区间内 if func_rva and dd_rva func_rva dd_rva dd_size: entry[is_forwarder] True entry[forwarder] self.cstr(func_rva) results.append(entry) return { name: self.cstr(name_rva), base: base, timestamp: ts, num_funcs: num_funcs, num_names: num_names, dd_rva: dd_rva, dd_size: dd_size, }, results if __name__ __main__: pe PEFile(sys.argv[1]) info, entries pe.parse_exports() if info is None: print(no export table) sys.exit(0) print(module :, info[name]) print(base :, info[base]) print(funcs :, info[num_funcs], / names:, info[num_names]) print(dd range: %08X ~ %08X % (info[dd_rva], info[dd_rva] info[dd_size])) print(- * 72) for e in entries: tag FWD if e[is_forwarder] else name e[name] or extra (- e[forwarder]) if e[is_forwarder] else print(%s %-5d %08X %-40s %s % (tag, e[ordinal], e[rva], name, extra))5.3 实测输出与对照拿C:\Windows\System32\version.dll跑一下输出大致是这样module : version.dll base : 1 funcs : 26 / names: 26 dd range: 0001D000 ~ 0001D2B4 ------------------------------------------------------------------------ 1 00002A10 GetFileVersionInfoA 2 00002A20 GetFileVersionInfoByHandle ...对照dumpbin /exports version.dll的输出序号、名字、位置全部能对上。如果对不上大概率是三个地方出问题RVA转偏移的节选择错了、Base没加上、或者名字表和序号表长度取反了。这里特别提醒一个测试技巧先拿NumberOfFunctions NumberOfNames的DLL测再拿两者不等的DLL测。相等的场景通常是每个导出都有名字不容易暴露索引错位问题不等的时候才会暴露用名字表下标去索引EAT这种错误。6. 转发导出与序号导出导出表里最容易被忽略的两种形态6.1 转发导出的判定条件转发导出的本质是这个导出项的RVA不指向代码而是指向一个ASCII字符串内容是目标DLL.目标函数或者目标DLL.#序号。判定条件是死的如果EAT里的RVA落在导出目录区间[DataDirectory[0].VirtualAddress, DataDirectory[0].VirtualAddress Size)之内那它就是转发字符串。这个判断之所以能成立是因为正常情况下函数代码不可能被放在导出目录数据块里所以这个区间就成了转发字符串的专属地带。转发导出最典型的例子是kernel32.dll里一大票函数转发到kernelbase.dll。用上面那段代码跑kernel32.dll会看到一堆标着FWD的项比如FWD ... 0001A2C0 CreateFileW - KERNELBASE.CreateFileW FWD ... 0001A2D0 GetLastError - KERNELBASE.GetLastError注意转发字符串里用的是另一个模块的导出名加载器会解析这个字符串、加载目标模块、再定位到目标函数。这也是为什么做API Hook的时候如果只Hookkernel32!CreateFileW而不处理转发可能整个Hook失效——真实实现根本不在kernel32里。6.2 转发链与解析顺序转发目标本身也可能是转发的。理论上可以形成一条链但实际上Windows对转发链的深度是有限制的我见过的最长也就两跳。解析时如果要做完整展开建议加一个深度上限比如8层和已访问集合防止构造出来的畸形文件把你带进死循环。另外转发字符串里如果出现#后面跟的是十进制序号而不是名字。比如NTDLL.#1234。处理的时候要判断有没有这个#有就按序号去目标模块的EAT里取没有就按名字查。这个分支漏了的话遇到按序号转发的项会直接解析失败。6.3 只导出序号的DLL长什么样有些DLL的NumberOfNames是0但NumberOfFunctions大于0。这种情况意味着这个DLL导出了函数但一个名字都没留只能靠序号调用。我第一次遇到这种文件时以为解析器写错了反复检查三张表的RVA才发现AddressOfNames和AddressOfNameOrdinals都是0。这种情况在系统DLL里不多但在一些安全软件、驱动辅助模块里很常见目的就是增加逆向和硬编码调用的难度。处理方式也很直接NumberOfNames 0时跳过名字遍历只按EAT索引输出序号 RVA的对应关系名字列留空即可。千万别在代码里对names_rva做unpack_fromRVA为0时rva_to_off返回None没防住就会抛异常。7. 解析导出表时最容易踩的五个坑7.1 名字表未必是有序的我前面说名字表通常是有序的这个通常背后有真实反例。MSVC和大部分链接器确实会按字母序排列名字表所以加载器能二分查找。但是用.def文件手工指定导出顺序、或者用某些非标准链接器包括一些加壳工具生成的DLL名字表可能是乱序的。这个坑的杀伤力在于如果你为了性能直接写了二分查找遇到乱序表就会找不到明明存在的函数。所以做分析工具时老老实实线性扫描更稳妥只有在确定文件来源可靠时再考虑二分。7.2 EAT里的空洞与全零项EAT里允许有0含义是该序号槽位空置。但还有一种情况更阴险NumberOfFunctions比实际有效项数大得多尾部一大片都是0。这可能是因为链接器保留了历史序号空间。遍历时判0跳过是基本操作。但要注意在统计导出函数总数时不能直接拿NumberOfFunctions当答案否则会明显偏大。正确的统计方式是数非0项而且要单独区分非0且是转发和非0且是代码。7.3 32位和64位下结构体完全一样IMAGE_EXPORT_DIRECTORY在PE32和PE32里大小都是40字节字段宽度完全一致。因为表里存的全是RVA而RVA永远是32位。这一点让很多人困惑64位程序地址不是64位吗 是但文件内部所有跨结构的引用都是RVA加载时才加64位的ImageBase。所以解析64位PE时唯一要改的是可选头里NumberOfRvaAndSizes和DataDirectory的偏移差20字节导出表本身的解析逻辑一行都不用动。我第一次处理64位文件时把EAT项当成8字节读结果整张表全错位查了半天才发现是自己想多了。7.4 把VA当RVA用反过来也一样dumpbin输出里既有RVA也有VAIDA里显示的是VA因为已经映射过。写脚本的时候从不同工具抓数据很容易混。记住一条铁律文件里的一切都是RVA只有加载器加了ImageBase之后才是VA。导出表里没有例外EAT里存的也是RVA。我在做两个DLL的导出对比时一边用脚本读RVA一边手工从IDA抄VA比了半天全是差异。后来统一转成RVA再比差异瞬间消失。7.5 越界读取畸形文件与不信任输入做安全分析时输入文件不可信所有偏移都要做边界检查。至少要检查这几处检查点危险后果建议做法导出目录off 40 len(buf)读到文件外unpack前先判断EAT长度num_funcs * 4整数溢出 / 巨量分配上限设成合理值比如65536名字表RVA为0unpack_from抛异常提前返回None字符串查找无\0无限跑到文件末尾find返回-1时截断尤其是num_funcs如果它是个接近0xFFFFFFFF的值struct.unpack_from(%dI)会尝试分配巨大内存然后挂掉。加个if num_funcs 0x10000: return能挡掉绝大部分畸形样本。这种防御性写法在做网络样本批量分析时是刚需我踩过因为一个坏样本整个批处理卡死的坑。8. 把导出表用起来的三个真实场景8.1 静态提取导出符号给逆向工程做标注分析一个无符号DLL时第一件事就是把导出表里的名字和RVA导出来生成一份.map或者IDA可导入的符号文件。格式很简单起始地址 名称用脚本把RVA加上ImageBase转成VA再输出即可。这一步做完IDA里函数窗口就会从sub_180001000一片变成CreateFileW_impl这种可读名字。对于体积大的DLL这一步能省掉大量手工命名时间。我的习惯是顺手把序号也带上因为有些调用点是按序号走的光有名字对不上。8.2 检测DLL是否被替换或篡改导出表是判断DLL身份的一个廉价指纹。做法是把目标DLL的导出名列表、序号、对应RVA整理成一个有序列表和已知干净的副本做哈希比对。因为函数的RVA会随着编译变化用RVA做指纹不稳定更稳的做法是只比对名字 序号的集合。这个方法对同名不同内容的DLL特别有效。曾经遇到过一个问题现场某个系统DLL被替换成了一个功能相似但内部实现不同的版本文件名、版本号都伪装得挺好但导出函数的序号分布对不上——原版某个函数在序号120替换版跑到了135。这种偏差用导出表比对一眼就能看出来。8.3 理解延迟加载和 GetProcAddress 的底层路径延迟加载Delay Load在文件层面依赖的是DataDirectory[13]IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT但它在运行时最终还是要走GetProcAddress而GetProcAddress干的事就是遍历目标模块的导出表。所以如果你写过自定义的延迟加载桩或者想在运行时拦截某个API的绑定过程理解导出表的三张表结构是绕不过去的。我做过一个需求需要在特定条件下把某个导出函数的绑定重定向到自己的实现核心就是在模块加载后、首次调用前改掉目标模块EAT里那一项存的RVA。这里的操作必须精确到哪个EAT索引而这个索引只能从名字表序号表反查出来——正好是本文讲的那套联动逻辑。最后再说一个实际写工具时的心得很多人喜欢先把导出表整体拷进内存结构体再处理我推荐反过来全部按需读取 每次重新做RVA转换。前者看着快但遇到跨节、畸形文件、转发字符串时到处是特判后者虽然多几次循环但逻辑干净出错时也容易定位是哪一步的RVA翻译出了问题。我现在的解析代码全部是后者稳定运行了很长时间没再因为文件结构奇葩翻过车。
返回列表