-
-
[原创] PE 资源逆向:特征码组缺失问题定位分析
-
发表于: 4小时前 38
-
引子
绑定工具报错了。不是崩溃,是校验失败——具体一点说,是某个内嵌资源的特征码组不完整。工具在某个资源文件里只找到了 11 个特征码块,而正常情况应该是 18 个。
这个 exe 是已经编译好的,内嵌了多个需要绑定的二进制资源文件。每个资源文件在编译时都会生成一组完整的特征码——18 个块,选择器从 0 到 17 各出现一次。绑定工具的工作就是找到这 18 个块,在对应的位置写入指定的信息。
但现在的问题是:特征码不完整。绑定工具找不到全部 18 个块,自然无法写入。
问题很明确:目标 exe 里的某个内嵌资源数据有问题。但具体是哪一个?它的数据是怎么变成这样的?如果直接去翻那堆编译脚本,大概率找不到头绪——因为特征码的生成和写入是两个独立的阶段,编译阶段生成,绑定阶段写入。现在写入时报错,嫌疑就落在编译阶段生成的数据上。
于是决定从目标 exe 本身入手,逆向看看到底是哪里的特征码出了问题。从 PE 到资源,从资源到具体的数据块,一步步收窄范围。
一、PE 资源结构速览
在动手之前,先简单过一下 PE 资源的组织方式。这块是基础,后面所有操作都依赖它。
PE 文件的资源存在 .rsrc 节里,结构是一个三层目录树:
根目录(按类型)
└── 类型节点(比如 BINARY)
└── 名称节点(资源 ID 或名称)
└── 语言节点(语言代码页)
└── 实际数据
每一层的目录结构都一样:
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
WORD NumberOfNamedEntries;
WORD NumberOfIdEntries;
} IMAGE_RESOURCE_DIRECTORY;
目录头后面跟着目录项数组,每个目录项指向下一层或直接指向数据:
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 时指向数据。走到第三层后,目录项指向一个数据条目:
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; // RVA
DWORD Size;
DWORD CodePage;
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY;
注意 OffsetToData 是 RVA(相对虚拟地址),不是文件偏移。要读取实际数据,需要把 RVA 转成文件偏移。转换需要借助节表:
文件偏移 = RVA - 节.VirtualAddress + 节.PointerToRawData
这个转换公式在后面写解析工具时会反复用到。
本文涉及两个资源类型:
| ID | 名称 | 用途 |
|---|---|---|
| 10 | RT_RCDATA |
任意二进制数据,本文主角 |
| 6 | RT_STRING |
字符串表,扩展部分会提到 |
二、从 PE 定位到资源
第一步:写一个最简解析器
现成的资源查看器能看到资源列表,也能导出数据,但有两个问题:
第一,RT_RCDATA 对查看器来说就是一堆二进制,不会解析内部结构。第二,我需要批量统计每个资源的特征码数量,手动操作太慢。
自己写一个,只依赖 C++ 标准库,跨平台,目标就两个:
- 遍历所有
RT_RCDATA资源,提取数据 - 统计每个资源里特征码出现的次数
解析 PE 头
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 转文件偏移
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;
}
遍历资源目录
核心是一个递归函数,处理每一层目录的条目数组:
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 字节是选择器:
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 个块,刚好半截。
校验函数
顺手写一个校验函数,用来判断一组特征码是否完整:
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 个字符串:
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内核攻防全技术栈,打造具备自动化能力的内核开发高手。