前阵子帮朋友排查一个DLL劫持的问题,对方把一个系统DLL替换掉之后程序照常启动,但某个功能就是静默失效。我用工具打开那个替换文件一看,导出表里空空如也——函数一个没导,加载器自然拿不到地址,结果就是一路空指针走到黑。这件事让我意识到,很多人写PE解析器时,导入表研究得挺细,导出表却总是糊里糊涂地带过去。其实导出表(IMAGE_EXPORT_DIRECTORY)是PE里结构最精巧的一张小表,它用三张互相索引的数组,把"名字—序号—地址"这三件事拧成了一个可二分查找的映射关系。搞懂它,不光是能写出一个DLL导出查看器,更是理解Windows加载器怎么把GetProcAddress跑通的必经之路。这篇就把我这些年踩过的坑、写过的解析代码、以及几个容易被忽略的边界情况,一次性摊开讲清楚,适合做过基础PE解析、想往DLL分析方向深入的读者。
1. 导出表到底解决了什么问题
1.1 从一个朴素需求说起:别人怎么找到我的函数
DLL的本质是一段可以被多方复用的代码,既然是复用,就必须回答一个问题:调用方怎么知道我要暴露哪些函数,又怎么把这些函数和具体地址对上号。最粗暴的办法是约定一个固定地址,谁用谁跳过去,但这等于把链接时的地址写死,重定位、版本更新全完蛋。第二种办法是导出一张"名字到地址"的名单,调用方拿着名字来查,这就是导出表存在的根本理由。
Windows加载器在加载DLL时并不会主动去读导出表,它只是把整个映像映射进内存;真正去解析导出表的,是GetProcAddress或者你写的加载器。也就是说,导出表是一份面向外部查询者的服务目录,只有在有人按名字或序号来问的时候才会被翻开。理解这一点很关键:它决定了导出表里的数据必须足够自描述,哪怕调用方对模块内部一无所知,也能仅凭这份目录把地址算出来。
顺带说一句,导出表不是DLL的专利,EXE同样可以拥有导出表,只不过绝大多数EXE的导出目录是空的(数据目录里RVA和Size都是0)。判断一个模块能不能被当DLL用,看它有没有非空的导出目录是最直接的办法之一。
1.2 数据目录的第0项:唯一入口
PE可选头里有一个数组叫数据目录(Data Directory),里面按固定顺序排列了16个条目,第0项就是导出表。它的两个DWORD分别是导出目录的RVA和Size:
// 数据目录在可选头中的起始偏移 // PE32: 可选头偏移 + 96 // PE32+: 可选头偏移 + 112 typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; // 导出目录的 RVA DWORD Size; // 整个导出信息的字节数 } IMAGE_DATA_DIRECTORY;这里的Size字段值得单独拎出来讲。它描述的是从导出目录起点开始,一直到所有导出名字字符串结束为止的连续范围。很多新手以为Size就是sizeof(IMAGE_EXPORT_DIRECTORY)(40字节),那是错的。Size覆盖的是整个"导出数据块":目录头本身、EAT数组、ENT数组、序号数组,以及所有函数名字符串都算在里面。这个范围在后面判断转发导出时是决定性的依据,先记住它。
之所以用RVA而不是文件偏移,是因为PE的设计哲学是"运行时的地址描述",同一份文件可能被映射到任意基址。解析的时候你需要自己写一个RVA到文件偏移的转换函数,这个转换依赖节表,是后面所有解析工作的地基。
提示:数据目录的项数与可选头Magic有关,PE32有16项,PE32+同样16项,顺序完全一致,只有可选头本身的长度不同。写解析器时不要假设可选头长度固定,一定从SizeOfOptionalHeader字段读。
1.3 导出表通常住在哪个节
绝大多数编译器会把导出信息放进一个叫.edata的节里,这个节只读、不可执行。它里面紧凑地排布着目录头、三张数组和名字字符串。为什么集中放一个节?因为链接器在生成时并不知道导出总量,先把所有相关信息堆在一起,最后再回填目录头里的三个RVA。集中存放也让导出信息在内存里是连续的,GetProcAddress做范围判断时只需要一次比较。
但别把.edata当成铁律。手工构造的PE、加过壳的PE、以及某些精简链接器的产物,完全可以把导出目录塞进.rdata甚至.text里。所以解析代码里绝对不能硬编码节名,必须老老实实从数据目录拿RVA,再查节表定位。
2. IMAGE_EXPORT_DIRECTORY 结构逐字段拆解
2.1 40字节的目录头,每一格都有用
结构定义不长,一共40字节,但每个字段几乎都会在解析过程中被用到。先看完整定义,再逐条解释。
typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; // 0x00 保留,通常为 0 DWORD TimeDateStamp; // 0x04 生成时间戳 WORD MajorVersion; // 0x08 主版本号 WORD MinorVersion; // 0x0A 次版本号 DWORD Name; // 0x0C 模块名字符串的 RVA DWORD Base; // 0x10 序号起始值,通常为 1 DWORD NumberOfFunctions; // 0x14 EAT 数组的项数 DWORD NumberOfNames; // 0x18 ENT 数组的项数 DWORD AddressOfFunctions; // 0x1C EAT 数组 RVA DWORD AddressOfNames; // 0x20 ENT 数组 RVA DWORD AddressOfNameOrdinals; // 0x24 序号数组 RVA } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;Characteristics现在基本是历史遗留,读出来一直是0,可以忽略。TimeDateStamp是链接器写入的时间戳,有些构建流程会为了可复现而把它固定成某个值,所以别拿它当版本判断依据。MajorVersion和MinorVersion同理,绝大多数编译器都填0。
真正的主角是后面七个字段。Name指向模块名字符串,比如"kernel32.dll",注意这里是RVA不是偏移。Base是序号的起点,微软的链接器默认从1开始,但规范允许0或其他值,手工构造的文件里见过从0开始的。NumberOfFunctions是EAT数组的长度,NumberOfNames是ENT数组的长度,这两个数可以相等,也可以不等,不等的情况是理解导出表的关键难点,后面会专门讲。
2.2 三张数组的配合关系
导出表最精妙的地方在于它没有用一个大结构体把"名字、序号、地址"绑在一起,而是拆成了三个长度不同的数组,靠下标隐式关联。先把三张表的角色理清:
| 数组 | 存放位置 | 元素类型 | 元素含义 |
|---|---|---|---|
| EAT(AddressOfFunctions) | AddressOfFunctions 指向 | DWORD | 每个导出函数的 RVA |
| ENT(AddressOfNames) | AddressOfNames 指向 | DWORD | 每个导出函数名字符串的 RVA |
| 序号表(AddressOfNameOrdinals) | AddressOfNameOrdinals 指向 | WORD | 该名字对应 EAT 数组的下标 |
按名字查找的完整链路是这样的:先在ENT里按下标i取出一个名字RVA,读出字符串跟目标名字比对,命中后去序号表取第i个WORD,得到一个EAT下标k,再用k去EAT里取DWORD,得到函数RVA,最后加上模块基址就是真实地址。而这个函数的导出序号是Base + k,不是Base + i。
注意这里的错位:ENT和序号表是同下标一一对应的,序号表和EAT是靠值对应的。很多初次写解析器的人会在这一步把i当k直接用,结果导出的地址全部错位,症状是解析出来的地址跟dumpbin对不上。我第一次写的时候就是这么错的,查了一下午。
2.3 为什么序号和名字要拆开
设想一个场景:某个DLL内部有1000个函数,但只对外开放50个名字。按"结构体数组"的思路,你得为1000个函数各留一个结构体槽位,其中950个槽位的名字字段是空的,浪费巨大。而拆成三张表之后,EAT保持1000项(覆盖全部导出序号,包括只按序号导出的),ENT只放50项(只有命名导出的才有名字),序号表也只放50项,通过WORD值指向EAT里的具体位置。空间省下来了,代价是查询逻辑多一次间接寻址。
这也解释了NumberOfNames总是小于等于NumberOfFunctions的原因。当两者相等时,说明所有导出函数都有名字;当NumberOfNames明显小于NumberOfFunctions时,说明有一批函数是"匿名导出"的,只能通过序号访问。系统里就有不少这样的DLL,比如某些版本的ws2_32.dll里既有命名导出,也有一批只按序号导出的内部函数。
3. 手写一个导出表解析器
3.1 先解决RVA到文件偏移的转换
解析任何基于RVA的结构,第一步都是把RVA翻译成文件偏移。逻辑很直白:遍历节表,找到满足VirtualAddress <= RVA < VirtualAddress + VirtualSize的那个节,然后做减法加上原始偏移。这里有个细节要注意——判断范围时用VirtualSize还是SizeOfRawData,两者不一定相等。稳妥的做法是取两者中较大的那个做上界,避免踩到边界外的数据。
import struct pe = open('demo.dll', 'rb').read() def get_sections(): e_lfanew = struct.unpack_from('<I', pe, 0x3C)[0] assert pe[e_lfanew:e_lfanew + 4] == b'PE\0\0', '不是有效的PE文件' coff = e_lfanew + 4 num_sec = struct.unpack_from('<H', pe, coff + 2)[0] size_opt = struct.unpack_from('<H', pe, coff + 16)[0] opt = coff + 20 magic = struct.unpack_from('<H', pe, opt)[0] sec_off = opt + size_opt secs = [] for i in range(num_sec): b = sec_off + i * 40 name = pe[b:b + 8].rstrip(b'\0').decode(errors='replace') vsize, va, rawsize, raw = struct.unpack_from('<IIII', pe, b + 8) secs.append((name, va, vsize, raw, rawsize)) return magic, opt, secs def rva_to_off(rva, secs): for name, va, vsize, raw, rawsize in secs: if va <= rva < va + max(vsize, rawsize): return rva - va + raw return Nonee_lfanew在DOS头偏移0x3C处,指向"PE\0\0"签名。COFF头从签名后4字节开始,NumberOfSections在偏移+2,SizeOfOptionalHeader在偏移+16。可选头紧随COFF头之后,Magic字段判断是PE32还是PE32+。节表紧跟可选头,每项40字节,字段顺序是名字(8)、VirtualSize(4)、VirtualAddress(4)、SizeOfRawData(4)、PointerToRawData(4)。
3.2 定位导出目录并读全字段
有了上面的基础,定位就简单了。数据目录的起始位置是可选头偏移加上一个固定值:PE32是96,PE32+是112,这也是可选头标准字段的总长度。第0项的前8字节就是导出目录的RVA和Size。
def get_export_info(): magic, opt, secs = get_sections() is64 = (magic == 0x20B) dd_off = opt + (112 if is64 else 96) exp_rva, exp_size = struct.unpack_from('<II', pe, dd_off) if exp_rva == 0: return None eo = rva_to_off(exp_rva, secs) # 从 0x0C 开始连续读 7 个 DWORD name_rva, base, nfunc, nname, eat_rva, ent_rva, eot_rva = \ struct.unpack_from('<IIIIIII', pe, eo + 12) return { 'exp_rva': exp_rva, 'exp_size': exp_size, 'name_rva': name_rva, 'base': base, 'nfunc': nfunc, 'nname': nname, 'eat_rva': eat_rva, 'ent_rva': ent_rva, 'eot_rva': eot_rva, 'secs': secs, }从偏移0x0C一口气读7个DWORD,正好覆盖Name到AddressOfNameOrdinals,读到文件里就是0x0C到0x28,刚好到结构体末尾。这种连续读取比逐字段读更简洁,前提是你对偏移烂熟于胸。
注意:如果exp_rva为0,说明模块没有导出任何东西,直接返回空,不要继续往下算,否则rva_to_off返回None之后一路报错,调试起来很烦。
3.3 还原每一个导出函数
接下来分两步走。第一步遍历ENT和序号表,建立"EAT下标 → 名字"的映射;第二步遍历EAT,按顺序输出每个导出项。
def dump_exports(): info = get_export_info() if not info: print('该模块没有导出表') return secs = info['secs'] # 模块名 no = rva_to_off(info['name_rva'], secs) end = pe.index(b'\0', no) mod_name = pe[no:end].decode(errors='replace') print(f'模块名: {mod_name} 序号基数: {info["base"]} ' f'EAT项数: {info["nfunc"]} ENT项数: {info["nname"]}') # 建立 EAT 下标 -> 名字 的映射 eat2name = {} ent_off = rva_to_off(info['ent_rva'], secs) eot_off = rva_to_off(info['eot_rva'], secs) for i in range(info['nname']): fn_rva = struct.unpack_from('<I', pe, ent_off + i * 4)[0] foff = rva_to_off(fn_rva, secs) if foff is None: continue e = pe.index(b'\0', foff) fname = pe[foff:e].decode(errors='replace') idx = struct.unpack_from('<H', pe, eot_off + i * 2)[0] eat2name[idx] = fname eat_off = rva_to_off(info['eat_rva'], secs) lo = info['exp_rva'] hi = lo + info['exp_size'] for k in range(info['nfunc']): func_rva = struct.unpack_from('<I', pe, eat_off + k * 4)[0] if func_rva == 0: continue # 空洞,该序号未使用 ordinal = info['base'] + k fname = eat2name.get(k, '<仅按序号导出>') if lo <= func_rva < hi: # 落在导出目录范围内,说明是转发导出 fo = rva_to_off(func_rva, secs) e = pe.index(b'\0', fo) target = pe[fo:e].decode(errors='replace') print(f'[{ordinal:5d}] {fname:40s} -> 转发到 {target}') else: print(f'[{ordinal:5d}] {fname:40s} RVA=0x{func_rva:08X}')这段代码里有两个地方值得停下来看。第一处是if func_rva == 0: continue。EAT里确实存在值为0的槽位,它们表示这个序号没有被占用。为什么会有空洞?因为链接器在分配序号时是单调递增的,如果某个函数的序号被手动指定得比较大,中间就会空出来。遇到0直接跳过是标准做法。
第二处是转发导出的判断。当函数RVA落在导出目录自身的范围内(exp_rva到exp_rva + exp_size),这个"RVA"其实不是代码地址,而是指向一个ASCII字符串,格式是"目标DLL.目标函数"。这是PE格式里一个非常巧妙的复用技巧,也是新手最容易踩的坑——不判断范围,直接把字符串地址当函数地址用,算出来的东西当然不对。
4. 名字查找与转发导出:两个高频坑点
4.1 二分查找能成立,是因为名字数组被强制排序
GetProcAddress按名字查询时用的是二分查找,而不是线性扫描。二分查找的前提是有序,所以PE规范要求ENT里的名字字符串必须按字典序升序排列。这个排序在链接阶段完成,用的是逐字节比较的字典序(类似strcmp,大写字母排在小写字母前面)。你如果自己生成DLL,链接器会自动排好;但手工构造PE时忘了排序,GetProcAddress就会静默失败,返回NULL,而且不报任何错。
验证排序状态很简单,把ENT里的名字全读出来,检查是否严格单调递增:
names = [] for i in range(info['nname']): fn_rva = struct.unpack_from('<I', pe, ent_off + i * 4)[0] foff = rva_to_off(fn_rva, secs) e = pe.index(b'\0', foff) names.append(pe[foff:e]) sorted_ok = all(names[i] < names[i + 1] for i in range(len(names) - 1)) print('名字数组有序:', sorted_ok)如果这里输出False,基本可以确定这个文件是手工改过导出表的,或者是某个壳在重建导出信息时偷懒了。我在分析一个商业壳的产物时就遇到过这种情况,导出名字乱序,导致正常的GetProcAddress拿不到函数,但按序号又能拿到,非常迷惑。
4.2 转发导出的识别与实战价值
转发导出(Forwarder)是导出表里最有意思的设计。当你在EAT里读出的RVA落到导出目录内部时,它指向的不是机器码,而是一个字符串,形如:
NTDLL.RtlAllocateHeap KERNELBASE.CreateFileW API-MS-WIN-CORE-FILE-L1-1-0.CreateFileW第一种是经典形式,表示"这个名字由NTDLL里的RtlAllocateHeap来提供"。第二种是API Set转发,Windows 7之后大量使用,形状是api-ms-win-*前缀的虚拟模块名,由加载器在运行时解析到真实的实现DLL。
判断逻辑的核心就是那句lo <= func_rva < hi。这里hi用的是exp_rva + exp_size。有一个细节需要留意:某些链接器生成的Size字段并不精确,可能比实际字符串区略大或略小。更稳的判断方式是同时检查RVA是否落在一个不可执行的节里,或者直接尝试读取字符串并匹配\w+\.\w+的形式做二次确认。我在生产代码里两个条件都会加,先用范围做快速筛选,再用格式做校验,误判率接近零。
转发导出不只是理论知识点,它有很实用的用途。比如你要给某个系统DLL做一层代理,拦截其中几个函数,其他函数原样放行,就可以建一个同名DLL,在.def文件里这么写:
EXPORTS CreateFileW = RealKernel32.CreateFileW OriginalFunc = RealDll.OriginalFunc这样加载器解析你的导出表时,看到的是转发字符串,会自动去真实DLL里取地址。你只实现了需要拦截的那几个,其余的零成本透传。这个技巧在排查兼容性问题、做灰度替换时特别好用,比挨个写桩函数省事得多。
5. 常见异常排查与工具选型
5.1 解析结果对不上时怎么查
导出表解析出的问题五花八门,但归纳下来跑不出几类。下面这张表是我这些年攒下来的排查清单,基本都是踩过一次就忘不掉的那种。
| 现象 | 常见原因 | 排查手法 |
|---|---|---|
| 导出的地址全错位 | 把ENT下标当EAT下标用,漏了序号表这一跳 | 拿dumpbin结果对照,看是否整体偏移 |
| 按名字查不到函数 | ENT未排序,二分查找失效 | 遍历检查名字是否单调递增 |
| 解析出的"地址"是乱码 | 没判断转发导出,把字符串当代码地址 | 检查RVA是否落在导出目录范围内 |
| 序号从0开始,对不上 | 硬编码了Base=1,没读Base字段 | 打印Base实际值 |
| 部分序号读出来是空 | EAT中值为0的空洞,属正常现象 | 跳过0值即可,不要当错误 |
| 64位文件解析越界 | 数据目录偏移仍按PE32的96计算 | 按Magic判断用96还是112 |
| Size字段明显偏大 | 某些链接器生成不精确 | 结合节范围和字符串格式双重校验 |
还有一类问题不在文件本身,而在你的读取方式。比如用内存映射读文件时,如果文件被其他进程占用,映射可能失败;或者读的是加壳后的映像,导出表被加密或者挪到了运行时才解密的位置,静态解析当然拿不到东西。这种情况下先脱壳再分析,别硬啃。
注意:EAT里的项数由NumberOfFunctions决定,序号表里的索引是WORD类型,理论最大65535。虽然NumberOfFunctions声明为DWORD,但实际有效值受限于WORD索引,所以一个模块最多导出65536个函数。这个上限在正常项目里远远够用,但在合并大型静态库时会撞上。
5.2 工具怎么选,看你要解决什么
日常核对导出表,我一般按场景挑工具,没有哪个是万能的。
| 工具 | 优点 | 适合场景 |
|---|---|---|
| dumpbin /exports | 系统自带,输出干净 | 快速核对导出名字和序号 |
| CFF Explorer | 图形界面,字段结构一目了然 | 定位偏移、看原始字节 |
| PE-bear | 结构树清晰,支持脚本 | 逐个字段对照学习 |
| objdump -p | 跨平台,能看转发目标 | Linux 环境下分析 |
| 自己写的脚本 | 可批量、可定制判断逻辑 | 样本量大、需要自动化 |
命令行工具里我推荐dumpbin /exports your.dll,它会直接把序号、名字、地址三列列出来,还能显示转发目标。图形工具里CFF Explorer的"Export Directory"页签会把三个RVA和Count都标出来,对照本文的结构定义看一遍,基本就通了。
至于自己写脚本,最大的价值在于批量处理和定制规则。比如你要扫描一批DLL,找出所有导出了特定敏感函数(像CreateProcess、WriteProcessMemory)的模块,用现成工具得一个个点,脚本几行就搞定了。上面那套解析代码稍微改改就能拿来跑批,这是我推荐每个人都动手写一遍的原因——用别人的工具永远只是使用者,自己写过一遍才算真的理解。
最后分享一个我在实际项目里养成的习惯:拿到任何DLL,第一件事是用脚本把导出数量、命名导出数量、转发导出数量这三个数打出来。正常发布的DLL,命名导出通常接近总数;如果转发导出占比异常高,说明这个模块很可能是API Set的转发壳或者代理DLL;如果命名导出的数量是总数的一半甚至更少,那里面很可能藏着一批只按序号导出的内部函数,值得进一步挖一挖。这个三秒的判断,比盲目翻目录树有效得多。