
每个接触过PE文件的人心里大概都有一道坎数据目录、导入表、导出表这些还好说到了资源表就有点绕了。我做逆向分析和去壳工作也算有些年头了最开始卡住我的就是这个资源表。图标是它管的版本信息是它管的菜单对话框是它管的就连不少软件藏着的自定义数据也经常作为一个特殊类型资源挂靠在资源表里。所以无论是做汉化、改图标、提取素材还是做样本分析过不了资源表这一关后面的活儿基本都干不下去。这篇文章我就把资源表的原理、结构和实操解析完整地讲一遍适合刚学完PE头格式、正准备啃资源表的新手也适合那些一直在用工具改资源却从没搞清楚它内部长什么样的朋友。1. 资源表到底是什么一个藏在PE里的文件系统1.1 资源表解决了什么问题一个Windows可执行程序除了代码和数据还带着一大堆附属品窗口左上角的图标、程序名底下的版本号、右键菜单里的文字、开屏弹出来的对话框、UAC需要的manifest清单。这些东西形式各异、长度不一散落在文件里东一块西一块显然不行所以PE格式专门设计了一张资源表把这些资源统一管起来。你可以把资源表想象成一个图书馆的目录系统。图书馆要把书分类摆放必须有一套编号规则先按图书分类文学、历史、计算机再按书名或者作者最后可能还要细分到具体版本。资源表的组织方式几乎一模一样它用三级树形结构来定位任何一个资源第一级是资源类型第二级是资源名称第三级是资源语言。你最终想拿到的图标、字符串、版本块就挂在第三级目录下面的叶子节点上。这个设计最巧妙的地方在于它把资源寻址和资源存储分开了。资源目录树负责告诉解析器你要找的东西属于什么类型、叫什么名字、哪个语言版本而真正的数据块则散落在文件的任意位置只要记录下它的RVA相对虚拟地址和大小就够了。所以资源表本质上是一套索引而不是一个连续存放所有资源的大文件。理解了这个后面看数据结构就不会懵。1.2 三级树形结构类型、名称、语言先看第一级也就是资源类型。PE规范里预定义了一批数字编号常见的包括类型号宏定义含义3RT_ICON图标6RT_STRING字符串表10RT_RCDATA原始数据块14RT_GROUP_ICON图标组16RT_VERSION版本信息24RT_MANIFEST程序清单每个类型下面又会挂若干名称项比如一个程序可能有三个不同尺寸的图标它们各自有一个ID再往下每个名称下面还有多个语言版本比如中文版和英文版的字符串表虽然ID相同但语言不同所以也被区分为两个独立资源。在数据结构上每一级都是一个目录表IMAGE_RESOURCE_DIRECTORY里面放着一组目录项IMAGE_RESOURCE_DIRECTORY_ENTRY。第一级目录项指向第二级目录第二级指向第三级第三级的目录项再指向真正的资源数据条目IMAGE_RESOURCE_DATA_ENTRY。一级一层逐级向下最终在第三级拿到数据区的RVA和大小。微软为何设计成这种固定三层结构而不是简单一张大表因为资源多的时候树形结构可以用ID做二分查找效率高得多而且类型、名称、语言三个维度的组合本身就很自然地对应了树的分层。还有个更实际的原因多语言软件很常见如果用扁平结构每种语言的每个资源都要独立一行表会膨胀得很难看用三级树每种资源先按类型归类再按名称归类最后语言作为叶子分叉管理起来清爽得多。2. 核心数据结构逐字节拆解2.1 IMAGE_RESOURCE_DIRECTORY目录头节点任何一级资源目录的起点都是一个16字节的结构typedef struct _IMAGE_RESOURCE_DIRECTORY { DWORD Characteristics; // 资源属性通常为0 DWORD TimeDateStamp; // 资源编译时间戳 WORD MajorVersion; // 主版本号 WORD MinorVersion; // 次版本号 WORD NumberOfNamedEntries; // 名称项数量 WORD NumberOfIdEntries; // ID项数量 } IMAGE_RESOURCE_DIRECTORY;这个结构本身不复杂真正重要的是最后两个字段NumberOfNamedEntries和NumberOfIdEntries。这两个值加起来就是紧跟在目录头后面的目录项IMAGE_RESOURCE_DIRECTORY_ENTRY的总数。其中前NumberOfNamedEntries个目录项使用字符串名称后NumberOfIdEntries个使用数字ID。解析器读到这个头之后立刻就能算出接下来要连续读取多少个8字节的目录项再逐个处理。初次接触的人很容易把这个目录想成一种容器结构觉得目录里直接装数据。实际上IMAGE_RESOURCE_DIRECTORY只是描述节点状态的一个头节点下面真正起作用的是数组形式的目录项列表。换句话说这个头是节点信息卡数组才是通向下一层的路标。很多工具在显示资源树时展开每一层看到的那些条目就是从这里按顺序输出的。2.2 IMAGE_RESOURCE_DIRECTORY_ENTRY通往下一层的路标每个目录项固定8字节结构如下typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY { union { struct { DWORD NameOffset : 31; // 名称字符串的偏移 DWORD NameIsString : 1; // 最高位1表示名称是字符串0表示是数字ID } DUMMYSTRUCTNAME; DWORD Name; WORD Id; } DUMMYUNIONNAME; union { DWORD OffsetToData; // 数据条目偏移 struct { DWORD OffsetToDirectory : 31; // 子目录偏移 DWORD DataIsDirectory : 1; // 最高位1表示指向子目录0表示指向数据条目 } DUMMYSTRUCTNAME2; } DUMMYUNIONNAME2; } IMAGE_RESOURCE_DIRECTORY_ENTRY;这个结构看着有点吓人两个union字段还带位域。拆开看其实清楚得很第一个4字节是身份信息告诉解析器这个资源项叫什么名字。如果最高位是0低16位就是一个数字ID比如类型号为3的表示图标如果最高位是1说明名字不是数字而是一个字符串低31位就是该字符串的偏移地址。第二个4字节是位置信息告诉解析器这个资源项的下一层在哪。如果最高位是1说明这一层下面还有子目录比如类型后面还有名称OffsetToDirectory就是子目录的偏移如果最高位是0说明这一层已经到了叶子OffsetToData指向真正的IMAGE_RESOURCE_DATA_ENTRY。用两个最高位作为指针类型标识这种技巧在二进制格式里很常见本质上就是用一个标志位把这是分支和这是叶子区分开。我当年第一次看到这个结构时被两个union绕晕了后来自己用十六进制编辑器dumping了几个文件一眼就明白了其实就是一个32位值最高位一路数过去全是1下面低31位就是偏移简单直接。2.3 IMAGE_RESOURCE_DATA_ENTRY资源数据的真实入口走到第三级的目录项它的位置信息指向的就是数据条目了typedef struct _IMAGE_RESOURCE_DATA_ENTRY { DWORD OffsetToData; // 资源数据的RVA DWORD Size; // 资源数据大小单位字节 DWORD CodePage; // 代码页通常为0 DWORD Reserved; // 保留字段 } IMAGE_RESOURCE_DATA_ENTRY;这个结构16字节核心就两个字段OffsetToData和Size。注意OffsetToData是RVA不是文件偏移。也就是说它描述的是当程序被加载进内存后这个资源位于虚拟地址空间的哪里而不是它在磁盘文件的哪个位置。想在磁盘上定位资源必须先把RVA转换成文件偏移转换规则跟转换导入表、导出表一样找到包含这个RVA的节section然后按文件偏移 节的PointerToRawData (RVA - 节的VirtualAddress)来计算。CodePage和Reserved两个字段大部分情况下都是0。尤其CodePagePE规范把它描述为资源的代码页但实际几乎没有人往这里填东西解析时基本可以忽略。到这一步一条完整的资源解析路径就通了从资源表起始地址出发第一级目录项按类型找第二级按名称找第三级按语言找第三级目录项指向资源数据条目数据条目给出RVA和大小再经过RVA转换最终在磁盘文件里拿到资源的那一坨原始字节。2.4 字符串名称与数字ID最高位的妙用资源名称有两种表达形式数字ID和Unicode字符串。数字ID直接存16位范围从0到65535字符串名称则通过另一个结构来引用typedef struct _IMAGE_RESOURCE_DIRECTORY_STRING { WORD Length; // 字符串长度单位是字符数不是字节数 CHAR NameString[1]; // Unicode字符串内容 } IMAGE_RESOURCE_DIRECTORY_STRING;这个结构同样简单难点在偏移的计算方式上。目录项里的NameOffset或OffsetToData、OffsetToDirectory统统是相对于资源表起始地址的偏移而不是相对于当前目录也不是相对于某个节的偏移。这一点至关重要。我见过太多人栽在这里拿着目录项里那个偏移直接当文件偏移去读结果读出来一堆乱码。正确做法是先取资源表在文件中的地址即数据目录给出的RVA转换后得到的文件偏移再加上目录项里的偏移值得到的才是字符串或子目录在文件里的真实位置。为什么设计成这种相对基址的方式因为PE文件在磁盘上和内存里的加载基址不同如果存绝对地址程序在不同基址下加载时这些指针就全失效了。资源表本身的位置由数据目录指定所有内部指针都相对于资源表基址这样文件无论加载到哪里只要资源表整体被映射过来这些相对偏移就永远有效。这种设计思想跟节表里用VirtualAddress而不是绝对地址是同一个道理。再说二分查找。IMAGE_RESOURCE_DIRECTORY头里的两个数量字段让解析器可以知道同一级目录下最多有多少个条目。因为目录项数组是有序的ID按升序字符串按Unicode码点升序Windows加载器定位某个资源时可以直接二分查找不必线性扫描全部条目这在资源数量非常庞大的程序里能省不少时间。3. 手把手解析一个真实的资源表3.1 环境准备与工具选型纸上谈兵没意思直接拿一个真实的exe来解析。建议动手前准备好三样东西一个精简的PE文件最好带图标、版本信息、manifest的小工具文件越大越难debug。一个十六进制编辑器我用的是HxD和010 Editor。HxD免费够用010 Editor自带PE模板能自动标出各个结构的位置新手强烈推荐。Resource Hacker或CFF Explorer作为对照工具验证自己解析出来的结果对不对。我这里用一个自己写的测试程序文件不大资源包括一个图标组、一个版本块和一个manifest清单。整个解析流程分为三步定位资源表递归遍历资源树最后把拿到的资源数据RVA转成文件偏移并读取。3.2 用Python编写资源表解析器手工一个个字节读太慢了我用Python写了段脚本把解析流程自动化。先读PE头定位数据目录再找到资源表RVA转换成文件偏移然后递归遍历目录树import struct with open(test.exe, rb) as f: data f.read() # 解析DOS头定位PE头 e_lfanew struct.unpack(I, data[0x3C:0x40])[0] assert data[e_lfanew:e_lfanew 4] bPE\0\0 # 解析COFF头IMAGE_FILE_HEADER coff_offset e_lfanew 4 machine, number_of_sections, timestamp struct.unpack(HHI, data[coff_offset:coff_offset 8]) size_of_optional_header struct.unpack(H, data[coff_offset 16:coff_offset 18])[0] optional_offset coff_offset 20 # 数据目录在可选头偏移0x60处资源表是第2个条目index为2 data_dir_offset optional_offset 0x60 resource_rva, resource_size struct.unpack(II, data[data_dir_offset 16:data_dir_offset 24]) print(f[] 资源表RVA: 0x{resource_rva:X}, 大小: 0x{resource_size:X}) # 解析节表 sections_offset optional_offset size_of_optional_header sections [] for i in range(number_of_sections): sec data[sections_offset i * 40: sections_offset (i 1) * 40] name sec[:8].rstrip(b\0).decode(ascii, errorsignore) virtual_size, virtual_addr, raw_size, raw_ptr struct.unpack(IIII, sec[8:24]) sections.append({ name: name, VirtualAddress: virtual_addr, VirtualSize: virtual_size, PointerToRawData: raw_ptr, RawSize: raw_size }) print(f 节 {name}: VA0x{virtual_addr:X}, VS0x{virtual_size:X}, fRoff0x{raw_ptr:X}, RS0x{raw_size:X}) def rva_to_offset(rva): for s in sections: start s[VirtualAddress] end s[VirtualAddress] s[VirtualSize] if start rva end: return s[PointerToRawData] (rva - start) return None resource_off rva_to_offset(resource_rva) print(f[] 资源表文件偏移: 0x{resource_off:X})这段代码做了三件事解析PE头、解析节表、实现RVA转文件偏移。接下来是核心的递归解析逻辑def parse_resource_directory(offset, base_offset, depth0): # 读取16字节目录头 characteristics, timestamp, major, minor, named_count, id_count \ struct.unpack(IIHHHH, data[offset:offset 16]) total_entries named_count id_count print( * depth f[目录] 项数量{total_entries} (命名{named_count}, ID{id_count})) entry_offset offset 16 for i in range(total_entries): name_raw, offset_raw struct.unpack(II, data[entry_offset:entry_offset 8]) entry_offset 8 # 解析名称最高位为1是字符串否则是数字ID if name_raw 0x80000000: str_off base_offset (name_raw 0x7FFFFFFF) length struct.unpack(H, data[str_off:str_off 2])[0] name data[str_off 2:str_off 2 length * 2].decode(utf-16le, errorsignore) name_str f字符串名称 {name} else: name_id name_raw 0xFFFF name_str fID0x{name_id:X}({name_id}) # 解析位置最高位为1是子目录否则是数据条目 if offset_raw 0x80000000: sub_off base_offset (offset_raw 0x7FFFFFFF) print( * depth f[条目] {name_str} - 子目录) parse_resource_directory(sub_off, base_offset, depth 1) else: data_entry_off base_offset offset_raw data_rva, data_size, code_page, reserved \ struct.unpack(IIII, data[data_entry_off:data_entry_off 16]) file_off rva_to_offset(data_rva) print( * depth f[条目] {name_str} - RVA0x{data_rva:X}, fSize0x{data_size:X}, 文件偏移0x{file_off:X}) parse_resource_directory(resource_off, resource_off)跑完脚本后输出是一棵完整的资源树。拿这份输出跟Resource Hacker里看到的资源树对比一下类型、名称、语言、大小全都对得上说明解析成功。我第一次跑通这个脚本时盯着屏幕上的树形输出成就感是实打实的——从这一刻起任何PE文件的资源表对我来说都不再是黑盒了。3.3 用Resource Hacker对照验证脚本跑完我通常还会用Resource Hacker打开同一个文件目测一下资源树层级确认解析结果一致。Resource Hacker会把资源树显示为类型→名称→语言三栏结构类型那一栏的名字都是处理过的文本比如Version Info对应的其实是RT_VERSION即类型ID16Manifest对应的就是类型ID24。看到这些熟悉编号以人类可读的方式呈现正好反推出了资源表内部的编号规则。对照验证特别适合排查那种明明感觉解析对了但数据内容就是不对的奇怪问题。比如Resource Hacker显示的版本信息是正常的而我脚本dump出来的版本块却乱码那十有八九是CodePage没处理对或者资源数据RVA转文件偏移时节的VirtualSize比RawSize大导致落在了未映射的Padding区域里。这些问题等到了踩坑那节我再展开说。4. 资源表的四大实战场景4.1 汉化与资源编辑资源表最古老、也最经典的应用就是汉化。早期很多国外软件没有国际化设计字符串硬编码在资源里要改成中文就得动资源表。通过修改资源表里对应语言版本的字符串资源或者把整个资源目录里某种语言的资源替换成翻译后的版本就能实现界面汉化而不碰任何代码。具体操作上成熟的汉化工具比如Radialix、Passolo做得比较完善但它们的原理用一句话概括把资源解析出来后替换掉RT_STRING和RT_DIALOG里的文本再重新计算资源表相关偏移并回写文件。这里面最麻烦的是体积变化。如果修改后的资源比原来大原来的目录树和数据区空间不够放就得重新调整整个资源表的布局牵一发动全身。这也是为什么我建议初学者先学习解析而不是直接上手修改能读是能写的基础。4.2 图标与素材提取游戏和软件的图标、图片素材很多都以资源形式打包在exe/dll里。用资源表解析器把RT_ICON、RT_BITMAP、RT_RCDATA这些类型的数据dump出来再按对应格式保存就能把原始素材提取出来。这里有个细节图标通常不是一块独立资源而是拆成RT_GROUP_ICON和若干个RT_ICON。RT_GROUP_ICON是一个小组目录里面记录了一组图标各自的大小、色彩位数和对应的RT_ICON资源ID真正存像素数据的是那些RT_ICON资源。如果只dump RT_ICON得到的是一块没法直接看的图像数据必须结合RT_GROUP_ICON里的结构信息才能拼出一个可用的.ico文件。感性的朋友可以这么理解RT_GROUP_ICON是图像索引文件RT_ICON是图像数据分片两者结合起来才是一张完整的图。4.3 资源完整性校验在一些安全审计场景里资源表常被拿来当完整性指纹。比如一个程序明明自称是官方版但它的资源跟官方版对不上——图标被换过、版本号被改过、多了一个奇怪的自定义类型资源。把资源表里所有资源条目拎出来计算每个数据块的哈希再跟官方安装包比对就能快速定位差异点。这种校验不需要理解资源内容本身是干嘛的只要完整解析出哪些资源存在、每个多大、内容哈希是什么就已经很有价值了。我做样本分析时经常先把资源表哈希跑一遍再看结果——光凭一个异常的资源类型编号就能筛掉一大批改了资源就敢自称破解版的东西。4.4 资源注入的边界与思考既然能读自然也能写。向资源表里添加一个新条目或者往某个自定义类型里塞一段自定义数据本身是一项中性技术广泛应用于插件系统、补丁分发、软件授权信息存储等场景。很多软件的许可证信息就存在RT_RCDATA类型里程序启动时自己解析。但任何技术工具都有边界。资源表是PE文件里公开可见的部分CFF Explorer、Resource Hacker这类工具都能一览无余所以把敏感内容藏到资源表里从来不是一个隐蔽的选择。反过来正常做软件定制时也别指望资源表能挡住任何有基本逆向能力的人。理解资源的存储与读取是为了更好地保护自己的软件形态、理解程序的组成而不是去给恶意行为找掩护。对学习资源表的人来说把重心放在读懂结构、正确解析这件事本身上就足够有价值了。5. 最容易踩的五个坑5.1 RVA与文件偏移的混淆这是资源表解析里第一大坑。资源树内部存的全是相对资源表基址的偏移资源数据条目里存的是RVA而你在文件里实际读取时用的是文件偏移。三者之间任何一个环节没转对结果都是定位错误。举个真实例子我调试某个程序时解析出来版本资源的大小是0x234但dump出来的数据开头完全没有版本块的固定签名反而是一堆看起来像代码的字节。排查了半天发现是资源数据RVA落在了某个节的VirtualSize和RawSize不一致的区域。节表里VirtualAddress到VirtualAddressVirtualSize这段空间在文件里可能没有对应的原始数据因为磁盘上不需要映射到那么大按公式映射到文件却计算出了一个落在节尾Padding的偏移自然就读错了。正确的排查方式是先确认RVA落在哪个节再看这个节的RawSize是否覆盖了目标偏移最后才动手读数据。如果RVA落在VirtualSize内但超出了RawSize范围那这个资源在磁盘文件里本来就没有对应的原始字节必须放弃物理读取或者按程序加载进内存后的状态来分析。5.2 偏移基准搞错从第二级目录开始的陷阱资源目录项的NameOffset、OffsetToData、OffsetToDirectory这三个偏移都以资源表基址为基准而不是以当前目录为基准。这个基准问题特别隐蔽因为第一级目录的基址和资源表基址是同一个你按资源表基址偏移算出来的结果跟按当前目录基址偏移算出来的一样于是你以为自己搞对了。到了第二级、第三级目录不再是资源表基址了但那些偏移还是相对资源表基址的如果你还按当前目录去算就全乱了。我在文章前面反复强调这一点是因为它就是新手最容易错、又最难自查的地方。强烈建议你把每一级目录的基址打印出来逐级手动验证偏移务必确保每个子目录和数据条目都是用同一个基准算出来的。写递归解析器时把base_offset作为参数一路传下去不要在递归过程中随意改基准。5.3 对齐与Padding问题资源数据存放在节内部时一切都要遵守节的对齐规则。比如一个节的文件对齐是0x200资源数据从0x2C0开始那么下一个资源的文件偏移最也要对齐到0x400。在解析时DataEntry给出的Size是精确数据大小不包含Padding要读取的就应该是Size这么多字节而不是把节里后续的所有内容都当作资源的一部分。这个问题在人工翻十六进制时特别容易看走眼。资源数据后面经常跟着一堆00那不是资源内容是对齐Padding。有些资源本身以00结尾更难分辨Padding从哪开始。稳妥的做法是严格按DataEntry里的Size字段截断别多读。尤其在做哈希校验时多读了Padding会导致哈希永远对不上白白浪费时间排查。5.4 递归遍历的终止条件资源树的层级从设计上说是类型、名称、语言三层最多只能嵌套三层。但写递归解析器时理想做法不是限制深度等于3而是根据目录项的DataIsDirectory标志来判断——是子目录就继续递归是数据条目就读取数据。这么写的好处是能兼容那些不合规但实际存在的PE文件有些压缩壳、混淆器会生成四层甚至五层嵌套的资源树加载器照样读得动限制死深度反而容易漏解析。同时要注意递归别写成死循环。正常情况下子目录偏移不会指向自己的祖先目录但畸形文件里可能存在循环引用。稳妥的解析器会维护一个访问过的目录偏移集合发现重复就停下并报警。这既是工程健壮性也是安全考虑——一个恶意构造的PE文件让解析器陷入死循环是再简单不过的目的了。5.5 工具使用与人工验证的思路最后聊一点工具心得。CFF Explorer和010 Editor是我做PE解析时用得最多的工具一个负责快速看全貌一个负责深入抠字节。资源表解析这种活儿只用脚本不用工具验证跟只用工具不写脚本都是走不远的。我的习惯流程是先让脚本输出资源树和资源数据哈希再用CFF Explorer打开同一文件人工比对每一级条目的名称和ID最后用Resource Hacker验证资源内容是否正确。三次验证全都通过才敢确认这个文件解析真没问题。这个流程看着繁琐但对培养结构直觉特别有用练多了之后你看一个十六进制dump就能大概猜出资源表在哪一层、偏移基准对不对。结尾一个持续有用的习惯资源表只是PE格式众多结构中的一块拼图但它的树形设计、相对偏移机制、RVA转换逻辑几乎涵盖了理解PE所需的全部核心思想。我自己当年把资源表彻底吃透之后再回头去看导入导出表、延迟加载、异常表这些结构感觉都顺了不少——因为它们的原理在资源表里都已经出现过一遍了。最后分享一个我实际踩过的小技巧做解析实验时别只拿一个小工具测试经常换不同的正常软件去跑你会发现资源表的生活习惯千奇百怪。有的软件资源树里塞着几百个名字全部是数字ID的条目有的软件把自定义数据裸放在RT_RCDATA里不带任何封装有的软件一个资源数据块跨越了节区的边界——这些都只有拿真实世界的文件去碰才能提前预习到。等你哪天闭着眼睛能把资源表的结构画出来、随手写出递归解析代码那种感觉看十篇文章都换不来。