首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
0
1
[原创] PE 资源逆向:特征码组缺失问题定位分析
发表于: 2026-6-17 18:50
1181
[原创] PE 资源逆向:特征码组缺失问题定位分析
云净天鉴
2026-6-17 18:50
1181
### 引子 绑定工具报错了。不是崩溃,是校验失败——具体一点说,是某个内嵌资源的特征码组不完整。工具在某个资源文件里只找到了 11 个特征码块,而正常情况应该是 18 个。 这个 exe 是已经编译好的,内嵌了多个需要绑定的二进制资源文件。每个资源文件在编译时都会生成一组完整的特征码——18 个块,选择器从 0 到 17 各出现一次。绑定工具的工作就是找到这 18 个块,在对应的位置写入指定的信息。 但现在的问题是:特征码不完整。绑定工具找不到全部 18 个块,自然无法写入。 问题很明确:目标 exe 里的某个内嵌资源数据有问题。但具体是哪一个?它的数据是怎么变成这样的?如果直接去翻那堆编译脚本,大概率找不到头绪——因为特征码的生成和写入是两个独立的阶段,编译阶段生成,绑定阶段写入。现在写入时报错,嫌疑就落在编译阶段生成的数据上。 于是决定从目标 exe 本身入手,逆向看看到底是哪里的特征码出了问题。从 PE 到资源,从资源到具体的数据块,一步步收窄范围。 ### 一、PE 资源结构速览 在动手之前,先简单过一下 PE 资源的组织方式。这块是基础,后面所有操作都依赖它。 PE 文件的资源存在 `.rsrc` 节里,结构是一个三层目录树: ``` 根目录(按类型) └── 类型节点(比如 BINARY) └── 名称节点(资源 ID 或名称) └── 语言节点(语言代码页) └── 实际数据 ``` 每一层的目录结构都一样: ```c typedef struct _IMAGE_RESOURCE_DIRECTORY { DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; WORD NumberOfNamedEntries; WORD NumberOfIdEntries; } IMAGE_RESOURCE_DIRECTORY; ``` 目录头后面跟着目录项数组,每个目录项指向下一层或直接指向数据: ```c typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY { union { struct { DWORD NameOffset : 31; DWORD NameIsString : 1; }; DWORD Name; WORD Id; }; union { DWORD OffsetToData; struct { DWORD OffsetToDirectory : 31; DWORD DataIsDirectory : 1; }; }; } IMAGE_RESOURCE_DIRECTORY_ENTRY; ``` `DataIsDirectory` 为 1 时指向下一层目录,为 0 时指向数据。走到第三层后,目录项指向一个数据条目: ```c typedef struct _IMAGE_RESOURCE_DATA_ENTRY { DWORD OffsetToData; // RVA DWORD Size; DWORD CodePage; DWORD Reserved; } IMAGE_RESOURCE_DATA_ENTRY; ``` 注意 `OffsetToData` 是 RVA(相对虚拟地址),不是文件偏移。要读取实际数据,需要把 RVA 转成文件偏移。转换需要借助节表: ```c 文件偏移 = RVA - 节.VirtualAddress + 节.PointerToRawData ``` 这个转换公式在后面写解析工具时会反复用到。 **本文涉及两个资源类型:** | ID | 名称 | 用途 | |----|------|------| | 10 | `RT_RCDATA` | 任意二进制数据,本文主角 | | 6 | `RT_STRING` | 字符串表,扩展部分会提到 | ### 二、从 PE 定位到资源 #### 第一步:写一个最简解析器 现成的资源查看器能看到资源列表,也能导出数据,但有两个问题: 第一,`RT_RCDATA` 对查看器来说就是一堆二进制,不会解析内部结构。第二,我需要批量统计每个资源的特征码数量,手动操作太慢。 自己写一个,只依赖 C++ 标准库,跨平台,目标就两个: - 遍历所有 `RT_RCDATA` 资源,提取数据 - 统计每个资源里特征码出现的次数 **解析 PE 头** ```cpp struct PEFile { std::vector<uint8_t> data; std::vector<IMAGE_SECTION_HEADER> sections; uint32_t resource_rva; }; std::optional<PEFile> LoadPEFile(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); if (!file) return std::nullopt; auto size = file.tellg(); file.seekg(0); std::vector<uint8_t> data(size); file.read(reinterpret_cast<char*>(data.data()), size); auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(data.data()); if (dos->e_magic != 0x5A4D) return std::nullopt; auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(data.data() + dos->e_lfanew); if (nt->Signature != 0x00004550) return std::nullopt; uint32_t resource_rva = nt->OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].VirtualAddress; std::vector<IMAGE_SECTION_HEADER> sections; auto* section_base = reinterpret_cast<const IMAGE_SECTION_HEADER*>( data.data() + dos->e_lfanew + offsetof(IMAGE_NT_HEADERS, OptionalHeader) + nt->FileHeader.SizeOfOptionalHeader); for (uint16_t i = 0; i < nt->FileHeader.NumberOfSections; ++i) { sections.push_back(section_base[i]); } return PEFile{std::move(data), std::move(sections), resource_rva}; } ``` **RVA 转文件偏移** ```cpp std::optional<uint32_t> RvaToOffset(const PEFile& pe, uint32_t rva) { for (const auto& section : pe.sections) { if (rva >= section.VirtualAddress && rva < section.VirtualAddress + section.SizeOfRawData) { return rva - section.VirtualAddress + section.PointerToRawData; } } return std::nullopt; } ``` **遍历资源目录** 核心是一个递归函数,处理每一层目录的条目数组: ```cpp void TraverseResourceDirectory( const PEFile& pe, const IMAGE_RESOURCE_DIRECTORY* dir, std::function<void(const std::vector<uint32_t>&, const IMAGE_RESOURCE_DATA_ENTRY*)> callback, std::vector<uint32_t>& path) { auto* entries = reinterpret_cast<const IMAGE_RESOURCE_DIRECTORY_ENTRY*>( reinterpret_cast<const uint8_t*>(dir) + sizeof(IMAGE_RESOURCE_DIRECTORY)); uint32_t total = dir->NumberOfNamedEntries + dir->NumberOfIdEntries; for (uint32_t i = 0; i < total; ++i) { const auto& entry = entries[i]; path.push_back(entry.Id); // 简化处理,只处理 ID 资源 if (entry.DataIsDirectory) { auto offset = RvaToOffset(pe, entry.OffsetToDirectory); if (offset) { auto* subdir = reinterpret_cast<const IMAGE_RESOURCE_DIRECTORY*>( pe.data.data() + *offset); TraverseResourceDirectory(pe, subdir, callback, path); } } else { auto offset = RvaToOffset(pe, entry.OffsetToData); if (offset) { auto* data_entry = reinterpret_cast<const IMAGE_RESOURCE_DATA_ENTRY*>( pe.data.data() + *offset); callback(path, data_entry); } } path.pop_back(); } } ``` **扫描特征码** 特征码是 15 个固定字节,出现在每个数据块的 2~16 字节位置,第 1 字节是选择器: ```cpp constexpr uint8_t kPattern[15] = { 0x78, 0xc2, 0x01, 0x13, 0x94, 0x4d, 0x86, 0x6b, 0x1D, 0x3d, 0x5d, 0x47, 0xb7, 0x04, 0xa7 }; struct Block { uint8_t selector; uint32_t offset; }; std::vector<Block> ScanBlocks(std::span<const uint8_t> data) { std::vector<Block> blocks; if (data.size() < 16) return blocks; for (size_t i = 1; i + 15 <= data.size(); ++i) { if (memcmp(data.data() + i, kPattern, 15) == 0) { uint8_t selector = data[i - 1]; if (selector <= 17) blocks.push_back({selector, i - 1}); } } return blocks; } ``` #### 第二步:跑一遍 把解析器分别跑在旧版本和新版本上,打印每个资源的 ID、大小、特征码块数量。 旧版本输出(所有内嵌资源特征码完整): ``` Resource #195: 18 blocks Resource #196: 18 blocks Resource #276: 18 blocks ... 所有资源均为 18 blocks ``` 新版本输出: ``` Resource #195: 18 blocks Resource #196: 18 blocks Resource #321: 11 blocks ← 异常 Resource #322: 18 blocks Resource #323: 11 blocks ← 异常 ``` 定位到两个异常资源:`#321` 和 `#323`。各自只有 11 个块,正常应该是 18 个。 ### 三、从资源定位到具体资源文件 #### 分析异常资源的数据 把 `#321` 的数据单独提取出来,列出每个块的偏移和选择器: ``` selector 4 at offset 0x00 selector 5 at offset 0x10 selector 6 at offset 0x20 selector 7 at offset 0x30 selector 11 at offset 0x40 selector 14 at offset 0x50 selector 16 at offset 0x60 selector 0 at offset 0x70 selector 1 at offset 0x80 selector 2 at offset 0x90 selector 3 at offset 0xA0 ``` 已存在的 11 个选择器:4,5,6,7,11,14,16,0,1,2,3。 缺失的 7 个:8,9,10,12,13,15,17。 同样的分析套到 `#323` 上,结果完全一样——同样的 11 个选择器存在,同样的 7 个缺失。 #### 交叉核对资源配置 查一下这两个 ID 对应什么: ``` #321 → 模块 A(某平台的无授权版本) #323 → 模块 B(同一模块的另一个平台版本) ``` 这两个是同一个模块的两个平台版本。异常模式完全一致,说明是同一个问题源。 再看旧版本:这两个资源文件也存在,但没有任何特征码块。为什么?因为它们属于"无授权版本",编译时本来就跳过特征码生成。 新版本的问题就清楚了:编译阶段错误地对这两个"无授权"模块执行了特征码生成,但生成逻辑跑到一半退出了——11 个块,刚好半截。 #### 校验函数 顺手写一个校验函数,用来判断一组特征码是否完整: ```cpp struct Analysis { bool is_valid; int count; std::vector<int> missing; std::vector<int> duplicates; }; Analysis AnalyzeBlocks(const std::vector<Block>& blocks) { bool seen[18] = {false}; std::vector<int> duplicates; for (const auto& block : blocks) { if (seen[block.selector]) duplicates.push_back(block.selector); seen[block.selector] = true; } std::vector<int> missing; for (int i = 0; i < 18; ++i) { if (!seen[i]) missing.push_back(i); } return { .is_valid = missing.empty() && duplicates.empty(), .count = static_cast<int>(blocks.size()), .missing = std::move(missing), .duplicates = std::move(duplicates) }; } ``` 把这个函数加到遍历流程里,每次扫描后自动输出校验结果,省得手动数。 ### 四、扩展:解析其他资源类型 本次分析主要依赖 `RT_RCDATA`,但解析器顺手实现了 `RT_STRING` 的解析,简单说两句。 字符串表(`RT_STRING`)按 16 个一组存放。资源 ID `n` 对应第 `n/16` 组的第 `n%16` 个字符串: ```c typedef struct { WORD Length; // 0 表示空字符串 WCHAR NameString[1]; // UTF-16,不以 \0 结尾 } IMAGE_RESOURCE_DIR_STRING_U; ``` 解析时从资源数据里顺序读取每个 `Length + NameString`,按组索引和组内偏移定位到对应的字符串。 对话框模板(`RT_DIALOG`)的解析稍微复杂一点,先判断是标准格式还是扩展格式(扩展格式前两个字节为 `01 00 FF FF`),然后按对应的结构解析对话框头、菜单、窗口类、标题、字体,最后是控件列表。这块代码比较长,不贴了。 ### 五、几点体会 **特征码是定位问题的线索。** 在这次排查里,特征码是固定不变的。正因为不变,才能通过统计它的出现次数来判断数据是否完整。如果特征码是动态生成的,或者根本没有特征码,那排查路径会完全不同。 **工具跟着问题长出来。** 一开始只是想看资源列表,后来想看资源大小,再后来想看每个资源里的特征码分布,最后想自动校验。每一步都是在已经有的代码上加点东西,不是预先设计好的。这种写法虽然不够优雅,但很实用——问题需要什么,你就加什么。 **对比是最直接的手段。** 新旧版本一对比,差异一眼就看到了。没有旧版本做参照,这个问题可能要翻很久。保留旧版本的构建产物,有时候比保留代码还能省事。 **这次遇到的是编译缺陷,不是逆向问题。** 特征码本身没问题,工具也没问题,问题在于编译阶段生成的数据不完整。逆向分析只是手段,真正要修的是编译配置。 ### 六、最后说一句 解析器的核心代码已经附在文章里了。这个思路不限于绑定的场景,只要是需要分析 PE 资源内部结构的任务,都可以拿来改改用。代码本身很简单,核心就是三层目录的递归遍历加上特征码的逐字节扫描,自己动手写一遍花不了多少时间。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
0
点赞
・
1
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
期待更多优质内容的分享,论坛有你更精彩!
2026-7-16 05:25
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
云净天鉴
15
发帖
4
回帖
10
RANK
关注
私信
他的文章
[原创] 内容可寻址记忆:从注意力机制看AI的记忆困境
1475
[原创] AI 在计算什么?——函数、λ 演算与丘奇-图灵论题
1384
[原创] PE 资源逆向:特征码组缺失问题定位分析
1181
[原创] AI的底层是什么?——从机械步骤到图灵机
1336
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部