最近在做一个构建辅助工具:给 PE 文件批量写入版本信息(RT_VERSION),必要时追加图标与随机数据块,并修改 PE 头的时间戳与校验和。按 Windows 文档的描述,这应当是调用 BeginUpdateResource / UpdateResource / EndUpdateResource 三件套的问题。实测下来,其间遇到四个行为问题,共同点是——API 全部返回成功,而结果都是错的。
这个工具经历了四轮草稿迭代才达到可用状态:第一版只写数字版本号的骨架,第二版补齐了字符串结构但语义有误,第三版以固定长度数组实现、字段长度写死导致中文与长值溢出,第四版改用柔性数组加手工 4 字节对齐后达到可用,最后整理为正式工具 pe_resource_injector(源码已开源:GitHub 仓库)——产出两个程序:peres.exe(资源注入器)与 peview.exe(PE 查看器,由 6 个 Python 测试脚本集成为 C 原生程序)。下文先交代这四版各自的问题,再逐个展开实测中遇到的四个 API 行为问题,最后附上 peview 的双路资源视图设计与多模块工程结构。
1 结论
如果也在做类似的工作,以下四条结论可以直接取用:
UpdateResource不能给完全没有资源目录的 PE 添加资源,会返回ERROR 1359。- 不要自建资源目录。字节布局完全合规也没有意义,Windows 可能只识别其中一部分。补一个空目录头,让
UpdateResource自行构建。 - 同一个
UpdateResource会话里,先写「字符串类型名」资源、再写「字符串名」资源会失败(ERROR_NOT_SUPPORTED)。且两种提交顺序各自只对一半样本有效,必须写完后回读校验,不符合则换顺序重试。 - 资源较多时,
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 全未清零 */
问题:
Padding1是未初始化的值——它正好位于szKey与VS_FIXEDFILEINFO之间,会导致解析错位;wType=1表示「文本数据」,但此处 Value 是二进制的VS_FIXEDFILEINFO,应为0;Children完全未填——写入后「详细信息」页里公司名/产品名均为空,只有数字版本号生效;wLength = sizeof(VS_VERSIONINFO)把声明中Padding2 + WORD Children[2](8 字节)也计入,且这些空间是垃圾数据。
3.2 第二版:结构补齐但语义有误
#pragma pack(push,2) /* 用 2 字节 packing 消除隐式对齐 */
...
wcscpy_s(...) /* MSVC 安全 CRT,MinGW 默认不可编译 */
问题:
- **
Translation被写成了字符串L"040904B0"**,而规范要求是DWORD(lang<<16 | codepage)。
后果:VerQueryValue(L"\\VarFileInfo\\Translation")取到的是乱码 → 资源管理器无法把语言 ID 映射到 StringTable → 版本信息整体不显示。 Var.wValueLength = wcslen(Value)*sizeof(WCHAR)——混淆了「字节数」与「字符数」。wcscpy_s是 MSVC 的 secure CRT,MinGW 下要么编译不过,要么需要_CRT_SECURE_NO_WARNINGS/MINGW_HAS_SECURE_API。#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" };
问题:
- **
OriginalFilename(16 字符)、LegalCopyright(14 字符)放不进szKey[12]**——直接截断甚至越界; - **中文与长值放不进
Value[12]**——"Copyright (C) 2024 XXXXXXX"明显超长; wValueLength = sizeof(companyName.Value)恒等于 24 字节,与实际字符串长度脱钩。填"Protect.EXE"(11 字符 = 24 字节含 NUL)时恰好正确,换成"公司名称"(4 字符 = 10 字节)即错,Windows 会多读 7 个 WCHAR 的垃圾;VS_Children Children[1]只声明了一个元素,却用{ stringFileInfo, varFileInfo }两个元素初始化 → 编译期即”excess elements”,且sizeof(VS_VERSIONINFO)不含VarFileInfo,写入的wLength偏小;versionInfo = { ... };这种「给已声明变量赋花括号列表」的写法在标准 C 里不合法(MSVC 也会报),应使用复合字面量(VS_VERSIONINFO){...}或直接初始化。
3.4 第四版:可用版本
核心思路:
- 柔性数组(
WCHAR KeyPaddingValue[]/WCHAR Children[])描述变长结构; - 每个块先算
sizeMems = align4(header + key + align2(value)),用calloc清零(padding 天然为 0); - 逐层
memcpy拼接,用wLength / sizeof(WCHAR)手工计算偏移; - 最后
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。


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 ← 完全一致
输出文件与输入一个字节都没有差异。

每个 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 布局类
- 不要把
VS_VERSIONINFO当成普通 C 结构体。 它是变长的,sizeof一定错。文档明示它「不是真的 C 结构体」,SDK 里也没有这个定义。 - 两处 4 字节对齐:Value 之前要对齐(Key 长度奇偶决定 0 还是 2 字节),块末尾也要补齐(让下一个兄弟对齐),且**尾部 padding 要计入
wLength**。 wValueLength的单位。 MSDN 对String.wValueLength的描述为 “size, in words“,但 MSVC 生成的真实资源与version.dll都按字节处理(含结尾 NUL)。按字节填才能正常显示;按字数填在 Explorer 里会串位。- **
wType**:根块(Value 是VS_FIXEDFILEINFO)与Var/Translation用 0;String/StringTable/StringFileInfo用 1。填反多数场景不报错,但会使某些解析器拒绝。 - 必须是 UTF-16LE。
UpdateResource明确要求 “All data containing strings or text must be in Unicode format”,传 ANSI 会显示为乱码或方块。 - 代码页固定
0x04B0(1200 = UTF-16LE)。 换成0x03A8(936/GBK)之类的代码页,非 ASCII 字符会立即乱码。
9.2 语言 ID 类
- 三处必须一致:
UpdateResource的wLanguage参数、StringTable.szKey(8 位十六进制,如040904B0)、VarFileInfo.Translation的 DWORD 值。三者不一致,资源管理器就找不到对应的字符串表,结果是「属性页一片空白」。 - 大小写与位数:用大写、恰好 8 位十六进制(
%04X%04X),不要写成409、040904b0。 - 原文件已有其他语言的版本资源时要先删。
RT_VERSION/#1/lang可多语言共存,Explorer 只取第一个,新加的会「看起来未生效」。正确做法是先EnumResourceLanguages枚举出所有语言,逐个UpdateResource(..., NULL, 0)删掉,再写入自己的。 MAKEINTRESOURCE(VS_VERSION_INFO)= 1(RT_VERSION= 16)。版本资源 ID 固定为 1,不要自创其他 ID。
9.3 API 行为类
- ⚠️
UpdateResource无法给「完全没有资源目录」的 PE 添加资源(见第 4 节)。实测GetLastError = 1359 (0x54F) ERROR_INTERNAL_ERROR。必须自行构造资源目录 + 新建一个节(本工具已实现这条路径)。 - ⚠️ 删除资源时,
(type, name, lang)必须精确命中一个真实存在的资源。 实测:用「猜测的语言」删除不存在的资源,UpdateResource当场返回 TRUE,但EndUpdateResource才会报 1359。正确做法是三级枚举EnumResourceTypes→EnumResourceNames→EnumResourceLanguages,取得真实语言再删。 - 文件不能正在运行 / 必须可写。 常见错误:
ERROR_SHARING_VIOLATION(32)、ERROR_ACCESS_DENIED(5)、ERROR_USER_MAPPED_FILE(1174)。系统目录、Program Files 下需要管理员权限。 bDeleteExistingResources=TRUE会清空全部资源(图标、对话框、manifest、字符串表等),文件基本报废,等同于不可逆操作。- 数字签名必然失效。 修改资源会改变
.rsrc,Authenticode 签名校验失败;文件哈希也一定变化,EDR / 白名单 / Defender 缓存会重新评估。 - PE
CheckSum不会自动重算。EndUpdateResource不更新OptionalHeader.CheckSum。普通 EXE 无影响,但驱动与强制校验场景必须重算(Imagehlp!CheckSumMappedFile)或置 0(表示「不校验」)。 - 资源管理器有缓存。 修改后「属性→详细信息」可能仍显示旧值,需重命名文件、重启 explorer 或更换目录才能刷新。
- **
wLength是WORD**,单个版本块不能超过 65535 字节(正常够用,但字段填超长文本时需留意)。
9.4 编译 / 可移植性类
wcscpy_s/wcsncpy_s是 MSVC 安全 CRT,MinGW 默认没有,跨编译器不要使用。- **
wmain需要-municode**(MinGW)或/SUBSYSTEM:CONSOLE配 Unicode 入口;否则argv是 ANSI 的,中文路径直接乱码。 #pragma pack(push,2)能消除隐式对齐但很脆弱:与其他非 pack 结构体混用(如来自系统头文件的VS_FIXEDFILEINFO)时偏移会算错。srand(time(NULL))同一秒内种子相同,批量处理会得到完全相同的「随机」版本号。- 固定长度
WCHAR szKey[12]无法承载中文与长字段,应使用动态分配。 - 柔性数组 +
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 解析问题:
- 编码:若以 UTF-8 保存,cmd 默认按 GBK(936) 逐行解析,中文 rem 行会把后续内容「断行」,出现
'ibc锛?rem' 不是内部或外部命令一类报错。含中文注释的 .bat 必须存成 GBK/ANSI 编码。 - 行尾: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 工程化建议
- 两遍式构建器:先「虚拟布局」算长度(不写数据),再按算好的偏移回填;或用可增长 buffer + 记录
wLength的偏移位置,子节点写完后patch回去(本工具即此做法)。 - 统一封装:
buf_u16 / buf_u32 / buf_wstr / buf_align4 / buf_patch16,杜绝手算偏移。 - 先读后写:用
GetFileVersionInfoSize+GetFileVersionInfo+VerQueryValue(L"\\VarFileInfo\\Translation")读出原语言,再决定用哪个 lang 覆盖,避免多语言共存。 - 写回自检:写完立即用
GetFileVersionInfoSize验证一次,读不到即报警。 - 默认另存副本,就地修改前强制备份
.bak。 - 释放内存,不要将一次性脚本的习惯引入库代码。
- 成对提供「注入」和「清理」,用固定前缀(如
PRND_)标记自己加的资源,便于幂等重跑。
12 相关工具与资料
本项目
- pe_resource_injector —— 本文所述工具的开源实现:纯 C + 原生 Win32 API,不链接任何 CRT
官方文档
- VS_VERSIONINFO structure
- VERSIONINFO resource
- StringFileInfo / StringTable / String / VarFileInfo / Var
- BeginUpdateResourceW · UpdateResourceW · EndUpdateResourceW
- VerQueryValueW · VS_FIXEDFILEINFO
同类 / 可对照的工具
| 工具 | 说明 |
|---|---|
| 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 授权,本文封面为非商业使用,并遵循相同协议。