1. 导出表到底在PE里承担什么角色:从"谁调用了谁"说起
刚开始接触PE格式的时候,我把导出表当成一张"函数名单"就完事了——DLL里能被别人调用的函数,名字全在这张表里。真到动手写解析器、或者拿OD、IDA去对符号的时候才发现,这个理解漏掉了太多东西。导出表不只是"名单",它同时承担了三件事:给出函数名字、给出函数在内存里的RVA、给出函数的序号。这三样东西由结构体头部加三张并行数组配合完成,缺一张都对不上号。
举个很常见的场景。你手上有一个第三方DLL,没符号、没PDB,只有一个二进制文件。你想知道它对外暴露了哪些接口,你想把GetProcAddress的参数对上号,你想确认某个版本的DLL是不是被人替换过——这三个需求全部要落到导出表上。而导出表偏偏是PE里结构最"绕"的一张表:它不是一坨连续的数据,而是头部 + 三张独立数组,彼此之间靠RVA互相指来指去。
再换个角度。调用方(EXE或者其他DLL)在导入表里写的那个函数名,最终是怎么落到目标DLL里某个具体函数上的?答案就是加载器去读被调用方的导出表,按名字在导出名字表里做查找,拿到索引,再去地址表里取RVA,最后加上模块基址得到真实地址。也就是说,导出表是DYNAMIC LINKING在文件层面的唯一落地点。理解了导出表,你才算真正看懂了LoadLibrary+GetProcAddress这套机制底下发生了什么。
这篇东西写给两类人。一类是刚开始啃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项的索引是硬编码约定的:
| 索引 | 常量名 | 指向的结构 |
|---|---|---|
| 0 | IMAGE_DIRECTORY_ENTRY_EXPORT | IMAGE_EXPORT_DIRECTORY |
| 1 | IMAGE_DIRECTORY_ENTRY_IMPORT | IMAGE_IMPORT_DESCRIPTOR 数组 |
| 2 | IMAGE_DIRECTORY_ENTRY_RESOURCE | 资源目录树 |
| 3 | IMAGE_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字节(0x28):
typedef 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编译的DLL,Base都等于1。但确实存在Base = 0的(一些驱动和自制样本),也见过Base是几百甚至几万的(某些商业SDK为了防止别人按序号硬编码调用,故意把基数调高)。
计算规则只有一个公式:序号 = Base + EAT索引。注意EAT索引是从0开始的数组下标,别和序号混了。我看到过不少人写解析器时直接把数组下标当序号输出,结果一个Base = 1的DLL导出的序号全都差了1。
4. 三张并行数组的联动:EAT、名字表、序号表的配合机制
4.1 AddressOfFunctions:真正存函数地址的地方
AddressOfFunctions指向的这张表叫EAT(Export 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") | 名字表二分 → 序号表 → EAT | O(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] != b'MZ': 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] != b'PE\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_from,RVA为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也有VA,IDA里显示的是VA(因为已经映射过)。写脚本的时候从不同工具抓数据,很容易混。记住一条铁律:文件里的一切都是RVA,只有加载器加了ImageBase之后才是VA。导出表里没有例外,EAT里存的也是RVA。
我在做两个DLL的导出对比时,一边用脚本读RVA,一边手工从IDA抄VA,比了半天全是差异。后来统一转成RVA再比,差异瞬间消失。
7.5 越界读取:畸形文件与不信任输入
做安全分析时输入文件不可信,所有偏移都要做边界检查。至少要检查这几处:
| 检查点 | 危险后果 | 建议做法 |
|---|---|---|
导出目录off + 40 <= len(buf) | 读到文件外 | unpack前先判断 |
EAT长度num_funcs * 4 | 整数溢出 / 巨量分配 | 上限设成合理值,比如65536 |
| 名字表RVA为0 | unpack_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翻译出了问题。我现在的解析代码全部是后者,稳定运行了很长时间,没再因为文件结构奇葩翻过车。