导语:WerEnc.dll 是 Windows 10 1607+ 和 Windows 11 自带的”Windows 错误报告转储编码库”,系统 32 目录下、微软签名、有两个导出函数 EncryptDumpFile(序号 1)和 EncryptDumpStream(序号 2),做 AES-256 + RSA-4096 的混合加密。0xsp 研究员 Mr.Z 写了一个 PoC,把 WerEnc 内部嵌入的 RSA 公钥在内存中替换成攻击者自己生成的 4096-bit 密钥,从此 WerEnc 加密的产物密钥完全由攻击者掌控——密文格式和正常 WER 工件在表面上看不出区别,目前没有公开的检测规则能拦住加载这个 DLL。
引言
每台 Windows 10 1607+ 和 Windows 11 机器的 System32 下都自带 WerEnc.dll——全称”Windows Error Reporting Dump Encoding Library”。微软签名,导出两个函数:EncryptDumpFile(序号 1)和 EncryptDumpStream(序号 2)。

这两个函数对进程崩溃转储做 AES-256 加密,session key 用 RSA 包装到内嵌的 4096-bit 公钥上。WerFaultSecure.exe 在 dump PPL 进程时会加载 WerEnc.dll(感谢 @TwoSevenOneT 提供这条线索)。
过去几年,恶意软件和红队都习惯用 advapi32!SystemFunction033——也就是 RtlEncryptMemory 的 RC4 模式——给载荷加密。EDR 现在已经把这套标记了。WerEnc 提供的是正经的 CNG(下一代加密 API)AES-256 + RSA-4096,产物长得跟合法加密的 WER 工件一模一样,目前没有公开检测规则能识别对 WerEnc.dll 的加载。
研究过程中最难的部分是:WerEnc 把 session key 包装到了微软内嵌的公钥上,意思是说输出 blob 只有微软能解开。但这不是终点——我们做了 BYOK(Bring Your Own Key,自带密钥)攻击,把内存里的密钥直接换掉。
WerEnc.dll 内部细节
找到调用约定
网上查不到、问 Opus 4.6 / GLM 5.3 也查不到这两个导出的 API 签名。AI 在某些上下文里说”它是 WerFaultSecure.exe 的依赖,用来加密崩溃转储后再上传微软”——到这里就断线了。
因为不知道确切签名,我们写了个探针(research/WerEnc/werenc_probe.c),在向量化异常处理(vectored exception handler)的保护下枚举每一种可能的调用签名。setjmp 检查点捕获非法访问违规,打一条消息,继续尝试下一个猜测。
探测 EncryptDumpFile 时,传 LPCWSTR 文件路径字符串没用,直接返回错误 0x80070006(无效句柄)。这个函数压根不检查输入是不是合法路径,直接把参数当文件句柄。所以正确用法是先 CreateFileW 打开两个文件,把 HANDLE 传过去。
另一边,探测 EncryptDumpStream 时,传裸内存 buffer 会直接崩——函数期望的是 COM 接口指针,会调它们的虚表。从 prologue 反汇编确认它吃三个参数不是两个:(IStream, IStream, PVOID reserved)。要做内存里加密,先要用 CreateStreamOnHGlobal 把 buffer 包成 stream,再调用。
替换密钥
WerEnc.dll 的导入表很少,CNG 调用只有 BCryptGenRandom、BCryptSetProperty、BCryptImportKeyPair、BCryptEncrypt——这是一套标准的混合加密流程:生成密钥、设置加密模式、导入公钥、加密数据。既然它导入了一个公钥,这个密钥必然嵌在 DLL 自己的某个位置。
用进程调试器跟函数调用链,就能定位到要找的”魔法密钥”:

基于这个事实,在映射到内存的 DLL 镜像里扫 RSA1 魔法数(0x31415352),找到唯一一处匹配,落在 RVA 0x5610。这是个标准 CNG 公钥 blob:
BCRYPT_RSAKEY_BLOB (public), 539 bytes total:
Magic: RSA1 (0x31415352)
BitLength: 4096
cbPublicExp: 3 bytes
cbModulus: 512 bytes
Exponent: 01 00 01 (65537)
Modulus: 512 bytes (Microsoft's)
替换能这么直白有两个原因:
- 在
BCryptImportKeyPair上打一个断点,触发了一次加密调用。断点命中时,blob 指针参数直接指向 WerEnc.dll 的.rdata段 RVA 0x5610,不在堆里、也不在栈副本上。意思是 WerEnc 函数直接从它自己的映射镜像里读密钥——如果我们在调用前把那 539 字节改掉,BCryptImportKeyPair导入的就是我们的密钥而不是微软的。 - blob 格式固定 24 字节头 + 3 字节 exponent + 512 字节 modulus。CNG 用
BCryptGenerateKeyPair生成新的 4096-bit RSA 密钥对时,导出的RSAPUBLICBLOB用的也是 3 字节编码的 65537(01 00 01)加 512 字节 modulus。我们的密钥和微软的密钥产出的 blob 维度完全一致。不需要调尺寸、改字段,直接memcpy539 字节就是完整的替换。
/* 扫描 RSA1 blob 的代码 */
for (off = 0; off + sizeof(BCRYPT_RSAKEY_BLOB) < imageSize; off++) {
if (*(ULONG*)(base + off) == 0x31415352) {
hdr = (BCRYPT_RSAKEY_BLOB*)(base + off);
if (hdr->BitLength == 4096 && hdr->cbPublicExp == 3
&& hdr->cbModulus == 512)
blob = base + off;
}
}
VirtualProtect(blob, 539, PAGE_READWRITE, &oldProtect);
memcpy(blob, ourPublicBlob, 539);
VirtualProtect(blob, 539, oldProtect, &oldProtect);
/* 现在调用 EncryptDumpFile:session key 包到我们的公钥上 */
EncryptDumpFile(hIn, hOut);
替换完成后,WerEnc 后续产出的每一个工件的密钥材料都包到了我们的公钥上。
容器格式
测试时我们加密了一个 21 字节的小文件,看看产物大小。结果输出是 1,659 字节,布局如下:64 (header) + 539 (key blob) + 512 (RSA wrap 1) + 512 (RSA wrap 2) + 32 (AES content) = 1659:
| 偏移 | 大小 | 内容 |
|---|---|---|
| 0x000 | 16 | 格式常量 A(所有工件相同) |
| 0x010 | 16 | 格式常量 B(所有工件相同) |
| 0x020 | 4 | version = 2 |
| 0x024 | 4 | 公钥偏移 = 0x40 |
| 0x028 | 4 | 原始明文大小(仅文件变种) |
| 0x02C | 4 | reserved |
| 0x030 | 4 | 公钥 blob 大小 = 539 |
| 0x034 | 4 | RSA 块大小 = 512 |
| 0x038 | 4 | AES 密钥大小 = 32 |
| 0x03C | 4 | reserved |
| 0x040 | 539 | RSA1 blob(替换后是我们的) |
| 0x25B | 512 | RSA 包装的 AES-256 session key |
| 0x45B | 512 | RSA 包装的内容 IV |
| 0x65B | N | AES-256-CBC 密文(对齐到 16 字节边界) |
头部这六条 DWORD 在 0x20 起始的位置很好解:version、offset、原始大小、blob 大小、RSA 块大小、AES 密钥大小,每个值跟实际文件布局都能交叉验证。
0x45B 处的第二个 512 字节块一开始不明显。我们尝试了大约 60 种 key/IV/mode 组合直接当 AES 解,全是噪声。但它恰好 512 字节(RSA-4096 一个块),用我们的私钥 RSA 一解,出来 16 字节——就是内容 IV。
这个结论由下文讲的 IAT hook 抓 CNG 调用链进一步坐实。
CNG IAT hooks
分析容器结构只帮我们走到一半。第二块 RSA 到底存的是什么、加密时具体 CNG 参数是哪些,这两个问题还得现场抓。
我们对 WerEnc.dll 的导入地址表(IAT)做 hook——把目标 BCrypt 函数的 IAT 条目重定向到轻量日志包装器,记录每次传给 CNG API 的参数、标志位、buffer,然后再转发给真实的实现。发布的 PoC 配合 --capture 标志就能启用:
static NTSTATUS WINAPI Hook_BCryptGenRandom(void *hAlg, BYTE *buf,
ULONG cb, ULONG flags)
{
NTSTATUS st = g_realGenRand(hAlg, buf, cb, flags);
if (g_cap && st == 0) CapHex("[GenRandom]", buf, cb);
return st;
}
再次加密、用这些 hook 抓日志后,完整流水线就出来了:
BCryptGenRandom(32) -> session key K
BCryptGenRandom(16) -> content IV
BCryptSetProperty(CBC) -> 把 AES 设成 CBC 模式
BCryptImportKeyPair(blob) -> RSA handle(blob = 我们的,+0x5610)
BCryptEncrypt(K, 32) -> K 的 512 字节 RSA 包装
BCryptEncrypt(IV, 16) -> IV 的 512 字节 RSA 包装
BCryptEncrypt(content) -> AES-256-CBC 密文
WerEnc.dll 每次加密产出两个随机值——32 字节 key + 16 字节 IV,各自单独 RSA 包装,然后用 AES-CBC 加密内容。两个 RSA 块都进容器。
不过在解密阶段(测了多个文件变种、不同输入大小)我们观察到:代码路径不同,RSA padding 模式也不同——EncryptDumpFile 走 BCRYPT_PAD_PKCS1,但 EncryptDumpStream 用的是 OAEP-SHA256。配上对应替换 blob 的私钥,完整解密流程是:
container -> 抽出 wrap1(0x25B 处 512 字节)
-> RSA 解密(依次试 PKCS#1 / OAEP-SHA256 / OAEP-SHA1)
-> session key K(32 字节)
-> 抽出 wrap2(0x45B 处 512 字节)
-> RSA 解密(同上 padding 序列)
-> content IV(16 字节)
-> 抽出密文(0x65B 之后)
-> AES-256-CBC 解密,key=K、iv=IV
-> 按头部 0x28 字段裁回原始长度
-> 明文
把它做成实战 stager
基于上面的发现,我们做了一个能跑的工具,能做这些事:
解密预加密载荷
载荷以 .byok.enc 容器形式到达,操作员用 werenc_byok.exe --encrypt 预先加密。stager 识别 WerEnc v2 头部(0 偏移处 32 字节常量),跳过加密、直接解密执行。
内存加密载荷
当载荷以明文形式到达(没预先加密),stager 可以先用 WerEnc.dll 的 DumpFileStream 在内存里加密,再解密执行。这对那些没法预先加密的载荷很有用——动态生成、从另一阶段拉过来之类的。如果载荷本身是已经加密好的,PoC 的头部检测会跳过加密步骤直接进入解密模式:

Beacon 模式
这是个扩展功能,用来演示从受密码保护的 C2 服务器拉取加密密钥的能力。攻击链:
- 操作员先跑一个 key server(
werenc_keyserver.py),通过 HTTP 提供 BYOK 密钥文件,以单长度前缀 blob 形式传输。 - beacon 首次 check-in 拉取 blob,把两个密钥缓存到内存里,BYOK-patch WerEnc.dll 一次,然后进入 sleep 循环。
- 两个线程并行跑:第一个线程独立运行 shellcode/载荷;第二个线程(Wrapping 线程)管加密/sleep/解密节奏。

PoC 代码和使用说明在 GitHub 仓库可取。
关于作者
Mr.Z,0xsp 安全研究与发展实验室(SRD)创始人,致力于攻击性安全研究和开源项目维护。X 账号 @zux0x3a。
附注
- 出处:0xsp — Turning WerEnc.dll LOLBIN into an attacker-controlled encryption primitive (BYOK)
- PoC 与代码:github.com/0xsp-SRD/0xsp.com/tree/main/Research/WerEnc
- 同领域参考:SystemFunction032/033、Phantom DLL Hijacking、in0finite/EncryptedDllLoader














暂无评论内容