HN 日本語サマリー

← 一覧へ戻る
セキュリティ

9ビットAESキーの事例

The case of the 9 bit AES key (v12.sh)

14 pointsby ledoge2 コメント

要約

複数のPDF実装において、AES-256ファイルキーが列挙可能なほど小さい状態空間から生成されていることが判明しました。MuPDFでは、ランダム状態のわずか9ビットのみが実際のAES-256キーに影響を与えます。Foxitなどの他のPDFエディタは、時間ベースのランダム性に基づいてAES-256キーを生成するため、容易に復元可能なキーにつながります。これらの脆弱性により、パスワードなしでのPDFの復号が可能になります。

全文翻訳

The case of the 9 bit AES key

We found that several PDF implementations generate their AES-256 file keys from state spaces small enough to enumerate. In MuPDF, only 9 bits of random state affect the actual AES-256 key. Other PDF editors like Foxit base their AES-256 key on time based randomness and thus also lead to keys that are trivial to recover.

[画像: MuPDF AES-256 key recovery worksheets on a desk]

Introduction

PDF encryption protects the strings and datastreams in a PDF document, including metadata unless it is explicitly left unencrypted. The password protection mechanism has gone through a bunch of revisions over the years, moving from RC4 to AES and hardening password hashing against brute force attacks. The current version, revision 6, uses AES-256.

When a PDF is encrypted with AES-256, the password does not encrypt the document directly. The implementation generates a random 32-byte file key, which encrypts the content. The password and a salt are then hashed to derive a separate key, which encrypts this random 32-byte file key. Revision 6 makes this hashing deliberately slow; the older AES-256 revision 5 does not.

A PDF can have a user password for viewing and an owner password for changing permissions. Either unlocks the same file key, so the restrictions are up to the reader: with the viewing password, you can also make an unrestricted copy.

We found that this randomness is often generated very insecurely, allowing passwordless decryption of protected PDFs.

MuPDF

MuPDF versions before 1.28.5 use an implementation of the drand48 family to generate their random numbers. This generator is seeded once, using the current epoch in seconds:

static void fz_init_random_context(fz_context *ctx){
  /* ... */
  fz_srand48(ctx, (uint32_t)time(NULL));
}

The generator is a 48-bit linear congruential generator:

sn+1=(𝟶𝟶𝟻𝚍𝚎𝚎𝚌𝚎𝟼𝟶𝟶𝚍sn+𝟶𝟶𝟶xb)mod248

fz_srand48() writes the state as three 16-bit words. The low word is the constant 0x330e, and the 32-bit timestamp occupies state bits 16 to 47:

void fz_srand48(fz_context *ctx, int32_t seed){
  uint16_t p[3] = { 0x330e, seed, seed>>16 };
  fz_seed48(ctx, p);
}

When MuPDF needs to generate a key, it calls fz_memrnd(), which will repeatedly take a random number, step the random number generator forward, and then use the lowest byte of this random number as a random byte.

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);

The shift discards state bits 0 to 16, and the cast to unsigned char discards everything above bit 24. Every output byte therefore comes from bits 17 to 24 of the LCG state.

The issue is that the higher bits of the state never affect the lower bits. Multiplication and addition can carry bits upward, but not downward. Since each output byte comes from bits 17 to 24, anything above bit 24 can be ignored, even after stepping the generator forward.

That leaves 25 bits that can affect the output. The bottom 16 are always initialized to 0x330e, so only nine bits from the timestamp matter. Timestamps that differ by 512 seconds produce exactly the same byte stream.

Since the possible keyspace is so small, we can just brute-force this by generating all possible 32-byte keys and trial decrypting:

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 uses the same generator for the document /ID, which is stored in the trailer in plaintext. When writing a new document, it will first draw 32 bytes from the same LCG and then immediately sample the decryption key after, so /ID can be used as an additional check.

Searching all 512 streams in a very unoptimized python implementation using pure brute force takes about 11 milliseconds. On a test document:

time(NULL) mod 512 = 80
/ID[0] = 7628523958da31d6c21c90e39344b28e
/ID[1] = 8be910bddaa1f1a542e5a27ceb115346
key    = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212

That key decrypts the document without either password.

Foxit

Foxit PDF Reader 2026.1.3.36551 derives the file key as a hash of the current timestamp, 32 uninitialized bytes, and a constant. In practice these uninitialized bytes are zero filled and do not add any randomness.

SHA-256(uint32_le(time(NULL)) || <32 uninitialized bytes> || "there")

Or as an equivalent python snippet:

from hashlib import sha256
key = sha256(timestamp.to_bytes(4, "little") + bytes(32) + b"there").digest()

The /Perms permissions block contains known plaintext, so the candidate check is one hash and one AES block decryption, with no password hashing and no document decryption:

let mut material = [0u8; 41];
material[..4].copy_from_slice(&timestamp.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)

Our Rust implementation searches timestamps downward from the current time, using Rayon for multithreading. On a Ryzen 9 7950X with 32 threads it sustains 370 to 376 million timestamps per second:

Target timestamp
Ten years ago
Unix epoch

The second row covers every timestamp from 1970 to the present. In practice, for a document that was relatively recently generated, this will be in the order of milliseconds.

timestamp = 1787583328
key       = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
/Perms    = fcfbffffffffffff54616462f67f0000   (decrypted)
Where did this key derivation come from

This exact pattern popped up in a couple different PDF editing suites, so we were curious where they got it from. It was already present in PDFium’s initial public commit from May 2014. The file header credits Foxit for the original code, so this code appears to have come from Foxit. Other editors may have picked it up through 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);
    /* ... */

The 32-byte middle value is m_EncryptKey, which is the uninitialized contents of the key buffer being hashed into itself. Assuming these bytes are zero filled, this is exactly the key derivation Foxit still uses today.

OpenPDF and iText

OpenPDF 3.0.5 and iText 9.7.1 both route cryptographic randomness through an IVGenerator class, an ARC4 stream seeded with a millisecond timestamp and the JVM’s free memory:

arcfour = new ARCFOUREncryption();
long time = System.currentTimeMillis();
long mem = Runtime.getRuntime().freeMemory();
String s = time + "+" + mem;
arcfour.prepareARCFOURKey(s.getBytes());

The same stream generates the file key and 32 bytes of salts stored in plaintext in /U and /O. We can check guesses for the timestamp and free memory against these salts, then recover the key from the adjacent bytes.

While there is significantly more variance here than in the MuPDF and Foxit examples, this once again shows that the promise of 256 bit security is undermined by insecure randomness sources and lower-than-advertised state space.

Conclusion

In each of these implementations, the file key is generated from a non-cryptographically secure source of randomness. What was supposed to be a security upgrade over older file keys derived from the user’s password instead led to weaker keys in practice. These issues were disclosed to the vendors and, in MuPDF’s case, fixed. Previously published copies cannot be retroactively protected, however, so these bugs may still have an impact.

We published our decryptors for MuPDF and Foxit.