DEV Community

Cover image for MuPDF's AES-256 key had 9 bits of entropy. I reproduced it in 8.7 milliseconds.
frank chu
frank chu

Posted on

MuPDF's AES-256 key had 9 bits of entropy. I reproduced it in 8.7 milliseconds.

A PDF encrypted with AES-256 is supposed to have a 256-bit file key. In MuPDF before 1.28.5, that key had 9 bits of entropy. Not 256. Nine. I reproduced the whole thing in Python, byte for byte, and the brute force took under 9 milliseconds.

The finding comes from V12, published this week. They looked at how several PDF implementations generate the random 32-byte file key that actually encrypts the document, and found the randomness is broken in different ways in each one. MuPDF's is the most elegant failure, so I started there.

MuPDF: a 48-bit generator with a 9-bit output

MuPDF seeded its drand48 generator once, with the current time in seconds:

fz_srand48(ctx, (uint32_t)time(NULL));
Enter fullscreen mode Exit fullscreen mode

drand48 is a 48-bit linear congruential generator. The seed function writes the state as three 16-bit words: the low word is the constant 0x330e, and the 32-bit timestamp occupies bits 16 to 47. Then fz_memrnd() draws each random byte from bits 17 to 24 of the state — the shift discards bits 0 to 16, the cast to unsigned char discards everything above bit 24.

Here is the part that makes it 9 bits: in an LCG, multiplication and addition carry bits upward, never downward. So anything above bit 24 can never influence bits 17 to 24. Of the 25 bits that could matter, the bottom 16 are always 0x330e. That leaves exactly 9 bits from the timestamp. Timestamps that differ by 512 seconds produce exactly the same byte stream.

I reimplemented the generator in Python, straight from the blog's code:

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)
Enter fullscreen mode Exit fullscreen mode

MuPDF draws 32 bytes for the document /ID (stored in plaintext in the trailer) and then immediately draws the 32-byte file key. So the key is stream(seed, 64)[32:64], and there are only 512 possible values. I checked all three claims:

512-apart identical: True | 1-apart identical: False
distinct 32B keys in 512 candidates: 512
stream(80)[32:64] = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212
blog key           = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212
match at index 80: True
512 streams in 8.6706 ms
Enter fullscreen mode Exit fullscreen mode

The blog's own test vector reproduces byte for byte: a document whose time(NULL) mod 512 is 80 yields exactly that key. V12 says their unoptimized brute force took about 11 milliseconds; mine took 8.7. Either way, this is not a "theoretical" weakness. Enumerating the entire keyspace is faster than opening the PDF.

The fix, in MuPDF 1.28.5, is to use a real CSPRNG. But previously published copies cannot be retroactively protected: any MuPDF-encrypted document made before the fix has a key an attacker can find by trying 512 candidates and checking against the plaintext /ID.

Foxit: the key is a hash of the timestamp

Foxit PDF Reader 2026.1.3.36551 is worse in a different way. Its file key is:

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

The 32 middle bytes are supposed to be the previous key buffer, but they are uninitialized memory that is zero-filled in practice. So the entire key is a SHA-256 of the current second plus a constant. I verified V12's test vector exactly:

foxit key = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
blog key  = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
foxit match: True
Enter fullscreen mode Exit fullscreen mode

That timestamp is 2026-08-24 07:55 PDT. The /Perms block in the file contains known plaintext, so checking a candidate costs one hash and one AES block decryption — no password hashing, no document decryption. V12's Rust searcher sustains 370–376 million timestamps per second on a 32-thread Ryzen 9: ten years of timestamps (315,573,571 candidates) in 0.84 seconds, every timestamp since the epoch (1,792,610,110 candidates) in 4.85 seconds. A document made this morning falls in milliseconds.

The kicker: this pattern traces back to PDFium's initial public commit in May 2014, whose file header credits Foxit for the original code. The bug is twelve years old and was inherited through the most widely used PDF rendering library on earth. Other editors that picked up the pattern through PDFium may have the same flaw.

OpenPDF and iText: timestamp plus free memory

OpenPDF 3.0.5 and iText 9.7.1 route randomness through an IVGenerator that seeds an ARC4 stream with the millisecond timestamp concatenated with the JVM's free memory:

long time = System.currentTimeMillis();
long mem = Runtime.getRuntime().freeMemory();
String s = time + "+" + mem;
arcfour.prepareARCFOURKey(s.getBytes());
Enter fullscreen mode Exit fullscreen mode

The same stream generates the file key and 32 bytes of salts stored in plaintext in /U and /O, so guesses for timestamp and free memory can be checked against those salts before recovering the key. More variance than MuPDF or Foxit, but the same structural sin: the "256-bit" key is derived from a seed an attacker can plausibly enumerate.

The pattern

Three implementations, three different broken RNGs, one shared mistake: the file key was supposed to be the upgrade over password-derived keys. A password at least has whatever entropy the user gave it. These keys have 9 bits, one second of timestamps, or a millisecond timestamp plus a heap statistic — all less than a decent password, all wrapped in an AES-256 label that promises the opposite.

The password hashing in PDF revision 6 was deliberately hardened against brute force. Nobody hardened the random number generator, because nobody checked it. The file key is the one thing in the whole scheme that must actually be random, and it was the one thing nobody audited.

V12 has published decryptors for MuPDF and Foxit, and disclosed to the vendors; MuPDF's case is fixed. If you have PDFs encrypted by any of these tools before the fixes, re-encrypt them. The old copies are not protected by a password anymore — they are protected by 512 guesses.

Sources: V12, "The case of the 9 bit AES key" (v12.sh, October 2026) — MuPDF drand48 analysis, Foxit timestamp-hash derivation, OpenPDF/iText IVGenerator, and all test vectors above, which I re-ran locally and reproduced byte for byte.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

The key lesson is that a strong cipher cannot compensate for a predictable key generator. Testing the randomness source and seed lifecycle separately from the encryption algorithm would catch a different class of failure than a round-trip test.