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

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) 

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 

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

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 

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

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.