Skip to content
Search ⌘/Ctrl+K Book a call Start a run

Type to search.

    The case of the 9 bit AES key

    Several PDF implementations advertise AES-256, but generate the 256-bit key from either a very small state space, or from insecure random sources.

    Rick de Jager, V12

    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+𝟶𝚡𝚋)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 Candidates tested Search time
    Ten years ago 315,573,571 0.84 s
    Unix epoch 1,792,610,110 4.85 s

    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.