De zaak van de 9-bit AES-sleutel

Verschillende PDF-implementaties adverteren met AES-256, maar genereren de 256-bit sleutel vanuit een zeer kleine toestandsruimte (state space) of vanuit onveilige willekeurige bronnen. We ontdekten dat diverse PDF-editors hun AES-256-bestandsleutels genereren vanuit toestandsruimtes die klein genoeg zijn om volledig te enumereren. In MuPDF beïnvloeden slechts 9 bits van de willekeurige toestand de feitelijke AES-256-sleutel. Andere PDF-editors, zoals Foxit, baseren hun AES-256-sleutel op tijdgebonden willekeur, wat eveneens leidt tot sleutels die triviaal te herstellen zijn.

Inleiding

PDF-encryptie beschermt de strings en datastreams in een PDF-document, inclusief metadata, tenzij deze expliciet onversleuteld is gelaten. Het mechanisme voor wachtwoordbeveiliging heeft door de jaren heen verschillende revisies ondergaan, waarbij de overstap werd gemaakt van RC4 naar AES en waarbij password hashing is versterkt tegen brute-force-aanvallen. De huidige versie, revisie 6, maakt gebruik van AES-256.

Wanneer een PDF is versleuteld met AES-256, versleutelt het wachtwoord het document niet rechtstreeks. De implementatie genereert een willekeurige bestandsleutel van 32 bytes die de inhoud versleutelt. Het wachtwoord en een salt worden vervolgens gehasht om een aparte sleutel af te leiden, die op zijn beurt deze willekeurige bestandsleutel van 32 bytes versleutelt. Revisie 6 maakt dit hashproces opzettelijk traag; de oudere AES-256 revisie 5 doet dit niet.

Een PDF kan een gebruikerswachtwoord hebben voor het bekijken en een eigenaarswachtwoord voor het wijzigen van rechten. Beiden ontgrendelen dezelfde bestandsleutel, dus de restricties liggen bij de reader: met het kijkwachtwoord kan men ook een onbeperkte kopie maken.

We hebben vastgesteld dat deze willekeur vaak zeer onveilig wordt gegenereerd, wat wachtwoordloze decryptie van beveiligde PDF's mogelijk maakt.

MuPDF

Versies van MuPDF vóór 1.28.5 maken gebruik van een implementatie van de drand48-familie om willekeurige getallen te genereren. Deze generator wordt één keer geïnitialiseerd met de huidige epoch in seconden:

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

De generator is een 48-bit lineaire congruentiële generator (LCG): $s{n+1} = (0x5deece66d \cdot sn + 0xb) \pmod{2^{48}}$

fz_srand48() schrijft de toestand als drie 16-bit woorden. Het lage woord is de constante 0x330e, en de 32-bit tijdstempel beslaat de toestandsbits 16 tot 47:

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

Wanneer MuPDF een sleutel moet genereren, roept het fz_memrnd() aan. Deze functie pakt herhaaldelijk een willekeurig getal, zet de generator een stap vooruit en gebruikt vervolgens het laagste byte van dit getal als willekeurige 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);

De shift verwijdert de toestandsbits 0 tot 16, en de cast naar unsigned char verwijdert alles boven bit 24. Elke output-byte komt dus voort uit bits 17 tot 24 van de LCG-toestand.

Het probleem is dat de hogere bits van de toestand de lagere bits nooit beïnvloeden. Vermenigvuldiging en optelling kunnen bits naar boven dragen (carry), maar niet naar beneden. Aangezien elke output-byte afkomstig is van bits 17 tot 24, kan alles boven bit 24 worden genegeerd, zelfs na het vooruitzetten van de generator.

Dit laat 25 bits over die de output kunnen beïnvloeden. De onderste 16 zijn altijd geïnitialiseerd op 0x330e, waardoor slechts negen bits van de tijdstempel relevant zijn. Tijdstempels die 512 seconden van elkaar verschillen, produceren exact dezelfde bytestroom.

Omdat de mogelijke sleutelruimte zo klein is, kunnen we dit brute-forcen door alle mogelijke 32-byte sleutels te genereren en te proberen te decrypteren:

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 gebruikt dezelfde generator voor de /ID van het document, die in platte tekst in de trailer wordt opgeslagen. Bij het schrijven van een nieuw document worden eerst 32 bytes uit dezelfde LCG getrokken, direct gevolgd door de decryptiesleutel. Hierdoor kan de /ID worden gebruikt als extra controle.

Het doorzoeken van alle 512 streams in een niet-geoptimaliseerde Python-implementatie via pure brute force duurt ongeveer 11 milliseconden. In een testdocument:

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

Deze sleutel decrypteert het document zonder dat een van de wachtwoorden nodig is.

Foxit

Foxit PDF Reader 2026.1.3.36551 leidt de bestandsleutel af als een hash van de huidige tijdstempel, 32 niet-geïnitialiseerde bytes en een constante. In de praktijk zijn deze niet-geïnitialiseerde bytes gevuld met nullen en dragen ze niet bij aan de willekeur.

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

Of als equivalent Python-fragment:

from hashlib import sha256

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

Het /Perms-rechtenblok bevat bekende platte tekst (known plaintext), waardoor de controle van een kandidaat-sleutel slechts bestaat uit één hash en één AES-blokdecryptie, zonder password hashing of volledige documentdecryptie:

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

// Controleer op bekende magic values om succesvolle decryptie aan te tonen
(plain[..4] == self.permissions
&& plain[4..8] == [0xff; 4]
&& plain[8] == self.metadata
&& plain[9..12] == *b"adb")
.then_some(key)

Onze Rust-implementatie doorzoekt tijdstempels terugwaarts vanaf de huidige tijd, met gebruik van Rayon voor multithreading. Op een Ryzen 9 7950X met 32 threads haalt dit 370 tot 376 miljoen tijdstempels per seconde:

Target tijdstempelGeteste kandidatenZoektijd
Tien jaar geleden315.573.5710,84 s
Unix epoch1.792.610.1104,85 s

De tweede rij beslaat elke tijdstempel van 1970 tot nu. In de praktijk zal dit voor een recent gegenereerd document in de orde van milliseconden zijn.

  • timestamp = 1787583328
  • key = 41474b490265500f9727f21f1dde34be31bcdfc3830e2dc027daabe5b5984081
  • /Perms = fcfbffffffffffff54616462f67f0000 (gedecrypteerd)

Waar komt deze sleutelafleiding vandaan?

Dit exacte patroon dook op in een paar verschillende PDF-bewerkingspakketten, dus we waren benieuwd waar ze dit vandaan hadden. Het was al aanwezig in de eerste publieke commit van PDFium uit mei 2014. De bestandsheader geeft Foxit de eer voor de originele code, dus deze code lijkt afkomstig te zijn van Foxit. Andere editors hebben het mogelijk overgenomen via 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);
    /* ... */
}

De middelste waarde van 32 bytes is m_EncryptKey, wat de niet-geïnitialiseerde inhoud is van de sleutelbuffer die in zichzelf wordt gehasht. Uitgaande van het feit dat deze bytes zijn gevuld met nullen, is dit exact de sleutelafleiding die Foxit vandaag de dag nog steeds gebruikt.

OpenPDF en iText

OpenPDF 3.0.5 en iText 9.7.1 sturen cryptografische willekeur beide via een IVGenerator-klasse; een ARC4-stream die wordt geïnitialiseerd met een milliseconde-tijdstempel en het vrije geheugen van de JVM:

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

Dezelfde stream genereert de bestandsleutel en 32 bytes aan salts die in platte tekst in /U en /O worden opgeslagen. We kunnen gokken voor de tijdstempel en het vrije geheugen controleren tegen deze salts, om vervolgens de sleutel te herstellen uit de aangrenzende bytes.

Hoewel er hier aanzienlijk meer variatie is dan in de voorbeelden van MuPDF en Foxit, laat dit opnieuw zien dat de belofte van 256-bit beveiliging wordt ondermijnd door onveilige bronnen van willekeur en een kleinere toestandsruimte dan geadverteerd.

Conclusie

In elk van deze implementaties wordt de bestandsleutel gegenereerd vanuit een bron van willekeur die niet cryptografisch veilig is. Wat een beveiligingsupgrade had moeten zijn ten opzichte van oudere bestandsleutels (die waren afgeleid van het wachtwoord van de gebruiker), leidde in de praktijk tot zwakkere sleutels. Deze problemen zijn gemeld bij de leveranciers en in het geval van MuPDF opgelost. Eerder gepubliceerde kopieën kunnen echter niet retroactief worden beschermd, waardoor deze bugs mogelijk nog steeds impact hebben.

We hebben onze decryptors voor MuPDF en Foxit gepubliceerd.