{"article":{"slug":"the-case-of-the-9-bit-aes-key","title":"The case of the 9 bit AES key","subtitle":null,"summary":"V12's Rick de Jager shows that several PDF implementations advertising AES-256 generate file keys from tiny or predictable state: in MuPDF before 1.28.5 only 9 bits of a time-seeded drand48 state affect the key, Foxit derives keys from time-based randomness, and OpenPDF/iText seed RC4 from a timestamp plus JVM free memory, enabling passwordless decryption. Issues were disclosed (MuPDF fixed) and decryptors published.","content_type":"research","language":"en","canonical_url":"https://v12.sh/blog/the-case-of-the-9bit-aes-key","author":{"name":"Rick de Jager","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"V12","url":"https://v12.sh/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Cryptography","slug":"cryptography","url":"https://listedarticles.com/topics/cryptography"},{"name":"Vulnerability Research","slug":"vulnerability-research","url":"https://listedarticles.com/topics/vulnerability-research"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1295,"reading_minutes":6,"published_at":"2026-10-27T00:00:00.000Z","added_at":"2026-10-28T23:10:40.061Z","updated_at":"2026-10-28T23:10:40.061Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/the-case-of-the-9-bit-aes-key","markdown_url":"https://listedarticles.com/articles/the-case-of-the-9-bit-aes-key.md","example":false,"citation":"Rick de Jager, V12. \"The case of the 9 bit AES key.\" 27 Oct 2026. https://v12.sh/blog/the-case-of-the-9bit-aes-key (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://v12.sh/blog/the-case-of-the-9bit-aes-key"},"body_markdown":"*Rick de Jager · V12 · October 27, 2026*\n\nWe 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.\n\n![MuPDF AES-256 key recovery worksheets on a desk](https://v12.sh/cdn/_astro/cover.CF5x0X6B_1N5vR.webp)\n\n## Introduction\n\nPDF 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.\n\nWhen 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.\n\nA 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.\n\nWe found that this randomness is often generated very insecurely, allowing passwordless decryption of protected PDFs.\n\n## MuPDF\n\nMuPDF versions before 1.28.5 use an implementation of the [drand48 family](https://pubs.opengroup.org/onlinepubs/7908799/xsh/drand48.html) to generate their random numbers. This generator is seeded once, using the current epoch in seconds:\n\n```\nstatic void fz_init_random_context(fz_context *ctx)\n{\n  /* ... */\n  fz_srand48(ctx, (uint32_t)time(NULL));\n}\n```\n\nThe generator is a 48-bit linear congruential generator:\n\n`s_(n+1) = (0x5deece66d · s_n + 0xb) mod 2^48`\n\n`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:\n\n```\nvoid fz_srand48(fz_context *ctx, int32_t seed)\n{\n  uint16_t p[3] = { 0x330e, seed, seed>>16 };\n  fz_seed48(ctx, p);\n}\n```\n\nWhen 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.\n\n```\nint32_t fz_nrand48(fz_context *ctx, uint16_t s[3])\n{\n  return fz_rand48_step(s, ctx->seed48+3) >> 17;\n}\n\n/* in fz_memrnd() */\nwhile (len-- > 0)\n  *data++ = (unsigned char)fz_lrand48(ctx);\n```\n\nThe 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.\n\nThe 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.\n\nThat 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.\n\nSince the possible keyspace is so small, we can just brute-force this by generating all possible 32-byte keys and trial decrypting:\n\n```\ndef stream(seed, count):\n    state = (seed << 16) | 0x330e\n    out = bytearray()\n    for _ in range(count):\n        state = (0x5deece66d * state + 0xb) & ((1 << 48) - 1)\n        out.append((state >> 17) & 0xff)\n    return bytes(out)\n\ncandidates = [stream(seed, 64)[32:64] for seed in range(512)]\n```\n\nMuPDF 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.\n\nSearching all 512 streams in a very unoptimized python implementation using pure brute force takes about 11 milliseconds. On a test document:\n\n```\ntime(NULL) mod 512 = 80\n/ID[0] = 7628523958da31d6c21c90e39344b28e\n/ID[1] = 8be910bddaa1f1a542e5a27ceb115346\nkey    = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212\n```\n\nThat key decrypts the document without either password.\n\n## Foxit\n\nFoxit 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.\n\n```\nSHA-256(uint32_le(time(NULL)) || <32 uninitialized bytes> || \"there\")\n```\n\nOr as an equivalent python snippet:\n\n```\nfrom hashlib import sha256\n\nkey = sha256(timestamp.to_bytes(4, \"little\") + bytes(32) + b\"there\").digest()\n```\n\nThe `/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:\n\n```\nlet mut material = [0u8; 41];\nmaterial[..4].copy_from_slice(&timestamp.to_le_bytes());\nmaterial[36..].copy_from_slice(b\"there\");\nlet key: [u8; 32] = Sha256::digest(material).into();\n\nlet cipher = Aes256::new(&key.into());\nlet mut plain = Block::<Aes256>::from(self.perms);\ncipher.decrypt_block(&mut plain);\n// Check for some known magic values to indicate successful decryption\n(plain[..4] == self.permissions\n    && plain[4..8] == [0xff; 4]\n    && plain[8] == self.metadata\n    && plain[9..12] == *b\"adb\")\n    .then_some(key)\n```\n\nOur 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:\n\n| Target timestamp | Candidates tested | Search time |\n| --- | --- | --- |\n| Ten years ago | 315,573,571 | 0.84 s |\n| Unix epoch | 1,792,610,110 | 4.85 s |\n\nThe 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.\n\n```\ntimestamp = 1787583328\nkey       = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081\n/Perms    = fcfbffffffffffff54616462f67f0000   (decrypted)\n```\n\n### Where did this key derivation come from\n\nThis 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](https://github.com/chromium/pdfium/blob/5110c4743751145c4ae1934cd1d83bc6c55bb43f/core/src/fpdfapi/fpdf_parser/fpdf_parser_encrypt.cpp#L504-L531) from May 2014. The [file header credits Foxit for the original code](https://github.com/chromium/pdfium/blob/5110c4743751145c4ae1934cd1d83bc6c55bb43f/core/src/fpdfapi/fpdf_parser/fpdf_parser_encrypt.cpp#L1-L5), so this code appears to have come from Foxit. Other editors may have picked it up through PDFium:\n\n```\nif (m_Revision >= 5) {\n    int t = (int)time(NULL);\n    uint8_t sha[128];\n    CRYPT_SHA256Start(sha);\n    CRYPT_SHA256Update(sha, (uint8_t*)&t, sizeof t);\n    CRYPT_SHA256Update(sha, m_EncryptKey, 32);\n    CRYPT_SHA256Update(sha, (uint8_t*)\"there\", 5);\n    CRYPT_SHA256Finish(sha, m_EncryptKey);\n    /* ... */\n```\n\nThe 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.\n\n## OpenPDF and iText\n\nOpenPDF 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:\n\n```\narcfour = new ARCFOUREncryption();\nlong time = System.currentTimeMillis();\nlong mem = Runtime.getRuntime().freeMemory();\nString s = time + \"+\" + mem;\narcfour.prepareARCFOURKey(s.getBytes());\n```\n\nThe 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.\n\nWhile 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.\n\n## Conclusion\n\nIn 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.\n\nWe published our [decryptors for MuPDF and Foxit](https://github.com/v12-security/pocs/tree/main/pdf_decryptors).\n\n---\n\n*Originally published at [the V12 blog](https://v12.sh/blog/the-case-of-the-9bit-aes-key).*\n","body_html":"<p><em>Rick de Jager · V12 · October 27, 2026</em></p>\n<p>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.</p>\n<figure><img src=\"https://v12.sh/cdn/_astro/cover.CF5x0X6B_1N5vR.webp\" alt=\"MuPDF AES-256 key recovery worksheets on a desk\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<h2 id=\"introduction\">Introduction</h2>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>We found that this randomness is often generated very insecurely, allowing passwordless decryption of protected PDFs.</p>\n<h2 id=\"mupdf\">MuPDF</h2>\n<p>MuPDF versions before 1.28.5 use an implementation of the <a href=\"https://pubs.opengroup.org/onlinepubs/7908799/xsh/drand48.html\" rel=\"nofollow ugc noopener\">drand48 family</a> to generate their random numbers. This generator is seeded once, using the current epoch in seconds:</p>\n<pre><code>static void fz_init_random_context(fz_context *ctx)\n{\n  /* ... */\n  fz_srand48(ctx, (uint32_t)time(NULL));\n}</code></pre>\n<p>The generator is a 48-bit linear congruential generator:</p>\n<p><code>s_(n+1) = (0x5deece66d · s_n + 0xb) mod 2^48</code></p>\n<p><code>fz_srand48()</code> writes the state as three 16-bit words. The low word is the constant <code>0x330e</code>, and the 32-bit timestamp occupies state bits 16 to 47:</p>\n<pre><code>void fz_srand48(fz_context *ctx, int32_t seed)\n{\n  uint16_t p[3] = { 0x330e, seed, seed&gt;&gt;16 };\n  fz_seed48(ctx, p);\n}</code></pre>\n<p>When MuPDF needs to generate a key, it calls <code>fz_memrnd()</code>, 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.</p>\n<pre><code>int32_t fz_nrand48(fz_context *ctx, uint16_t s[3])\n{\n  return fz_rand48_step(s, ctx-&gt;seed48+3) &gt;&gt; 17;\n}\n\n/* in fz_memrnd() */\nwhile (len-- &gt; 0)\n  *data++ = (unsigned char)fz_lrand48(ctx);</code></pre>\n<p>The shift discards state bits 0 to 16, and the cast to <code>unsigned char</code> discards everything above bit 24. Every output byte therefore comes from bits 17 to 24 of the LCG state.</p>\n<p>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.</p>\n<p>That leaves 25 bits that can affect the output. The bottom 16 are always initialized to <code>0x330e</code>, so only nine bits from the timestamp matter. Timestamps that differ by 512 seconds produce exactly the same byte stream.</p>\n<p>Since the possible keyspace is so small, we can just brute-force this by generating all possible 32-byte keys and trial decrypting:</p>\n<pre><code>def stream(seed, count):\n    state = (seed &lt;&lt; 16) | 0x330e\n    out = bytearray()\n    for _ in range(count):\n        state = (0x5deece66d * state + 0xb) &amp; ((1 &lt;&lt; 48) - 1)\n        out.append((state &gt;&gt; 17) &amp; 0xff)\n    return bytes(out)\n\ncandidates = [stream(seed, 64)[32:64] for seed in range(512)]</code></pre>\n<p>MuPDF uses the same generator for the document <code>/ID</code>, 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 <code>/ID</code> can be used as an additional check.</p>\n<p>Searching all 512 streams in a very unoptimized python implementation using pure brute force takes about 11 milliseconds. On a test document:</p>\n<pre><code>time(NULL) mod 512 = 80\n/ID[0] = 7628523958da31d6c21c90e39344b28e\n/ID[1] = 8be910bddaa1f1a542e5a27ceb115346\nkey    = c3888ecc8ff554ac93acd689da2ecd6223cb760168622e8a0c44a533a037c212</code></pre>\n<p>That key decrypts the document without either password.</p>\n<h2 id=\"foxit\">Foxit</h2>\n<p>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.</p>\n<pre><code>SHA-256(uint32_le(time(NULL)) || &lt;32 uninitialized bytes&gt; || &quot;there&quot;)</code></pre>\n<p>Or as an equivalent python snippet:</p>\n<pre><code>from hashlib import sha256\n\nkey = sha256(timestamp.to_bytes(4, &quot;little&quot;) + bytes(32) + b&quot;there&quot;).digest()</code></pre>\n<p>The <code>/Perms</code> 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:</p>\n<pre><code>let mut material = [0u8; 41];\nmaterial[..4].copy_from_slice(&amp;timestamp.to_le_bytes());\nmaterial[36..].copy_from_slice(b&quot;there&quot;);\nlet key: [u8; 32] = Sha256::digest(material).into();\n\nlet cipher = Aes256::new(&amp;key.into());\nlet mut plain = Block::&lt;Aes256&gt;::from(self.perms);\ncipher.decrypt_block(&amp;mut plain);\n// Check for some known magic values to indicate successful decryption\n(plain[..4] == self.permissions\n    &amp;&amp; plain[4..8] == [0xff; 4]\n    &amp;&amp; plain[8] == self.metadata\n    &amp;&amp; plain[9..12] == *b&quot;adb&quot;)\n    .then_some(key)</code></pre>\n<p>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:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Target timestamp</th><th>Candidates tested</th><th>Search time</th></tr></thead><tbody><tr><td>Ten years ago</td><td>315,573,571</td><td>0.84 s</td></tr><tr><td>Unix epoch</td><td>1,792,610,110</td><td>4.85 s</td></tr></tbody></table></div>\n<p>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.</p>\n<pre><code>timestamp = 1787583328\nkey       = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081\n/Perms    = fcfbffffffffffff54616462f67f0000   (decrypted)</code></pre>\n<h3 id=\"where-did-this-key-derivation-come-from\">Where did this key derivation come from</h3>\n<p>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 <a href=\"https://github.com/chromium/pdfium/blob/5110c4743751145c4ae1934cd1d83bc6c55bb43f/core/src/fpdfapi/fpdf_parser/fpdf_parser_encrypt.cpp#L504-L531\" rel=\"nofollow ugc noopener\">initial public commit</a> from May 2014. The <a href=\"https://github.com/chromium/pdfium/blob/5110c4743751145c4ae1934cd1d83bc6c55bb43f/core/src/fpdfapi/fpdf_parser/fpdf_parser_encrypt.cpp#L1-L5\" rel=\"nofollow ugc noopener\">file header credits Foxit for the original code</a>, so this code appears to have come from Foxit. Other editors may have picked it up through PDFium:</p>\n<pre><code>if (m_Revision &gt;= 5) {\n    int t = (int)time(NULL);\n    uint8_t sha[128];\n    CRYPT_SHA256Start(sha);\n    CRYPT_SHA256Update(sha, (uint8_t*)&amp;t, sizeof t);\n    CRYPT_SHA256Update(sha, m_EncryptKey, 32);\n    CRYPT_SHA256Update(sha, (uint8_t*)&quot;there&quot;, 5);\n    CRYPT_SHA256Finish(sha, m_EncryptKey);\n    /* ... */</code></pre>\n<p>The 32-byte middle value is <code>m_EncryptKey</code>, 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.</p>\n<h2 id=\"openpdf-and-itext\">OpenPDF and iText</h2>\n<p>OpenPDF 3.0.5 and iText 9.7.1 both route cryptographic randomness through an <code>IVGenerator</code> class, an ARC4 stream seeded with a millisecond timestamp and the JVM’s free memory:</p>\n<pre><code>arcfour = new ARCFOUREncryption();\nlong time = System.currentTimeMillis();\nlong mem = Runtime.getRuntime().freeMemory();\nString s = time + &quot;+&quot; + mem;\narcfour.prepareARCFOURKey(s.getBytes());</code></pre>\n<p>The same stream generates the file key and 32 bytes of salts stored in plaintext in <code>/U</code> and <code>/O</code>. We can check guesses for the timestamp and free memory against these salts, then recover the key from the adjacent bytes.</p>\n<p>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.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>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.</p>\n<p>We published our <a href=\"https://github.com/v12-security/pocs/tree/main/pdf_decryptors\" rel=\"nofollow ugc noopener\">decryptors for MuPDF and Foxit</a>.</p>\n<hr />\n<p><em>Originally published at <a href=\"https://v12.sh/blog/the-case-of-the-9bit-aes-key\" rel=\"nofollow ugc noopener\">the V12 blog</a>.</em></p>","headings":[{"level":2,"text":"Introduction","id":"introduction"},{"level":2,"text":"MuPDF","id":"mupdf"},{"level":2,"text":"Foxit","id":"foxit"},{"level":3,"text":"Where did this key derivation come from","id":"where-did-this-key-derivation-come-from"},{"level":2,"text":"OpenPDF and iText","id":"openpdf-and-itext"},{"level":2,"text":"Conclusion","id":"conclusion"}]}}