首页
社区
课程
招聘
[原创] PE 资源逆向:特征码组缺失问题定位分析
发表于: 4小时前 38

[原创] 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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回