PE文件版本资源注入问题排查与工具实现


最近在做一个构建辅助工具:给 PE 文件批量写入版本信息(RT_VERSION),必要时追加图标与随机数据块,并修改 PE 头的时间戳与校验和。按 Windows 文档的描述,这应当是调用 BeginUpdateResource / UpdateResource / EndUpdateResource 三件套的问题。实测下来,其间遇到四个行为问题,共同点是——API 全部返回成功,而结果都是错的。

这个工具经历了四轮草稿迭代才达到可用状态:第一版只写数字版本号的骨架,第二版补齐了字符串结构但语义有误,第三版以固定长度数组实现、字段长度写死导致中文与长值溢出,第四版改用柔性数组加手工 4 字节对齐后达到可用,最后整理为正式工具 pe_resource_injector(源码已开源:GitHub 仓库)——产出两个程序:peres.exe(资源注入器)与 peview.exe(PE 查看器,由 6 个 Python 测试脚本集成为 C 原生程序)。下文先交代这四版各自的问题,再逐个展开实测中遇到的四个 API 行为问题,最后附上 peview 的双路资源视图设计与多模块工程结构。

PE 资源注入工具主界面

1 结论

如果也在做类似的工作,以下四条结论可以直接取用:

  1. UpdateResource 不能给完全没有资源目录的 PE 添加资源,会返回 ERROR 1359。
  2. 不要自建资源目录。字节布局完全合规也没有意义,Windows 可能只识别其中一部分。补一个空目录头,让 UpdateResource 自行构建。
  3. 同一个 UpdateResource 会话里,先写「字符串类型名」资源、再写「字符串名」资源会失败(ERROR_NOT_SUPPORTED)。且两种提交顺序各自只对一半样本有效,必须写完后回读校验,不符合则换顺序重试。
  4. 资源较多时,EndUpdateResource 可能不提交删除请求——输出文件 md5 与输入完全一致。

概括为一句话:正确性必须以 FindResource 回读校验为准,API 返回值不能作为依据。

2 背景知识:版本信息的实际形态

2.1 版本信息存在哪

PE 文件的版本信息不是一个字段,而是 .rsrc 资源节里的一棵 二进制资源树:

资源类型 RT_VERSION (16)
  └─ 资源名 VS_VERSION_INFO (1)
       └─ 语言 ID (如 0x0409)
            └─ 二进制块:VS_VERSIONINFO

因此「改版本信息」本质上是「改资源」,必须使用资源更新 API,不能按字节直接修补 .rsrc。

2.2 VS_VERSIONINFO 的内存布局

MS 官方文档对这一点有明确说明:

“This structure is not a true C-language structure because it contains variable-length members.”
—— VS_VERSIONINFO structure (Microsoft Learn)

也就是说,typedef struct 加 sizeof 无法得到正确长度:它是变长的、带 padding 的。实际布局如下(单位:字节):

Block 偏移 内容
VS_VERSIONINFO 0–1 wLength(整个块的总长,含尾部 padding)
2–3 wValueLength = 52(sizeof(VS_FIXEDFILEINFO))
4–5 wType = 0(Value 是二进制)
6–37 szKey = L"VS_VERSION_INFO\0"(16 WCHAR = 32 字节)
38–39 Padding1(6+32=38,补 2 字节把后面顶到 4 对齐)
40–91 VS_FIXEDFILEINFO(52 字节)
92… Children = StringFileInfo + VarFileInfo(92 已经 4 对齐,Padding2 可省)
StringFileInfo 0–5 6 字节头(wLength / 0 / 1)
6–35 L"StringFileInfo\0"(15 WCHAR = 30 字节)→ 36 已 4 对齐,无 padding
StringTable 0–5 6 字节头
6–23 L"040904B0\0"(9 WCHAR = 18 字节)→ 24 已 4 对齐
String 0–5 6 字节头(wLength / wValueLength(字节数) / 1)
6… Key(含 NUL)
… padding(0 或 2 字节,使 Value 落在 4 的倍数的偏移上)
… Value(含 NUL)
… 尾部 padding(让下一个兄弟 4 对齐,计入本块 wLength)
VarFileInfo 0–5 6 字节头
6–29 L"VarFileInfo\0"(12 WCHAR = 24 字节)= 30
30–31 padding 2 字节(此处必然存在)
Var “Translation” 0–5 6 字节头(wLength / 4 / 0)
6–29 L"Translation\0" = 30
30–31 padding 2 字节
32–35 一个 DWORD = lang(WORD) + codepage(WORD),不是字符串

String 块 padding 的计算规律如下(动态版代码里的 align2 就是在做这件事):

设 key 长度 K、value 长度 V,A = align2(V+1)(一定是偶数),则

oldsize = 6 + 2*(K+1) + 2*A     →  2*A ≡ 0 (mod 4)
oldsize ≡ 2K + 8 ≡ 2K (mod 4)
padding = 0  (K 为偶数)
padding = 2  (K 为奇数)

结论:padding 只取决于 key 长度的奇偶,与 value 长度无关,因为 A 已被 align2 强制为偶数。这一处写错后,资源管理器将不显示任何版本信息。

2.3 三件套 API

HANDLE h = BeginUpdateResource(path, /*bDeleteExistingResources=*/FALSE);
UpdateResource(h, RT_VERSION, MAKEINTRESOURCE(VS_VERSION_INFO), wLang, pData, cbData);
EndUpdateResource(h, FALSE);   /* TRUE = 丢弃全部改动(回滚) */

要点(来自 UpdateResource / BeginUpdateResource):

  • UpdateResource 只是把改动排入内部队列,只有 EndUpdateResource 才真正落盘;
  • bDeleteExistingResources = TRUE 会清空所有资源(图标、对话框、manifest 全部丢失),通常不应使用;
  • lpData=NULL && cb=0 表示删除该资源(Win7 之前若 cb != 0 会抛异常);
  • 目标文件必须可写、且不能正在运行;
  • 含 RC Config 数据的 LN 文件 / .mui 文件有额外限制(只能修改 Version / RC Config / Manifest 三类)。

3 初始实现的四个缺陷

3.1 第一版:只有版本号骨架

VS_VERSIONINFO versionResource;                 /* 未初始化! */
versionResource.TotalSize = sizeof(VS_VERSIONINFO);
versionResource.DataSize  = sizeof(VS_FIXEDFILEINFO);
versionResource.Type      = 1;                  /* 应为 0 */
wcscpy(versionResource.Name, L"VS_VERSION_INFO");
versionResource.FixedFileInfo = versionInfo;    /* Padding1 / Padding2 / Children 全未清零 */

问题:

  1. Padding1 是未初始化的值——它正好位于 szKey 与 VS_FIXEDFILEINFO 之间,会导致解析错位;
  2. wType=1 表示「文本数据」,但此处 Value 是二进制的 VS_FIXEDFILEINFO,应为 0;
  3. Children 完全未填——写入后「详细信息」页里公司名/产品名均为空,只有数字版本号生效;
  4. wLength = sizeof(VS_VERSIONINFO) 把声明中 Padding2 + WORD Children[2](8 字节)也计入,且这些空间是垃圾数据。

3.2 第二版:结构补齐但语义有误

#pragma pack(push,2)      /* 用 2 字节 packing 消除隐式对齐 */
...
wcscpy_s(...)             /* MSVC 安全 CRT,MinGW 默认不可编译 */

问题:

  1. **Translation 被写成了字符串 L"040904B0"**,而规范要求是 DWORD(lang<<16 | codepage)。
    后果:VerQueryValue(L"\\VarFileInfo\\Translation") 取到的是乱码 → 资源管理器无法把语言 ID 映射到 StringTable → 版本信息整体不显示。
  2. Var.wValueLength = wcslen(Value)*sizeof(WCHAR)——混淆了「字节数」与「字符数」。
  3. wcscpy_s 是 MSVC 的 secure CRT,MinGW 下要么编译不过,要么需要 _CRT_SECURE_NO_WARNINGS / MINGW_HAS_SECURE_API。
  4. #pragma pack(push,2) 本身是个偏方:它能消除隐式 padding,但与其他非 pack 结构体混用时偏移会算错,且 sizeof 的结果依赖编译器实现,不推荐。

3.3 第三版:长度写死

typedef struct {
    WORD  wLength; WORD wValueLength; WORD wType;
    WCHAR szKey[12];      /* key 最多 11 字符 */
    WORD  Padding;
    WCHAR Value[12];      /* value 最多 11 字符 */
} String;

String companyName = { sizeof(String), sizeof(companyName.Value), 1,
                       L"CompanyName", 0, L"Protect.EXE" };

问题:

  1. **OriginalFilename(16 字符)、LegalCopyright(14 字符)放不进 szKey[12]**——直接截断甚至越界;
  2. **中文与长值放不进 Value[12]**——"Copyright (C) 2024 XXXXXXX" 明显超长;
  3. wValueLength = sizeof(companyName.Value) 恒等于 24 字节,与实际字符串长度脱钩。填 "Protect.EXE"(11 字符 = 24 字节含 NUL)时恰好正确,换成 "公司名称"(4 字符 = 10 字节)即错,Windows 会多读 7 个 WCHAR 的垃圾;
  4. VS_Children Children[1] 只声明了一个元素,却用 { stringFileInfo, varFileInfo } 两个元素初始化 → 编译期即”excess elements”,且 sizeof(VS_VERSIONINFO) 不含 VarFileInfo,写入的 wLength 偏小;
  5. versionInfo = { ... }; 这种「给已声明变量赋花括号列表」的写法在标准 C 里不合法(MSVC 也会报),应使用复合字面量 (VS_VERSIONINFO){...} 或直接初始化。

3.4 第四版:可用版本

核心思路:

  1. 柔性数组(WCHAR KeyPaddingValue[] / WCHAR Children[])描述变长结构;
  2. 每个块先算 sizeMems = align4(header + key + align2(value)),用 calloc 清零(padding 天然为 0);
  3. 逐层 memcpy 拼接,用 wLength / sizeof(WCHAR) 手工计算偏移;
  4. 最后 UpdateResource(..., versionInfo, versionInfo->wLength) 提交。
int CalculateSizeMems(const WCHAR* k, const WCHAR* v) {
    DWORD bytes = ((lstrlenW(k)+1) + align2((lstrlenW(v)+1))) * sizeof(WCHAR);
    return align4(sizeof(String) + bytes);
}

对 padding 的处理是这一版的关键:

int padBytes = sizeMems - oldsizeMems;                  /* 0 或 2 */
memcpy(pString->KeyPaddingValue, wstrKey, (lstrlenW(k)+1)*2);
memcpy(pString->KeyPaddingValue + (lstrlenW(k)+1) + padBytes/2,
       wstrValue, pString->wValueLength);

这一版仍然遗留四个工程问题(后在正式工具中逐条解决):

  • 所有 calloc 出来的块**均未 free**(一次性 CLI 无影响,做成库或批量调用即为泄漏);
  • stringTable->Children 用 WCHAR[] 手工偏移拼接,8 段 memcpy 的偏移量是手算的,增加一个字段就要修改一串数字,容易出错;
  • 语言键写成小写 L"040904b0"(惯例是大写 040904B0),个别解析器对大小写敏感;
  • 未删除已存在的 RT_VERSION,若原文件是其他语言的版本资源,会出现两份共存。

4 无资源目录的 PE

目标完全没有资源目录时,UpdateResource 会直接失败。这个问题在实现初期即暴露——用一个不含 .rsrc 节的测试程序验证:

BeginUpdateResource OK
UpdateResource 失败,GetLastError=1359 (0x0000054F) ERROR_INTERNAL_ERROR

BeginUpdateResource 成功,UpdateResource 却报内部错误。原因很直接:PE 里不存在资源目录,Windows 无从下手。

此时有两条路:

  • 自建三级资源目录(Type → Name → Language),再追加一个 .rsrc 节;
  • 或者先给它一个「空」的资源目录,使其能被 UpdateResource 接受。

我最初选择了第一条路,随即遇到下一节的问题。

注入前后节表多出一个资源节

5 自建资源目录不可靠

自建的资源目录字节布局完全合规,Windows 却只识别其中一部分。按第一条路自建资源目录、写入新加的 .rsrc 节后,各项检查看起来都正常:

  • objdump 能正常解析;
  • 自写的 Python 解析器能完整枚举出 7 个资源;
  • 三级目录的每一项、每个偏移、每个 NumberOfNamedEntries / NumberOfIdEntries 都对得上;
  • 甚至把目录逐字节 dump 出来人工核对,也未发现问题。

三级资源目录中两个类型不可见

但用 Windows 自身的 API 验证时,结果与预期不符:

/* 实际只枚举出 5 个:RT_BITMAP(2) 和 RT_RCDATA(10) 这两个类型整个消失。
   注意 FindResourceW 的参数顺序是 (hModule, lpName, lpType) —— name 在前。 */
EnumResourceTypesW(hMod, type_cb, 0);
FindResourceW(hMod, MAKEINTRESOURCE(60984), MAKEINTRESOURCE(2));   // NULL
FindResourceW(hMod, L"PRND_51427DA4",       MAKEINTRESOURCE(10));  // NULL

写入 7 个资源,系统只识别 5 个。

(这两个「看不见」的类型,结构示意见上图 PE-IMG-03。修复之后,resdump
用 Windows 自己的 EnumResourceTypesW 到底能不能认出全部 7 个?见第 5 节末尾的 PE-IMG-04。)

规律也不稳定:同样是 [2, 10, 16, 24] 这一组类型 ID——

顶层字符串名类型数量 结果
0 个 4/4 全部可见
1 个 全部可见(865 个资源的样本也全对)
3 个 丢掉 ID 为 2 和 10 的两个

也就是说,一个纯克隆(865 个资源)的大样本完全正常,反而是只有 7 个资源的小样本识别失败。

排查期间尝试过按二分查找推测 Windows 内部的比较逻辑,检查过 SizeOfImage 是否覆盖 .rsrc、数据项偏移是否越界、目录项是否按「字符串在前、ID 升序」排列——全部正常。结论:Windows 资源加载器对目录的校验比文档描述更严格,自建目录在边界情况下不可靠,继续推测其内部规则没有意义。

5.1 解法:只补一个空目录头

既然问题出在「自建完整目录」,就只给 Windows 一个能开工的最小起点——一个空的资源目录头:

/* 16 字节:Characteristics=0, TimeDateStamp=0, Major/Minor=0,
   NumberOfNamedEntries=0, NumberOfIdEntries=0 */
put32(out, 0, 0);  put32(out, 4, 0);
put16(out, 8, 0);  put16(out, 10, 0);
put16(out, 12, 0); put16(out, 14, 0);

实测:只要存在这样一个空目录,UpdateResource 即可正常工作,资源目录完全交给 Windows 构建,「看不见」的问题不再出现。最终实现删去了全部 250 余行自建目录的代码,产物体积同时减小了 8KB。

resdump 枚举出全部七个资源

空资源目录头只有十六字节

6 写入顺序约束

同一个 BeginUpdateResource 会话内,资源的写入顺序会影响成败。改用 UpdateResource 后,865 个资源的大样本一次通过;但更换样本后即失败:

[错误] UpdateResource 失败 type=10 name="PRND_DAC2CFD1" lang=0x0000 size=4799
[错误] UpdateResource 失败,GetLastError=50 (0x00000032) 不支持该请求。

ERROR_NOT_SUPPORTED。缩小范围后确定触发条件:在同一个 BeginUpdateResource 会话里,先写入「字符串类型名」的资源(如 "MUI"),再写入「字符串名」的资源(如 "PRND_xxxx"),就会失败。

于是改为分趟提交,结果两种顺序各自只对一半样本有效:

提交顺序 865 资源样本 notepad 样本(3 个字符串类型名)
先一次性写标准类型 → 再逐个写自定义类型 ✅ 865/865 ❌ 只剩 5/7
先逐个写自定义类型 → 再一次性写标准类型 ❌ ERROR_NOT_SUPPORTED ✅ 7/7

两种顺序都无法覆盖全部样本。

两种提交顺序各只对一半样本有效

6.1 解法:回读校验加换序重试

既然无法预判,就不做预判——写完用 FindResource 逐个复查,识别不全就恢复原始副本、换另一种顺序重试:

for (order = 0; order < 2 && !done; order++) {
    if (order == 1) {
        CopyFileW(tgt, out, FALSE);   /* 恢复原始副本 */
        log_add(L"  [重试] 换一种资源提交顺序…");
    }
    if (!updater_write_all(out, items, nItems, randItems, order, &nAdded))
        break;

    seen = verify_items(out, items, nItems);   /* 逐个 FindResource 复查 */
    if (seen < 0 || seen >= nItems) { done = 1; break; }
}

实际效果(notepad 样本):

  UpdateResource 写入 4 个资源(标准类型)
  UpdateResource 写入 1 个资源(自定义类型)
  UpdateResource 写入 1 个资源(自定义类型)
  UpdateResource 写入 1 个资源(自定义类型)
  [校验] 只有 5/7 个资源能被系统识别,换顺序重试
  [重试] 换一种资源提交顺序…
  ...
[完成] 共 7 项改动 -> e1.patched.exe
   total = 7        ← 这次 7 个全都在

多写一次文件的代价,换来的是不会交付「看似成功、实际缺资源」的产物,这一交换是合理的。

自动检出缺失后换顺序重试

7 删除未被提交

删除请求可能根本没有被提交到文件。工具还有一个「清理上次注入的随机资源」的功能,按 PRND_ 前缀和 60000–60999 的位图 ID 精确删除(删除时必须命中真实的 (type, name, lang) 三元组,猜测语言会让 EndUpdateResource 报 1359)。

在只有 2 个资源的文件上行为正常;但在 867 个资源的文件上:

  清理上次注入的随机资源: 2 项
[完成] 共 0 项改动 -> m2.patched.exe

输出看似正常。对输入输出文件做 md5 对比:

$ md5sum m1.exe        # 输入
3188f10912ee30bc9adfcbd399c725b9
$ md5sum m1.patched.exe # 输出
3188f10912ee30bc9adfcbd399c725b9   ← 完全一致

输出文件与输入一个字节都没有差异。

输入输出 md5 完全一致

每个 UpdateResource(..., NULL, 0) 删除调用都返回成功,计数也如实报了「2 项」,但 EndUpdateResource 未把删除写入。资源数仍是 867,PRND_51427DA4 仍然存在。

7.1 解法:清理完再数一遍

删除可能被吞掉,因此清理之后重新计数,未删干净就如实报告,而不是把「已清理 N 项」当作成功:

if (cb_checked(7)) {
    int left = count_injected(out);
    if (left > 0) {
        wsprintfW(buf, L"  [提示] 仍有 %d 项上次的随机资源没被真正删除"
                       L"(Windows 未提交删除请求,文件未变化)。", left);
        log_add(buf);
    }
}

现在大样本上会如实输出提示,小样本上则能真正删干净、不提示。

8 两个附带问题

8.1 签名复制的局限

签名数据可以搬走,签名有效性搬不走。工具支持把 A 文件的签名复制到 B 文件:读取 Security Directory(数据目录第 4 项),搬到目标文件末尾(8 字节对齐),再把「文件偏移 + 大小」写回数据目录——注意 Security Directory 存的是文件偏移,不是 RVA,这是它与其他数据目录不同之处。

但必须说明:这只能让「签名数据存在」,不能让签名通过校验。 Authenticode 的摘要覆盖除证书表之外的全部文件内容,迁移到另一文件后必然不匹配:

[签名] 从源读取证书表 6016 字节
[签名] 已写入输出文件。WinVerifyTrust=0x80096010 :
       数字摘要不匹配 (TRUST_E_BAD_DIGEST) —— 文件内容与签名不符

要让签名真正有效,只能用 signtool 重新签署。程序在复制后会立即调用 WinVerifyTrust 并原样输出结论。

签名复制后摘要不匹配

8.2 无 CRT 编译的两个坑

程序以 -nostdlib 编译(不链接任何 CRT)。加了 -fno-builtin 之后,GCC 16 在 -O2 下仍会为大结构体赋值生成 memcpy / memset 调用,链接直接失败:

undefined reference to `memcpy'

仅将自有函数改名为 mcopy 不够,还须提供同名的全局实现(**不能写 static**,否则不参与链接):

void *memcpy(void *dst, const void *src, unsigned long long n)
{
    return mcopy(dst, src, (u32)n);
}

void *memset(void *dst, int c, unsigned long long n)
{
    return mfill(dst, c, (u32)n);
}

另外 WINTRUST_ACTION_GENERIC_VERIFY_V2 定义在 softpub.h,若不想多引头文件,可直接内联该 GUID:

guid.Data1 = 0x00AAC56Bu; guid.Data2 = 0xCD44u; guid.Data3 = 0x11D0u;
guid.Data4[0]=0x8Cu; guid.Data4[1]=0xC2u; guid.Data4[2]=0x00u; guid.Data4[3]=0xC0u;
guid.Data4[4]=0x4Fu; guid.Data4[5]=0xC2u; guid.Data4[6]=0x95u; guid.Data4[7]=0xEEu;

最终 peres.exe 导入表 9 个系统 DLL,peview.exe 仅 5 个,均不含任何 msvcrt / api-ms-win-crt:

peres.exe (9):  comdlg32  CRYPT32  GDI32  KERNEL32  ole32
                SHELL32   USER32   VERSION  WINTRUST
peview.exe (5): comdlg32  GDI32    KERNEL32  SHELL32  USER32

导入表仅九个系统动态库

无 CRT 环境下还须自行解决以下事项(在 common/util.c 中均有对应实现,两个程序共享):

问题 处理
没有 main / 启动代码 自行实现 void WINAPI WinMainCRTStartup(void),链接器 --entry 指向它,结尾 ExitProcess(0)
没有 printf / strlen / memcpy 自写 mcopy / mfill / wlen / wcopy / wcat / wcat_u32 / wcat_hex;格式化用 user32 的 wsprintfW
没有 malloc / free 直接用 HeapAlloc / HeapReAlloc / HeapFree(GetProcessHeap(), …)
没有 rand / srand 自写 xorshift64,种子取自 QueryPerformanceCounter + GetTickCount64 + PID + TID
GCC 生成 memcpy/memset 调用 提供同名全局实现(见上)
栈帧 > 4KB 引用 ___chkstk_ms 用内联汇编自行实现(入口 rax = 大小,必须保 rax)
栈保护 cookie / 异常处理表 -fno-stack-protector / -fno-asynchronous-unwind-tables

9 问题清单(24 条,按成因分类)

9.1 布局类

  1. 不要把 VS_VERSIONINFO 当成普通 C 结构体。 它是变长的,sizeof 一定错。文档明示它「不是真的 C 结构体」,SDK 里也没有这个定义。
  2. 两处 4 字节对齐:Value 之前要对齐(Key 长度奇偶决定 0 还是 2 字节),块末尾也要补齐(让下一个兄弟对齐),且**尾部 padding 要计入 wLength**。
  3. wValueLength 的单位。 MSDN 对 String.wValueLength 的描述为 “size, in words“,但 MSVC 生成的真实资源与 version.dll 都按字节处理(含结尾 NUL)。按字节填才能正常显示;按字数填在 Explorer 里会串位。
  4. **wType**:根块(Value 是 VS_FIXEDFILEINFO)与 Var/Translation 用 0;String / StringTable / StringFileInfo 用 1。填反多数场景不报错,但会使某些解析器拒绝。
  5. 必须是 UTF-16LE。 UpdateResource 明确要求 “All data containing strings or text must be in Unicode format”,传 ANSI 会显示为乱码或方块。
  6. 代码页固定 0x04B0(1200 = UTF-16LE)。 换成 0x03A8(936/GBK)之类的代码页,非 ASCII 字符会立即乱码。

9.2 语言 ID 类

  1. 三处必须一致:UpdateResource 的 wLanguage 参数、StringTable.szKey(8 位十六进制,如 040904B0)、VarFileInfo.Translation 的 DWORD 值。三者不一致,资源管理器就找不到对应的字符串表,结果是「属性页一片空白」。
  2. 大小写与位数:用大写、恰好 8 位十六进制(%04X%04X),不要写成 409、040904b0。
  3. 原文件已有其他语言的版本资源时要先删。 RT_VERSION/#1/lang 可多语言共存,Explorer 只取第一个,新加的会「看起来未生效」。正确做法是先 EnumResourceLanguages 枚举出所有语言,逐个 UpdateResource(..., NULL, 0) 删掉,再写入自己的。
  4. MAKEINTRESOURCE(VS_VERSION_INFO) = 1(RT_VERSION = 16)。版本资源 ID 固定为 1,不要自创其他 ID。

9.3 API 行为类

  1. ⚠️ UpdateResource 无法给「完全没有资源目录」的 PE 添加资源(见第 4 节)。实测 GetLastError = 1359 (0x54F) ERROR_INTERNAL_ERROR。必须自行构造资源目录 + 新建一个节(本工具已实现这条路径)。
  2. ⚠️ 删除资源时,(type, name, lang) 必须精确命中一个真实存在的资源。 实测:用「猜测的语言」删除不存在的资源,UpdateResource 当场返回 TRUE,但 EndUpdateResource 才会报 1359。正确做法是三级枚举 EnumResourceTypes → EnumResourceNames → EnumResourceLanguages,取得真实语言再删。
  3. 文件不能正在运行 / 必须可写。 常见错误:ERROR_SHARING_VIOLATION(32)、ERROR_ACCESS_DENIED(5)、ERROR_USER_MAPPED_FILE(1174)。系统目录、Program Files 下需要管理员权限。
  4. bDeleteExistingResources=TRUE 会清空全部资源(图标、对话框、manifest、字符串表等),文件基本报废,等同于不可逆操作。
  5. 数字签名必然失效。 修改资源会改变 .rsrc,Authenticode 签名校验失败;文件哈希也一定变化,EDR / 白名单 / Defender 缓存会重新评估。
  6. PE CheckSum 不会自动重算。 EndUpdateResource 不更新 OptionalHeader.CheckSum。普通 EXE 无影响,但驱动与强制校验场景必须重算(Imagehlp!CheckSumMappedFile)或置 0(表示「不校验」)。
  7. 资源管理器有缓存。 修改后「属性→详细信息」可能仍显示旧值,需重命名文件、重启 explorer 或更换目录才能刷新。
  8. **wLength 是 WORD**,单个版本块不能超过 65535 字节(正常够用,但字段填超长文本时需留意)。

9.4 编译 / 可移植性类

  1. wcscpy_s / wcsncpy_s 是 MSVC 安全 CRT,MinGW 默认没有,跨编译器不要使用。
  2. **wmain 需要 -municode**(MinGW)或 /SUBSYSTEM:CONSOLE 配 Unicode 入口;否则 argv 是 ANSI 的,中文路径直接乱码。
  3. #pragma pack(push,2) 能消除隐式对齐但很脆弱:与其他非 pack 结构体混用(如来自系统头文件的 VS_FIXEDFILEINFO)时偏移会算错。
  4. srand(time(NULL)) 同一秒内种子相同,批量处理会得到完全相同的「随机」版本号。
  5. 固定长度 WCHAR szKey[12] 无法承载中文与长字段,应使用动态分配。
  6. 柔性数组 + calloc 比 sizeof 硬算安全得多:calloc 顺带把 padding 清零。

10 工具实现

源码仓库:https://github.com/yanghaoi/pe_resource_injector(MIT 许可,MinGW-W64 下执行 build.bat 一键编译)。

10.1 功能概览

一个 纯 C + 原生 Win32 API、不链接任何 CRT 的 GUI 程序,为任意 PE 添加/替换随机资源与元数据,并支持 PE ↔ PE 克隆(资源 / 元数据 / 数字签名):

能力 说明
PE → PE 资源克隆 指定一个「源 PE(模板)」,把它的资源整批复制到目标 PE,按 7 个类别分别勾选
PE 元数据克隆 复制 TimeDateStamp / CheckSum / 链接器版本号
查看数字签名 CryptQueryObject 解出 PKCS#7,枚举整条证书链(使用者 / 颁发者 / 序列号 / 有效期 / SHA1),再用 WinVerifyTrust 给出可信结论
复制数字签名 把源 PE 的 Security Directory(证书表)搬到目标文件末尾并重定位数据目录
克隆时仍可随机/手改 克隆只是「预填」,随机添加与手工修改照常可用,克隆的版本信息可被手动/随机值覆盖
RT_VERSION 版本信息 8 个标准字段,可手填或一键随机生成;自动删除旧的多语言版本资源
RT_RCDATA 随机数据块 随机资源名(前缀 PRND_)、512B–64KB 随机内容
RT_BITMAP 随机位图 随机尺寸的 32bpp BGRA 噪声位图(资源格式:BITMAPINFOHEADER + 像素,无文件头)
自定义类型资源 随机类型名 + 随机资源名
自建(空)资源目录 目标没有 .rsrc 节时,先补一个只含空目录头的 .rsrc 节,之后交给 UpdateResource 构建(见第 4、5 节)
PE 元数据 随机化 COFF TimeDateStamp、把 OptionalHeader.CheckSum 置 0
幂等清理 按 PRND_ 前缀 / 60000–60999 位图 ID 精确删除上次注入的资源(绕开条目 12)
读回校验 写完用 GetFileVersionInfoSize + VerQueryValue 验证
安全输出 默认另存 <原名>.patched.exe;就地修改则自动备份 .bak
拖拽 / 命令行 支持拖文件进窗口;也支持 peres.exe <PE> [--inplace] [--on=...] [--off=...] 批量调用

10.2 核心调用流程

WinMainCRTStartup
 └─ do_inject(filePath)
     ├─ pe_read_header ──────────── 解析目标 PE 头(Machine/节表/资源目录/签名目录)
     ├─ LoadLibraryEx(源, DATAFILE) + EnumResourceTypes×Names×Languages
     │    └─ 收集源资源条目(按 7 个类别过滤)
     ├─ CopyFile 另存副本 / 备份 .bak
     ├─ build_version_block ─────── BUF 动态缓冲 + buf_u16/raw/wstr/align4/patch16
     │    └─ 两遍式构建 VS_VERSIONINFO(先占位后回填 wLength,杜绝手算偏移)
     ├─ 目标无资源目录 → pe_append_resource_section 补 16 字节空目录头 + 新 .rsrc 节
     ├─ updater_write_all ───────── 分趟提交(标准类型一批 + 字符串类型名逐个)
     │    ├─ 先 EnumResourceLanguages 枚举旧 RT_VERSION 真实语言再删(绕条目 12)
     │    └─ 失败即 EndUpdateResource(TRUE) 回滚
     ├─ verify_items ────────────── 逐个 FindResource 复查,识别不全 → 恢复副本换顺序重试
     ├─ pe_patch_u32/u16 ────────── TimeDateStamp / CheckSum / 链接器版本
     ├─ GetFileVersionInfo 读回校验版本资源
     └─ pe_read/write_cert_table ── 签名复制(必须最后做:证书表位于文件末尾)

10.3 实测结果

以下结果均用 Windows 自身的资源 API(EnumResourceTypes / FindResource)逐个复核,而非只看写入是否报错:

场景 结果
865 个资源的 MUI 文件 → 无资源目录的 exe 865/865 全部可被系统枚举到,输出文件仍可执行
865 克隆 + 随机资源 867/867,随机 BITMAP 与 RCDATA 均可 FindResource 命中
notepad(含 3 个字符串名类型)→ 无资源目录的 exe 7/7,首次 5/7 被自动检出并换顺序重试后成功
32 位 PE(SysWOW64 notepad,机器 0x14C) 7/7,同样走自动重试
内嵌签名的 exe(6016 字节证书表) 证书链 3 级、有效期与 SHA1 均正确解析,WinVerifyTrust = 0(有效受信任)
把签名复制到另一个文件 证书表 6016 字节完整写入,WinVerifyTrust = 0x80096010(摘要不匹配,符合预期)
元数据克隆 TimeDateStamp / CheckSum / 链接器版本三项均从源复制成功

10.4 使用

build.bat                              :: 编译两个目标到 release\
release\peres.exe                      :: 启动 GUI
release\peres.exe app.exe              :: 命令行,另存为 app.patched.exe
release\peres.exe app.exe --inplace    :: 就地修改,自动备份 app.exe.bak
release\peres.exe app.exe --on=3,4     :: 额外开启自定义类型资源 + 随机语言
release\peres.exe app.exe --src=tpl.exe :: 从 tpl.exe 克隆资源与元数据
release\peres.exe app.exe --src=tpl.exe --nocopy=5 --copy=6   :: 只克隆元数据
release\peres.exe app.exe --src=tpl.exe --copysig             :: 连同数字签名一起复制
release\peres.exe app.exe --showsig    :: 只看签名信息
release\peres.exe --help               :: 查看全部选项

复制类别:0 版本信息 · 1 图标 · 2 清单 · 3 界面资源(对话框/菜单/字符串/加速键)
· 4 位图/光标/字体/RCDATA 等 · 5 自定义(非标准)类型 · 6 PE 元数据。

10.5 build.bat 的编码与行尾约束

build.bat 里带中文注释、又用了 ^ 续行符时,实测会撞上两个 cmd 解析问题:

  1. 编码:若以 UTF-8 保存,cmd 默认按 GBK(936) 逐行解析,中文 rem 行会把后续内容「断行」,出现 'ibc锛?rem' 不是内部或外部命令 一类报错。含中文注释的 .bat 必须存成 GBK/ANSI 编码。
  2. 行尾:LF-only 行尾时,cmd 对 ^ 续行的解析会错位(peres.exe 被截成 es.exe 之类)。**.bat 必须是 CRLF 行尾**。

最终形态直接绕开了编码这一层:注释全部改成英文,同时去掉 ^ 续行。纯 ASCII 的 .bat 在 GBK 与 UTF-8 下完全等价,第一个问题自然消失;行尾则交给仓库里的 .gitattributes 兜底,防止别人在 Linux/macOS 上 clone 之后把 .bat 转成 LF:

*.bat text eol=crlf

工具链不做任何探测,脚本里直接调用 PATH 上的 gcc——编译环境由使用者自行准备。

10.6 peview 查看器移植到 C

开发过程中曾用 6 个 Python 脚本做验证(dump_pe.py / list_res.py / enum_res.py / dir_dump.py / scan_sig.py / peinfo.py)。最终将这些功能集成为一个 C + Win32 API + 无 CRT 的原生 GUI 程序 peview.exe,复用 common/util.c 与 common/buffer.c 基建,不再依赖 Python 运行时。

按钮 对应原脚本 功能
PE 头信息 dump_pe.py MZ/PE 签名、machine、节数、16 个数据目录、节表
资源列表 list_res.py 自写解析器遍历 .rsrc 三级目录,列出 type/name/lang/size
API枚举资源 enum_res.py 用 Windows EnumResourceTypesW 枚举,结果最权威
目录树 dir_dump.py dump .rsrc 三级目录树结构,显示偏移/名称/ID/DIR/DATA
签名扫描 scan_sig.py 输入文件→签名详情;输入目录→扫描该目录签名 PE;无输入→扫描系统目录

10.6.1 双路资源视图的设计动机

「资源列表」与「API枚举资源」是一对同题异解的对照,都用来列出 PE 内的资源,但走两条完全不同的路径:

维度 资源列表(自解析) API枚举资源(系统)
解析方式 自写解析器,按 PE 规范直接读 .rsrc 节字节遍历三级目录 LoadLibraryExW(LOAD_LIBRARY_AS_DATAFILE) 加载后 EnumResourceTypesW / EnumResourceNamesW / EnumResourceLanguagesW 三层回调
是否加载模块 否,纯文件 I/O 是,需 LoadLibraryEx
对损坏/非常规 PE 只要 .rsrc 节在就能解析 LoadLibrary 可能失败
能看到的资源 文件字节里真实存在的一切,包括 Windows 不识别的「野」条目 只列 Windows 认识的条目,系统会过滤/归一化
结果权威性 反映文件原始字节布局 与系统认知一致,是权威结果
能否枚举自身 可以解析任意文件,包括正在运行的 exe 不能加载正在运行的自己(共享段冲突)

一句话:资源列表看的是「文件里写了什么」,API 枚举看的是「Windows 认为它有什么」。两者并列,正是为了暴露这层差异——当 Windows 对某个资源条目做了归一化、隐藏或拒绝识别时(正是第 5 节的现象),对比两份输出就能立刻定位。这正是前文第 5 节「自建资源目录不可靠」的验证手段:资源列表显示 7 个,API 枚举只显示 5 个,差异即暴露了 Windows 的过滤行为。

10.6.2 签名扫描的三路智能判断

签名扫描按钮根据输入框内容自动选择行为:

  • 空输入 → 扫描系统目录(System32\drivers / System32 / Program Files / Program Files (x86) / Windows),列出有数字签名的 PE 文件
  • 文件路径 → 显示该文件的签名详情(证书表偏移、大小、PKCS#7 头字段)
  • 目录路径 → 扫描该目录下的签名 PE(上限 500 个)

判断用 GetFileAttributesW:返回 INVALID_FILE_ATTRIBUTES 则路径不存在;含 FILE_ATTRIBUTE_DIRECTORY 标志则是目录,否则是文件。

10.7 多模块工程结构

peres.c 原为 2512 行 / 94 函数的单文件,已拆分为多模块 GitHub 项目。两个程序共享无 CRT 基建(common/),各自专属源码独立存放,生成物统一输出到 release/:

pe_resource_injector/
├── common/              共享基建
│   ├── peres.h          公共头文件:类型、宏、extern 声明
│   ├── util.c           无 CRT 基建:内存/字符串/对齐/随机数
│   └── buffer.c         动态字节缓冲区 BUF
├── peres/               PE 资源注入器源码(10 个 .c)
│   ├── version.c        VS_VERSIONINFO 构建 + 随机版本信息
│   ├── pe.c             PE 头解析/修补/追加节/证书表
│   ├── log.c            日志输出
│   ├── rescol.c         从源 PE 枚举收集资源
│   ├── sign.c           数字签名验证与显示
│   ├── updater.c        UpdateResource 写入/校验/清理
│   ├── inject.c         do_inject 主注入流程
│   ├── ui.c             GUI 窗口/布局/控件
│   ├── cli.c            命令行解析
│   └── main.c           peres 入口 + ___chkstk_ms
├── peview/              PE 查看器源码(6 个 .c/.h)
│   ├── peview.h         peview 公共头文件
│   ├── peview_pe.c      PE 头/资源列表/目录树解析
│   ├── peview_enum.c    Windows API 资源枚举
│   ├── peview_sigscan.c 签名扫描
│   ├── peview_ui.c      peview GUI
│   └── peview_main.c    peview 入口
├── release/             生成物
│   ├── peres.exe        PE 资源注入器 (51 KB)
│   └── peview.exe       PE 查看器 (25 KB)
├── build.bat            构建脚本(-Icommon,输出到 release/)
├── LICENSE
└── README.md

build.bat 一次编译两个目标,共享 CFLAGS / LFLAGS / LIBS,仅源文件列表与输出路径不同。-Icommon 让两个程序的 #include "peres.h" 都能找到共享头文件。

11 工程化建议

  1. 两遍式构建器:先「虚拟布局」算长度(不写数据),再按算好的偏移回填;或用可增长 buffer + 记录 wLength 的偏移位置,子节点写完后 patch 回去(本工具即此做法)。
  2. 统一封装:buf_u16 / buf_u32 / buf_wstr / buf_align4 / buf_patch16,杜绝手算偏移。
  3. 先读后写:用 GetFileVersionInfoSize + GetFileVersionInfo + VerQueryValue(L"\\VarFileInfo\\Translation") 读出原语言,再决定用哪个 lang 覆盖,避免多语言共存。
  4. 写回自检:写完立即用 GetFileVersionInfoSize 验证一次,读不到即报警。
  5. 默认另存副本,就地修改前强制备份 .bak。
  6. 释放内存,不要将一次性脚本的习惯引入库代码。
  7. 成对提供「注入」和「清理」,用固定前缀(如 PRND_)标记自己加的资源,便于幂等重跑。

12 相关工具与资料

本项目

  • pe_resource_injector —— 本文所述工具的开源实现:纯 C + 原生 Win32 API,不链接任何 CRT

官方文档

同类 / 可对照的工具

工具 说明
Resource Hacker(Angus Johnson) 最知名的资源查看/编辑 GUI,做法是自建资源目录,因此能处理「无 .rsrc」的 PE
rcedit(Electron 生态) 命令行改 exe 图标/版本/执行级别,Go 版自建资源目录,Rust 版 rcedit crate
CFF Explorer / PE-bear PE 结构查看,能直观看到 DataDirectory[2] 与节表
dumpbin /headers dumpbin /resources VS 自带,objdump -x 在 MinGW 下等价
mt.exe / signtool.exe 微软官方:manifest 处理与(重)签名
editbin.exe 修改子系统、校验和等 PE 字段
pefile (Python) / LIEF 脚本化读写 PE,LIEF 支持重建资源目录

可继续扩展的方向

  • 随机化 / 规范化 OptionalHeader.MajorLinkerVersion、Rich 头、Debug Directory;
  • 追加或替换图标(RT_GROUP_ICON + RT_ICON,需构造 GRPICONDIR);
  • 注入 / 替换 SxS manifest(RT_MANIFEST,ID 1/2/3,注意 UAC 与 DPI 段);
  • 修改后调用 CheckSumMappedFile 重算校验和,或接 signtool 重新签名;
  • 批量模式 + JSON 配置,接入 CI 做构建产物 branding;
  • peview 增加导出表 / 导入表解析、Rich 头解码、节熵值计算,向 PE-bear / CFF Explorer 看齐。

13 合规边界

这类「改 PE 元数据」的能力是标准的双刃剑:构建流水线 branding、产物去标识化、资源解析器健壮性测试都是正当用途;用于伪装成他人软件、规避签名/哈希检测则不合适。工程上建议:

  • 只对自己有权修改的文件操作;
  • 明确告知「会使原数字签名失效」;
  • 生成的文件名/公司名使用中性随机值,不冒充真实厂商(本工具的内部名/原始文件名用随机十六进制生成,即出于此考虑)。

14 附录 A:代码修正记录

(2026-09-30)对 peres.c 做了 6 处健壮性修正,均不改变功能:

# 位置 问题 修正
1 pe_parse 边界检查只保证读到 opt+68,PE32+ 文件的 DataDirectory 起始在 opt+112,dd+36(Security Directory)实际读到 opt+148——头部异常的 PE32+ 文件会读到清零缓冲之外的未定义区域 按 optMagic 动态计算并校验 opt + ddOff + 40 <= size
2 rnd_range hi=0xFFFFFFFF, lo=0 时 span = hi-lo+1 溢出为 0,% 0 触发除零异常;且 rnd32() % span 存在取模偏差 span==0 走满域分支;改为拒绝采样(lim = 0xFFFFFFFF - 0xFFFFFFFF % span)
3 randomize_vinfo 手动生成 fvMS/fvLS/pvMS/pvLS 是死代码——do_inject 每次都从 fileVer 字符串重新解析覆盖;且该 DWORD 与版本字符串是两次独立随机,本就不对应 删除,并注释说明 DWORD 一律由 fileVer 解析得出
4 do_inject 空目录 halloc(64) 分配 64 字节只用 16 字节,还需 hfree 管理所有权 改为 16 字节栈数组,消除堆分配
5 build_version_block buf_u16(&b, 52) 魔法数字,与 sizeof(VS_FIXEDFILEINFO) 重复维护 改用 sizeof(VS_FIXEDFILEINFO)
6 do_inject 版本号解析 手填超长版本串时 *t*10+digit 无溢出防护 加溢出钳制(上限 429496729,封顶为 0xFFFFFFFF)

回归验证:gcc -O2 零警告编译;对真实样本执行全流程注入,读回校验 \StringFileInfo\040904B0\CompanyName 正确;peinfo 复核输出 PE 的 .rsrc 节与资源目录结构正常。

15 附录 B 多模块化与 peview 集成

(2026-10-01)两部分改动:把单文件的 peres.c 拆成多模块工程,并把 6 个 Python 验证脚本集成为 C 原生程序。

15.1 peres.c 拆分

peres.c(2512 行 / 94 函数)拆分为 common/(3 文件)+ peres/(10 文件)+ peview/(6 文件),-Wall -Wextra 零警告编译通过。拆分原则:

  • 共享基建独立:util.c(内存/字符串/随机数)、buffer.c(BUF 动态缓冲区)、peres.h(类型/宏/extern)归入 common/,两个程序 -Icommon 共享
  • 按职责切分:PE 解析(pe.c)、版本信息构建(version.c)、签名(sign.c)、资源收集(rescol.c)、UpdateResource 封装(updater.c)、主流程(inject.c)、GUI(ui.c)、命令行(cli.c)、入口(main.c)各司其职
  • 消除多重定义:memcpy/memset 全局实现只在 util.c 中保留一份;RT_NAMES 等数组各编译单元各自维护 static 副本

15.2 peview 集成

6 个 Python 脚本的功能逐一移植为 C:

Python 脚本 C 实现 关键差异
dump_pe.py(pefile 库) peview_pe.c: peview_dump_header 不依赖 pefile,直接按 PE 规范读字节
list_res.py(pefile 遍历) peview_pe.c: peview_list_resources 自写 walk_res 递归遍历三级目录
enum_res.py(LoadLibraryEx) peview_enum.c: peview_enum_resources 直接用 Win32 API,三层回调
dir_dump.py(字节 dump) peview_pe.c: peview_dump_dir_tree 三级展开,显示 DIR/DATA 标志
scan_sig.py(遍历系统目录) peview_sigscan.c 三路判断:文件/目录/系统目录
peinfo.py(综合) peview GUI 整合 拖拽 + 按钮 + 结果区

复用 common/util.c 时需注意:memcpy/memset 全局实现已在 util.c 中,peview_main.c 不能再定义(会多重链接报错);peview_enum.c 中 RT_NAMES 数组不能引用 peview_pe.c 的 static 定义,需各自维护一份。

最终 peview.exe 25 KB,导入表仅 5 个系统 DLL(comdlg32/GDI32/KERNEL32/SHELL32/USER32),无任何 CRT。

16 总结

这轮实践中最主要的收获,是对一条原则的反复验证:

API 返回成功 ≠ 事情真的做成了。

四个问题没有一个是通过返回值发现的——全部依靠「写完之后再用另一个 API 读一遍」才暴露。因此本工具中的 verify_items()(逐个 FindResource)与 count_injected()(清理后重新计数)是必要开销,而非可选优化。

另一个副产品经验:当两种方案各自只对一半样本有效时,不要再向下推测规律,直接做「校验 + 回退重试」。为猜测 Windows 内部实现花费的数小时,是本轮实践中唯一真正的时间损失。

第三个收获来自 peview 的双路资源视图:把同题异解的两条路径并列保留,比择优保留一条更有价值。资源列表(自解析)与 API 枚举(系统)各自单独看都不完整——前者看不到 Windows 的归一化,后者看不到文件的原始字节——但两份输出并列对比,恰恰是第 5 节「自建资源目录不可靠」最直接的验证手段。若为「精简」而合并成一路,这层观察能力就消失了。

希望这些记录对同样处理 PE 资源的读者有所帮助。

17 封面图片来源

本文封面使用「鲸鱼娘 DeepSeek」同人形象二次创作:角色原作 上善无形(Bilibili),DeepSeek 元素女仆版二次设计 ZipZipPipe(Bilibili),素材取自同人整理站 蓝色大肥鱼档案馆。原作与二次设计均依 CC BY-NC-SA 4.0 授权,本文封面为非商业使用,并遵循相同协议。



文章作者: YangHao
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 YangHao !
评论
 本篇
PE文件版本资源注入问题排查与工具实现 PE文件版本资源注入问题排查与工具实现
给 PE 文件批量写入版本信息,按文档描述应是调用三个资源 API 的问题;实测遇到四个行为问题,共同点是 API 返回成功而结果错误。本文给出完整的排查过程、VS_VERSIONINFO 的内存布局、24 条问题清单、一个无 CRT 的工程实现,以及将 6 个 Python 验证脚本集成为 C 原生 PE 查看器(peview.exe)的过程——含双路资源视图设计动机与多模块工程结构。
2026-10-01
下一篇 
加固APK的AndroidManifest.xml修复方法总结 加固APK的AndroidManifest.xml修复方法总结
加固APK的AndroidManifest.xml修复方法总结分析加固后的 Android 应用时,解包得到的 AndroidManifest.xml 经常无法解码或显示为乱码,导致包名、组件声明等关键信息无法获取。本文以 X加密加固的银行
2026-08-31
  目录