Οι αναζητήσεις υποσυμβολοσειρών όπως η Pos('/Length', Dict) είναι το λάθος εργαλείο για την ανάγνωση ενός λεξικού PDF, επειδή τα κλειδιά ονομάτων PDF μοιράζονται κοινά προθέματα: το /Length είναι πρόθεμα του /Length1, και το /Encrypt είναι πρόθεμα του /EncryptMetadata. Το ISO 32000-1 §7.3.5 ορίζει ένα όνομα ως ένα διακριτικό (token) που τελειώνει μόνο σε έναν οριοθέτη ή κενό διάστημα, οπότε ένα κλειδί θεωρείται ότι βρέθηκε μόνο όταν το byte μετά από αυτό είναι ένας από αυτούς τους χαρακτήρες. Ένας αναγνώστης λεξικού που παραλείπει αυτόν τον έλεγχο θα καταλήξει τελικά να διαβάσει λάθος τιμή από ένα απολύτως έγκυρο αρχείο
Το σφάλμα που μας το έμαθε αυτό δεν έμοιαζε καθόλου με πρόβλημα λεκτικού αναλυτή (lexer). Μια σάρωση συμμόρφωσης άρχισε να αναφέρει μια ροή FontFile ως κατεστραμμένη: το αποσυμπιεσμένο πρόγραμμα γραμματοσειράς ήταν ένα απόσπασμα, αποκομμένο στη μέση του πίνακα. Το αρχείο άνοιγε μια χαρά σε κάθε πρόγραμμα προβολής. Τα δεδομένα ροής στο δίσκο ήταν ανέπαφα. Η βασική αιτία βρισκόταν σε μία γραμμή του κοινόχρηστου αναγνώστη λεξικών μας: η Pos('/Length', ...) είχε ταιριάξει με το /Length1, ένα τυπικό κλειδί που μεταφέρουν τα λεξικά ροής FontFile σύμφωνα με τον Πίνακα 127 του ISO 32000-1, και ο αναγνώστης πήρε τον ακέραιο μετά το /Length1 ως το μήκος της ροής. Ο συγγραφέας αυτού του συγκεκριμένου αρχείου έτυχε να σειριοποιήσει το /Length1 πριν από το /Length, κάτι που είναι εντελώς ελεύθερος να κάνει, αφού οι καταχωρήσεις λεξικού δεν έχουν καθορισμένη σειρά σύμφωνα με την §7.3.7. Η ροή περικόπηκε σε έναν λανθασμένο αριθμό byte, και κάθε έλεγχος που την κατανάλωνε στη συνέχεια τυφλώθηκε σιωπηλά
Γιατί η ταύτιση υποσυμβολοσειρών αποτυγχάνει επειδή ο χώρος ονομάτων PDF είναι γεμάτος από σκόπιμες οικογένειες προθεμάτων, και επειδή η σειρά των καταχωρήσεων του λεξικού δεν είναι καθορισμένη. Ο Πίνακας 127 του ISO 32000-1 ορίζει τα /Length1, /Length2 και /Length3 για ενσωματωμένες ροές γραμματοσειρών, που βρίσκονται όλα δίπλα σε ένα /Length στο ίδιο λεξικό. Το λεξικό κρυπτογράφησης συνδυάζει το /Encrypt στο trailer με το /EncryptMetadata μέσα σε αυτό. Τα σύντομα κλειδιά είναι χειρότερα: μια αναζήτηση που έχει κατασκευαστεί ως Pos('/' + Key, ...) με Key = 'N' καταλήγει με χαρά στο /Name ή στο /Nums. Καμία από αυτές τις συγκρούσεις δεν απαιτεί κακοσχηματισμένο αρχείο. Ένας συγγραφέας που τοποθετεί το /Length1 πριν από το /Length είναι πλήρως συμβατός, πράγμα που σημαίνει ότι το σφάλμα υποσυμβολοσειράς δεν είναι ένα κενό στιβαρότητας έναντι κατεστραμμένων εισόδων — είναι ένα κενό ορθότητας έναντι έγκυρων εισόδων
Ο τρόπος αποτυχίας είναι επίσης αθόρυβος. Ένα λάθος /Length δεν προκαλεί εξαίρεση· σας δίνει ένα μικρότερο ή μεγαλύτερο κομμάτι byte από αυτό που πραγματικά καταλαμβάνει η ροή. Εάν αυτό το κομμάτι τροφοδοτεί έναν έλεγχο υποσυνόλου γραμματοσειράς, μια ανάλυση CMap ή μια σάρωση μεταδεδομένων, ο καταναλωτής βλέπει σκουπίδια και συνήθως δεν αναφέρει απολύτως τίποτα, επειδή μια μισή ροή zlib απλά αποτυγχάνει να αποσυμπιεστεί και ο κώδικας συνεχίζει. Στείλαμε στην παραγωγή ακριβώς αυτήν την κατηγορία ελαττώματος και τη διορθώσαμε στην έκδοση v2.14.3 του κοινόχρηστου αναγνώστη μας, αφού ένας έλεγχος άρθρο προς άρθρο του ISO 32000-1 §7.2–§7.3 επισήμανε κάθε αναζήτηση κλειδιού στυλ Pos ως ύποπτη
Τι ορίζει πραγματικά το ISO 32000-1 §7.3.5 ως όνομα
Η παράγραφος 7.3.5 είναι σύντομη και ακριβής: ένα αντικείμενο ονόματος (name object) είναι μια κάθετος (solidus) ακολουθούμενη από μια σειρά κανονικών χαρακτήρων, και το διακριτικό τερματίζεται από τον πρώτο οριοθέτη ή χαρακτήρα κενού διαστήματος. Οι οριοθέτες είναι οι οκτώ χαρακτήρες αγκυλών συν την κάθετο και το σύμβολο του ποσοστού — ( ) < > [ ] { } / % — και το κενό διάστημα είναι ο μηδενικός χαρακτήρας, το tab, η αλλαγή γραμμής (line feed), η αλλαγή σελίδας (form feed), η επιστροφή φορέα (carriage return) και το διάστημα (§7.2.2–§7.2.3). Αυτός ο κανόνας τερματισμού είναι όλη η ιστορία. Το /Length1 δεν είναι το "/Length ακολουθούμενο από ένα 1"· είναι ένα ενιαίο, αδιαίρετο διακριτικό, ακριβώς όπως τα LengthOne και Length are different identifiers in Pascal. Any reader that finds keys by raw byte search is reimplementing the lexer with the termination rule deleted
Εδώ είναι το σχήμα του ελαττώματος, περιορισμένο στα ουσιώδη. Αυτή η έκδοση μεταγλωττίζεται, περνά τις δοκιμές έναντι αρχείων των οποίων οι συγγραφείς τοποθετούν το /Length πρώτο, και καταστρέφει τις ροές για συγγραφείς που δεν το κάνουν
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Ταυτοποίηση ολόκληρου του διακριτικού: ελέγξτε το byte μετά το κλειδί
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
Δύο λεπτομέρειες σε αυτόν τον βρόχο έχουν βάρος. Πρώτον, συνεχίζει την αναζήτηση αντί να επιστρέψει αποτυχία στην πρώτη σύγκρουση προθέματος, επειδή το /Length1 120 /Length 4076 είναι μια νόμιμη σειρά και το πραγματικό κλειδί είναι ακόμα πιο μπροστά. Δεύτερον, η περίπτωση τέλους του buffer μετράει ως τερματιστής, αφού ένα τμήμα λεξικού μπορεί νόμιμα να τελειώνει αμέσως μετά από ένα όνομα. Ένα πιο λεπτό σημείο που αξίζει να ελέγξετε στον δικό σας κώδικα: ο ίδιος κανόνας ισχύει στην αριστερή πλευρά της ταύτισης εάν η συμβολοσειρά αναζήτησής σας δεν περιλαμβάνει την κάθετο, διαφορετικά η Pos('Length', ...) μπορεί να προσγειωθεί μέσα στο /PieceLength. Η αγκύρωση της συμβολοσειράς αναζήτησης με την αρχική /, όπως παραπάνω, χειρίζεται το αριστερό άκρο επειδή η / είναι η ίδια ένας οριοθέτης που τερματίζει το προηγούμενο διακριτικό
Πώς μπορεί ένα εχθρικό PDF να μετατρέψει ένα σφάλμα αναλυτή σε εκχώρηση ενός gigabyte;
Ένα κακοσχηματισμένο ή κακόβουλο αρχείο κλιμακώνει αυτά τα λεκτικά λάθη σε εξάντληση πόρων, επειδή οι ακέραιοι των λεξικών συχνά τροφοδοτούν μεγέθη εκχώρησης μνήμης. Ο έλεγχός μας βρήκε μια αλυσίδα ακριβώς αυτού του σχήματος στην επέκταση ροής αντικειμένων (object-stream expansion). Η καταχώρηση /N ενός λεξικού ObjStm λέει πόσα συμπιεσμένα αντικείμενα περιέχει η ροή, και ο κώδικας επέκτασης καλούσε τη SetLength σε έναν πίνακα με μέγεθος που καθοριζόταν από αυτήν. Ο αναλυτής ακεραίων, ωστωσο, άφηνε την παράμετρο εξόδου του ανέπαφη σε περίπτωση αποτυχίας ενώ εξακολουθούσε να την επιστρέφει — έτσι ένα μη αριθμητικό /N παρέδιδε στη SetLength μια μη αρχικοποιημένη τιμή στοίβας. Ένας τυχαίος θετικός ακέραιος εκεί σημαίνει ένα αίτημα εκχώρησης μνήμης σε gigabytes, που ενεργοποιείται από λίγα byte κατεστραμμένης εισόδου, ενώ απλώς σαρώνετε ένα έγγραφο που δεν έχετε καν συμφωνήσει να εμπιστευτείτε ακόμη
Η επισκευή είχε δύο ανεξάρτητα μέρη, και τα δύο γενικεύονται. Ο αναλυτής επιστρέφει τώρα ένα ρητό 0 σε περίπτωση αποτυχίας, ποτέ μη αρχικοποιημένη μνήμη. Και ο καταναλωτής δεν εμπιστεύεται πλέον το /N χωρίς αριθμητικό έλεγχο: η περιοχή κεφαλίδας ObjStm πριν από το /First αποθηκεύει ένα ζεύγος ακεραίων — αριθμό αντικειμένου και μετατόπιση — για κάθε συμπιεσμένο αντικείμενο, και κάθε ζεύγος καταλαμβάνει τουλάχιστον τέσσερα byte συμπεριλαμβανομένων των διαχωριστικών. Οποιοδήποτε /N πάνω από το FirstVal div 4 + 1 είναι επομένως φυσικά αδύνατο για το δηλωμένο μέγεθος κεφαλίδας και απορρίπτεται πριν συμβεί οποιαδήποτε εκχώρηση μνήμης. Το όριο κοστίζει μία σύγκριση και προέρχεται από δεδομένα που έχουμε ήδη στα χέρια μας, που είναι το μοτίβο που πρέπει να αναζητούμε: ένα ανώτατο όριο που αποδεικνύει το ίδιο το αρχείο, όχι μια αυθαίρετη σταθερά
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
Δύο ακόμα οροφές συμπληρώνουν την αμυντική περίμετρο στον αναγνώστη μας, και οι δύο στάλθηκαν στην έκδοση v2.12.0. Ο αναγνώστης ροής απορρίπτει οποιοδήποτε /Length μεγαλύτερο από ολόκληρο το αρχείο πριν εκχωρήσει το buffer αποτελεσμάτων — μια ροή δεν μπορεί να είναι μεγαλύτερη από το κοντέινερ στο οποίο ζει, οπότε ο έλεγχος είναι απαλλαγμένος από ψευδώς θετικά αποτελέσματα. Και η διαδρομή αποσυμπίεσης (inflate) περιορίζει το αποσυμπιεσμένο αποτέλεσμα στα 256 MiB, γεγονός που εξουδετερώνει την κλασική βόμβα zlib όπου λίγα kilobyte εισόδου επεκτείνονται χωρίς όριο· το όριο είναι γενναιόδωρο για οποιαδήποτε πραγματική ροή PDF ενώ διατηρεί την χειρότερη περίπτωση επιβιώσιμη. Το θέμα και στα τρία είναι το ίδιο: κάθε μέγεθος που δηλώνει ένα αρχείο είναι ένας ισχυρισμός, και ο αναλυτής επαληθεύει κάθε ισχυρισμό έναντι κάποιου μεγέθους που μπορεί να μετρήσει πριν δεσμεύσει μνήμη για αυτόν. Η ίδια στάση ελέγχου εφαρμόζεται ένα επίπεδο πιο κάτω, στο όριο της σύνδεσης, το οποίο καλύπτεται στο άρθρο για την θωράκιση του PDFium ABI και την ασφάλεια μνήμης στο Delphi
Πού δεν αρκεί ο κανόνας του ολόκληρου διακριτικού
Ειλικρινή όρια, ώστε να μην εμπιστεύεστε υπερβολικά την παραπάνω διαδικασία. Η ταυτοποίηση ολόκληρου του διακριτικού διορθώνει τον εντοπισμό του κλειδιού, αλλά μια επίπεδη αναζήτηση byte σε ένα εύρος λεξικού εξακολουθεί να μην μπορεί να διακρίνει εάν μια ταύτιση βρίσκεται μέσα σε ένα φωλιασμένο λεξικό, μια κυριολεκτική συμβολοσειρά ή ένα σχόλιο — η FindDictKey σε ένα αντικείμενο σελίδας μπορεί να προσγειωθεί σε ένα κλειδί μέσα στο υπολεξικό της /Resources εάν της δώσετε πολύ μεγάλο εύρος. Ο αναγνώστης μας περιορίζει πρώτα το εύρος σε ένα ενιαίο σώμα αντικειμένου και αντιμετωπίζει τα περιβάλλοντα συμβολοσειρών και σχολίων ως ξεχωριστό, ακόμα ανοιχτό θέμα ελέγχου. Η ασφάλεια υποσυμβολοσειρών είναι ένα σκαλί μιας σκάλας, όχι ολόκληρη η σκάλα: η συνέπεια των παραπομπών (cross-reference consistency) είναι η δική της πειθαρχία, που καλύπτεται στο επικύρωση αντικειμένων και ροών xref, και ο ευρύτερος κατάλογος απειλών για έγγραφα που δεν έχετε συντάξει εσείς βρίσκεται στο έλεγχος κινδύνων ασφαλείας PDF
Εάν συντηρείτε έναν χειροκίνητα γραμμένο αναγνώστη λεξικών σε Delphi ή Lazarus, η λίστα ελέγχου από αυτό το περιστατικό είναι σύντομη. Αναζητήστε με grep κάθε Pos('/ στη βάση κώδικα και δρομολογήστε τα αποτελέσματα μέσω ενός βοηθού ολόκληρου διακριτικού. Καταγράψτε τις οικογένειες προθεμάτων στις οποίες συμμετέχουν τα κλειδιά σας — τα /Length, /Encrypt, /N, /Type έναντι του /Type1 εμφανίζονται όλα σε πραγματικά αρχεία. Στη συνέχεια, διατρέξτε κάθε ακέραιο που φτάνει στη SetLength, στη GetMem ή σε έναν βρόχο αντιγραφής και αναρωτηθείτε τι τον περιορίζει: το μέγεθος του αρχείου, ένα ανώτατο όριο που προέρχεται από την κεφαλίδα ή τίποτα. Το επίπεδο ανάλυσης που περιγράφεται εδώ είναι το θεμέλιο κάτω από το δικό μας PDFium Component, όπου ο αναγνώστης επιπέδου byte και η μηχανή απόδοσης διασταυρώνουν τα στοιχεία τους σε κάθε έγγραφο που αγγίζουν