Τεχνικό Άρθρο

Επαναλαμβανόμενα κλειδιά PDF σε FPC: διόρθωση RNG

Πριν την έκδοση 3.114.8, το PDFiumPas παρήγαγε υλικό κλειδιών κρυπτογράφησης PDF σε στόχους εκτός Windows με τη συνάρτηση Random της runtime βιβλιοθήκης, και επειδή τίποτα δεν καλούσε το Randomize, κάθε διεργασία παρήγαγε την ίδια ακολουθία bytes. Τα builds Free Pascal σε Linux και macOS έγραφαν επομένως πανομοιότυπα κλειδιά κρυπτογράφησης αρχείων, salts, CBC IVs και προθέματα nonce AES-GCM εκτέλεση με την εκτέλεση. Η έκδοση 3.114.8 διαβάζει αντ' αυτού το /dev/urandom και πετάει exception όταν δεν μπορεί

Το ίδιο το ελάττωμα είναι ένας βρόχος τεσσάρων γραμμών. Το πιο χρήσιμο μάθημα είναι γιατί μια σουίτα τεστ που κρυπτογραφεί και αποκρυπτογραφεί εκατοντάδες έγγραφα, με AESV3 και AESV4, με και χωρίς PDF MAC, έμενε πράσινη όλο αυτό τον καιρό. Η τυχαιότητα που είναι σταθερή ανά διεργασία είναι αόρατη σε κάθε τεστ που τρέχει μέσα σε μία διεργασία, και έτσι ακριβώς γράφονται συνήθως τα τεστ κρυπτογράφησης

Πού χρειάζεται το PDFiumPas τυχαία bytes;

Κάθε τυχαίο byte στη στοίβα κρυπτογράφησης του PDFiumPas βγαίνει από μία μόνο διαδικασία, την AesGenerateRandomBytes στη μονάδα FPdfAes, οπότε μια κακή πηγή μολύνει το σύνολο. Ο standard security handler στο ISO 32000-2 §7.6.4 και η επέκταση AESV4 στο ISO/TS 32003 καταναλώνουν εκείνα τα bytes σε αυτά τα σημεία:

  • Το κλειδί κρυπτογράφησης αρχείου των 32 bytes, φρέσκο από το DeriveEncryptionKeys για κάθε έγγραφο και μετά τυλιγμένο σε /UE και /OE κάτω από κλειδιά παραγόμενα από κωδικό
  • Δύο salts των 16 bytes, ένα αποθηκευμένο στα τελευταία 16 bytes του /U και ένα στα τελευταία 16 bytes του /O, το καθένα χωρισμένο σε salt επικύρωσης 8 bytes και salt κλειδιού 8 bytes
  • Τα bytes 12 έως 15 του plaintext πίσω από το /Perms, που το ISO 32000-2 γεμίζει με τυχαία δεδομένα πριν το μπλοκ κρυπτογραφηθεί κάτω από το κλειδί αρχείου
  • Ένα CBC IV των 16 bytes μπροστά σε κάθε κρυπτογραφημένο string και stream σε ένα έγγραφο AESV3
  • Ένα πρόθεμα nonce 8 bytes για έγγραφα AESV4, ακολουθούμενο από έναν μετρητή ανά αντικείμενο 4 bytes που ξεκινά από το μηδέν
  • Το /KDFSalt των 32 bytes και το κλειδί MAC όταν είναι τεθειμένο το EnableIntegrityProtection
Κάθε τυχαίο byte στη στοίβα κρυπτογράφησης του PDFiumPas ρέει από την AesGenerateRandomBytes στο FPdfAes σε έξι καταναλωτές: το κλειδί κρυπτογράφησης αρχείου 32 bytes τυλιγμένο σε /UE και /OE, τα salts /U και /O, τα bytes γεμίσματος /Perms, το CBC IV του AESV3, το πρόθεμα nonce GCM του AESV4, και το KDF salt και το κλειδί MAC
Ένας κοινός generator σημαίνει ότι μια κακή πηγή μολύνει το υλικό κλειδιών παντού ταυτόχρονα, και γι' αυτό η διόρθωση έπεσε σε μία μόνο διαδικασία αντί σε κάθε σημείο κλήσης

Γιατί κάθε διεργασία παρήγαγε το ίδιο κλειδί;

Η AesGenerateRandomBytes χρησιμοποιούσε τον generator του λειτουργικού μόνο σε Windows· παντού αλλού γέμιζε το buffer από τον ψευδοτυχαίο generator του RTL, και εκείνος ο generator ξεκινά από RandSeed = 0 εκτός αν το πρόγραμμα καλέσει το Randomize. Το σχόλιο πάνω από τον βρόχο έλεγε ότι ο generator σπερνόταν από το GetTickCount64. Καμία γραμμή κώδικα δεν το έκανε ποτέ, με αποτέλεσμα το σχόλιο να είναι το μόνο μέρος που υπήρχε ο σπόρος:

// Ο κλάδος εκτός Windows της AesGenerateRandomBytes πριν την 3.114.8
// (το σχόλιο πάνω της υποσχόταν σπόρο GetTickCount64 που δεν εφαρμόστηκε ποτέ)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Η ακολουθία ξαναρχίζει με κάθε διεργασία και προχωρά μέσα της, οπότε το πρώτο έγγραφο που κρυπτογραφεί όποια διεργασία μοιράζεται το κλειδί αρχείου του με το πρώτο έγγραφο κάθε άλλης διεργασίας που τρέχει το ίδιο build, το δεύτερο με το δεύτερο, και ούτω καθεξής. Το κλειδί αρχείου στα R5, R6 και R7 δεν εξαρτάται καθόλου από τον κωδικό, αφού ο κωδικός απλώς το τυλίγει, που σημαίνει ότι όποιος μπορεί να αναπαραγάγει την ακολουθία κρατάει το κλειδί χωρίς να ξέρει κωδικό. Το AESV4 προσθέτει μια δεύτερη αποτυχία: το ίδιο κλειδί με το ίδιο πρόθεμα 8 bytes και μετρητή που ξαναρχίζει από το μηδέν επαναλαμβάνει GCM nonces, που το NIST SP 800-38D §8 απαγορεύει απερίφραστα. Ένα επαναλαμβανόμενο GCM nonce κάτω από ένα κλειδί αποκαλύπτει το XOR των δύο plaintexts και εκθέτει το subkey αυθεντικοποίησης, οπότε τα tags που στηρίζονται στην κρυπτογράφηση AESV4-GCM και το token PDF MAC παύουν να σημαίνουν οτιδήποτε. Εμπιστευτικότητα και ακεραιότητα καταρρέουν μαζί

Μη σπαρμένος generator RTL στο PDFiumPas σε builds FPC εκτός Windows: με RandSeed 0 κάθε διεργασία εκπέμπει την ίδια ακολουθία, ώστε το έγγραφο ένα στη διεργασία A κουβαλάει το ίδιο κλειδί αρχείου με το έγγραφο ένα στη διεργασία B, και το AESV4 επαναλαμβάνει GCM nonces επειδή το ίδιο κλειδί συναντά το ίδιο πρόθεμα με τον μετρητή να ξαναρχίζει από το μηδέν
Επειδή το κλειδί αρχείου δεν εξαρτάται ποτέ από τον κωδικό, όποιος μπορεί να αναπαραγάγει την ακολουθία κρατάει το κλειδί ολωσδιόλου, και τα επαναλαμβανόμενα GCM nonces καταστρέφουν μαζί εμπιστευτικότητα και ακεραιότητα

Η εμβέλεια είναι στενότερη απ' όσο ίσως υπονοεί η προηγούμενη παράγραφος. Τα builds Windows δεν επηρεάστηκαν ποτέ, επειδή ο κλάδος Windows πάντα καλούσε το CryptGenRandom μέσω advapi32 με CRYPT_VERIFYCONTEXT και πετούσε όταν αποτύγχανε. Εκτεθειμένη ήταν η έξοδος από builds εκτός Windows παλαιότερα της 3.114.8, που στην πράξη σημαίνει εφαρμογές Lazarus και Free Pascal σε Linux και macOS, άλλη μία εγγραφή στη λίστα των παγίδων Delphi απέναντι σε FPC στα builds PDFium

Γιατί το Randomize δεν ήταν ποτέ η σωστή διόρθωση;

Το να κληθεί το Randomize θα είχε κρύψει το σύμπτωμα χωρίς να διορθώσει την πηγή, επειδή το RandSeed είναι τιμή 32-bit και το Randomize την παράγει από το ρολόι. Αυτό περικόπτει τον αριθμό των πιθανών ροών κλειδιών σε 2^32, και το να γνωρίζεις περίπου πότε γράφτηκε ένα αρχείο κατεβάζει την αναζήτηση πολύ κάτω από αυτό, που είναι μηδενικό δίπλα σε ένα κλειδί AES 256-bit. Το υλικό κλειδιών πρέπει να βγαίνει από τη δεξαμενή εντροπίας του πυρήνα, οπότε η AesGenerateRandomBytes στην 3.114.8 διαβάζει το /dev/urandom, βρόχο από πάνω από σύντομες αναγνώσεις, και πετάει αν η δεξαμενή δεν μπορεί να παραδώσει κάθε ζητούμενο byte:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // αποτυχία ή απρόσμενο τέλος ροής
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Η άρνηση είναι σκόπιμη, και ταιριάζει με αυτό που ο κλάδος Windows πάντα έκανε όταν το CryptGenRandom δεν είναι διαθέσιμο. Μια αποτυχημένη κρυπτογραφημένη αποθήκευση είναι ένα περιστατικό που προσέχεις την ίδια μέρα· μια επιτυχημένη αποθήκευση με προβλέψιμα κλειδιά είναι αυτή που μαθαίνεις από άλλον. Ακολουθούν δύο πρακτικές συνέπειες. Ένα minimal container ή chroot χωρίς populated /dev αποτυγχάνει τώρα στην κρυπτογράφηση αντί να υποβαθμίζεται σιωπηλά, οπότε κάντε το mount. Και επειδή η exception διαδίδεται έξω από το TPdf.SaveAsEncrypted αφού το αρχείο προορισμού άνοιξε με fmCreate, μένει πίσω ένα κενό αρχείο εξόδου για να το διαγράψει ο error handler σας

Γιατί τα τεστ round-trip δεν το έπιασαν ποτέ;

Ένα τεστ round-trip δεν μπορεί να δει σταθερή τυχαιότητα, επειδή η αποκρυπτογράφηση ανακτά όποιο κλειδί αρχείου διάλεξε η κρυπτογράφηση. Το τεστ κρυπτογραφεί ένα έγγραφο, το ξανανοίγει με τον κωδικό, ξετυλίγει το κλειδί από το /UE, και αποκρυπτογραφεί κάθε αντικείμενο· ένα προβλέψιμο κλειδί ξετυλίγεται και αποκρυπτογραφεί εξίσου καλά με ένα τυχαίο, και τα GCM tags επαληθεύονται επειδή υπολογίστηκαν με το ίδιο εκείνο κλειδί. Ακόμη κι ένα τεστ που κρυπτογραφεί δύο φορές και απαιτεί οι δύο έξοδοι να διαφέρουν περνά, αφού η δεύτερη κλήση στην ίδια διεργασία τραβάει τα επόμενα bytes της ακολουθίας. Η ιδιότητα που μετράει, διαφορετικό κλειδί σε κάθε διεργασία, παρατηρείται μόνο συγκρίνοντας εξόδους ανάμεσα σε διεργασίες. Όποτε ο ίδιος κώδικας παράγει και καταναλώνει μια τιμή, τα τεστ είναι τυφλά σε ολόκληρες κατηγορίες ελαττωμάτων, και η τυχαιότητα είναι το καθαρότερο παράδειγμα

Πώς τεστάρετε την τυχαιότητα κλειδιών ανάμεσα σε διεργασίες;

Τρέξτε μια μικρή δοκιμαστική κλήση δύο φορές ως ξεχωριστές διεργασίες στην πλατφόρμα στόχο και συγκρίνετε την έξοδο. Το παρακάτω probe καλεί το DeriveEncryptionKeys και τυπώνει το salt αποθηκευμένο στα bytes 32 έως 47 της εγγραφής /U. Εκείνη η τιμή γράφεται φανερά μέσα σε κάθε κρυπτογραφημένο αρχείο, οπότε το να τυπωθεί σε logs CI δεν αποκαλύπτει τίποτα, ενώ βγαίνει από τον ίδιο generator με το κλειδί αρχείου:

Τεστ τυχαιότητας μεταξύ διεργασιών για το PDFiumPas: το πρόγραμμα SaltProbe καλεί το DeriveEncryptionKeys και τυπώνει το hex των bytes 32 έως 47 του /U, η εργασία το τρέχει δύο φορές ως ξεχωριστές διεργασίες και αποτυγχάνει όταν οι γραμμές ταυτίζονται, και τα παραδοτέα PDF συγκρίνονται από τα τελευταία 16 bytes των strings /U τους
Η σταθερή τυχαιότητα είναι αόρατη μέσα σε μία διεργασία επειδή η αποκρυπτογράφηση ανακτά όποιο κλειδί διάλεξε η κρυπτογράφηση, ώστε η ιδιότητα που μετράει παρατηρείται μόνο συγκρίνοντας εξόδους ανάμεσα σε διεργασίες
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash 32 bytes + salt 16 bytes
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // πρέπει να διαφέρει σε κάθε εκτέλεση
end.

Δέστε το probe στο build για κάθε στόχο εκτός Windows: τρέξτε το δύο φορές, αποτύχετε την εργασία αν οι δύο γραμμές ταυτίζονται. Η ίδια σύγκριση δουλεύει και σε αρχεία ήδη σε κυκλοφορία. Πάρτε δύο κρυπτογραφημένα PDF γραμμένα από διαφορετικές εκτελέσεις της ίδιας εφαρμογής, διαβάστε τα strings /U από τα dictionaries Encrypt τους, και συγκρίνετε τα τελευταία 16 bytes· πανομοιότυπα salts ταυτοποιούν ένα προσβεβλημένο build, και τα έγγραφα πρέπει να κρυπτογραφηθούν ξανά από το plaintext τους με 3.114.8 ή νεότερο ώστε το καθένα να πάρει φρέσκο κλειδί αρχείου. Η γενική συνήθεια είναι να εξετάζονται οι διαδρομές κώδικα εκτός Windows πάνω στην ίδια την πλατφόρμα αντί να εμπιστεύεστε την εκτέλεση Windows, η ίδια λογική πίσω από το timestamp backend libcurl για builds εκτός Windows

Το PDFiumPas είναι ένα PDF component για Delphi και Lazarus χτισμένο πάνω στη μηχανή PDFium, με AES-256, AES-GCM και το token PDF MAC υλοποιημένα εγγενώς σε Pascal και υλικό κλειδιών από τον generator του λειτουργικού σε κάθε πλατφόρμα. Λεπτομέρειες και downloads βρίσκονται στη σελίδα του PDFium Delphi component