Technical Article

Θωράκιση ενός αναλυτή PDF σε Pascal έναντι κακόβουλων αρχείων

Ένα PDF δεν είναι απλώς ένα έγγραφο που ανοίγετε. Είναι ένα μικρό πρόγραμμα που εκτελείτε. Κάθε ενσωματωμένη γραμματοσειρά είναι ένας διερμηνέας (interpreter) βασισμένος σε στοίβα που περιμένει charstrings, κάθε εικόνα είναι ένας αποκωδικοποιητής που τροφοδοτείται με πεδία πλάτους, ύψους και βάθους bit που επέλεξε το αρχείο, και κάθε ροή φτάνει τυλιγμένη σε φίλτρα των οποίων τις παραμέτρους όρισε το αρχείο. Κανένας από αυτούς τους αριθμούς δεν είναι δικός σας. Προήλθαν από όποιον παρήγαγε το αρχείο, το οποίο σε έναν πραγματικό φόρτο εργασίας είναι το τιμολόγιο ενός πελάτη ή ένα συνημμένο από άγνωστο αποστολέα. Οι αποκωδικοποιητές που μετατρέπουν αυτά τα byte σε εικονοστοιχεία και γλύφους είναι η επιφάνεια επίθεσης, και ένας αναλυτής (parser) που εμπιστεύεται την είσοδό του εκεί απέχει ένα κακοσχηματισμένο αρχείο από μια κατάρρευση ή κάτι χειρότερο.

Το PDFlibPas πέρασε από μια διαδικασία θωράκισης που αντιμετώπισε ολόκληρη τη διαδρομή αποκωδικοποίησης ως εχθρική, σε όλα τα προγράμματα γραμματοσειρών (TrueType, Type1, CFF και τους πίνακες CMap), τους αποκωδικοποιητές εικόνων (PNG, GIF, TIFF, JBIG2 και CCITT Group 3 και Group 4), και τα φίλτρα ροής (LZW, ASCII85 και τους Flate predictors). Αυτό που ακολουθεί είναι πέντε κατηγορίες ελαττωμάτων που έκλεισαν, καθεμία βασισμένη στη συγκεκριμένη συμπεριφορά του Delphi που την έκανε εφικτή. Έχουν διορθωθεί στις τρέχουσες εκδόσεις, και οι ίδιες μορφές επαναλαμβάνονται σε οποιονδήποτε κώδικα Pascal αναλύει μη έμπιστη είσοδο.

Μια υπερχείλιση ακεραίου που σας παραδίδει ένα buffer μικρότερου μεγέθους

Το κλασικό σφάλμα ασφάλειας μνήμης σε έναν αποκωδικοποιητής εικόνας είναι ένα γινόμενο διαστάσεων που αναδιπλώνεται (wraps). Ένας αποκωδικοποιητής διαβάζει το πλάτος, το ύψος, τον αριθμό στοιχείων και το βάθος bit, τα πολλαπλασιάζει για να ορίσει το μέγεθος της εξόδου του, εκχωρεί τόσα byte, και στη συνέχεια γράφει την εικόνα στις πραγματικές της διαστάσεις. Εάν ο πολλαπλασιασμός γίνει με αριθμητική 32 bit, το γινόμενο μπορεί να αναδιπλωθεί σε μια μικρή τιμή ακόμα και όταν κάθε μεμονωμένος παράγοντας είναι εντός λογικού εύρους, οπότε η κατανομή επιτυγχάνει, προκύπτει πολύ μικρή, και η αποκωδικοποίηση ξεπερνά το τέλος της. Αυτό είναι το CWE-190 (υπερχείλιση ακεραίου), που οδηγεί σε εγγραφή εκτός ορίων σωρού (CWE-787) ένα βήμα αργότερα.

Η κοινή διαδρομή εικόνας περιόριζε ήδη κάθε διάσταση στο 65535. Οι αυτόνομοι αποκωδικοποιητές δεν κληρονόμησαν όλοι αυτόν τον περιορισμό. Μια έκφραση row-bytes-times-height όπως η ByteCount * FHeight, ή μια έκφραση ανά εικονοστοιχείο όπως η FWidth * Components * BitDepth, είναι ένα γινόμενο 32 bit στο Delphi όταν και οι δύο τελεστέοι είναι ακέραιοι 32 bit, ανεξάρτητα από το πόσο ευρεία είναι η μεταβλητή στην οποία εκχωρείτε το αποτέλεσμα. Ένα πλάτος και ένα ύψος 60000 είναι το καθένα εύλογο για μια μεγάλη σάρωση, αλλά το γινόμενό τους σε byte υπερβαίνει το προσημασμένο εύρος 32 bit και το μήκος προκύπτει μικρό. Η ίδια παγίδα υπήρχε στο βήμα (stride) του ZLib predictor, BitsPerComponent * Colors * Columns.

Η διόρθωση είναι να γίνει τουλάχιστον ένας τελεστέος Int64, ώστε ολόκληρη η έκφραση να αξιολογηθεί σε 64 bit, στη συνέχεια να γίνει σύγκριση με το MaxInt και να απορριφθεί το αρχείο πριν από τη μείωση του πλάτους για την κλήση της SetLength.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

Ένας τύπος πεδίου που καθιστά αδύνατη την ενεργοποίηση ενός φύλακα

Ένα αρχείο TIFF είναι μια αλυσίδα καταλόγων αρχείων εικόνας (image file directories - IFD), καθένας από τους οποίους μεταφέρει τη μετατόπιση byte του επόμενου. Ένα κακόβουλο αρχείο μπορεί να στρέψει αυτή την αλυσίδα πίσω στον εαυτό της, και ένας αναγνώστης που τη διασχίζει χωρίς συνθήκη διακοπής εκτελείται επ' άπειρον. Αυτό είναι το CWE-835, ένας ατέρμων βρόχος που οδηγείται από είσοδο ελεγχόμενη από τον επιτιθέμενο, και η άμυνα είναι ένας μετρητής που σταματά μόλις ξεπεράσει ένα όριο που κανένα νόμιμο αρχείο δεν θα έφτανε.

Ο μετρητής σελίδων είχε δηλωθεί ως Word, το οποίο στο Delphi κρατά τιμές από 0 έως 65535. Ο βρόχος έφερε έναν φύλακα τερματισμού της μορφής "σταμάτα όταν ο αριθμός σελίδων υπερβεί το 65535", ο οποίος φαίνεται σωστός μέχρι να παρατηρήσετε ότι ο τελεστέος και το όριο μοιράζονται το ίδιο ανώτατο όριο. Ένας τύπος Word δεν μπορεί ποτέ να είναι μεγαλύτερος από 65535, οπότε η σύγκριση είναι δομικά πάντα ψευδής: όταν ο μετρητής φτάσει στο 65535 η επόμενη αύξηση τον αναδιπλώνει πίσω στο 0, ο φύλακας δεν βλέπει ποτέ τιμή πάνω από την οροφή, και μια αλυσίδα IFD που σχηματίζει βρόχο κρατά τον αναγνώστη να περιστρέφεται συνεχώς.

Η διόρθωση ήταν η διεύρυνση του πεδίου, ώστε ο φύλακας να μπορεί να εκφράσει μια τιμή που ο μετρητής μπορεί πραγματικά να κρατήσει. Με την TPDFTIFF.FPageCount δηλωμένη ως Integer, η ίδια σύγκριση FPageCount > 65535 γίνεται προσβάσιμη, ο βρόχος τερματίζεται, και η δημόσια ιδιότητα PageCount άλλαξε τύπο για να ταιριάζει χωρίς να σπάσει κανέναν καλούντα. Κάθε φορά που ένας έλεγχος ορίων έχει τη μορφή Value > MaxValueOfType(Value) και ο τελεστέος έχει ήδη τον τύπο ακριβώς αυτού του μέγιστου, η συνθήκη είναι μια σταθερά ψευδής: διευρύνετε τον τύπο, ή ελέγξτε την ισότητα έναντι του μέγιστου ώστε να μπορεί να ενεργοποιηθεί.

Ο έλεγχος εύρους απενεργοποιημένος σε μια κρίσιμη διαδρομή (hot path)

Με τον έλεγχο εύρους (range checking) ενεργοποιημένο, το Delphi εισάγει έναν έλεγχο ορίων σε κάθε δείκτη πίνακα και συμβολοσειράς, ο οποίος αποτελεί τη διαφορά μεταξύ ενός δείκτη εκτός εύρους που εγείρει μια συλλήψιμη ERangeError και του ίδιου δείκτη που διαβάζει ή γράφει μνήμη που δεν ανήκει στη δομή. Οι κρίσιμες διαδρομές (hot paths) μερικές φορές τον απενεργοποιούν με μια τοπική οδηγία {$R-}, η οποία είναι υπερασπίσιμη μέχρι τη στιγμή που οι δείκτες παύουν να είναι αξιόπιστοι.

Ο προσπελαστής λίστας (list accessor) στον οποίο βασίζονται οι διερμηνείς γραμματοσειρών, ο TPDFlibStringList.Get, είναι ακριβώς μια τέτοια διαδρομή. Στα Windows μεταγλωττίζεται με τον έλεγχο εύρους απενεργοποιημένο και ευρετηριάζει το backing store του απευθείας, οπότε ένας δείκτης εκτός εύρους δεν είναι σφάλμα αλλά μια πρωτογενής πρόσβαση στη μνήμη. Αυτό είναι εντάξει όταν ο δείκτης είναι πάντα έγκυρος, και παύει να είναι εντάξει μέσα σε έναν διερμηνέα CFF ή Type2 charstring, όπου ο δείκτης μπορεί να προέρχεται από το αρχείο. Ένα charstring που αφαιρεί (pops) έναν τελεστέο από μια άδεια στοίβα παράγει έναν δείκτη με τιμή μείον ένα. Ένα αναγνωριστικό γλύφου που αποκλίνει κατά ένα έναντι του πλήθους γλύφων ευρετηριάζει μία θέση μετά το τέλος. Με τον έλεγχο εύρους απενεργοποιημένο, και τα δύο γίνονται πραγματική πρόσβαση εκτός ορίων αντί για μια συλλήψιμη εξαίρεση, και επειδή οι θέσεις κρατούν τιμές AnsiString με μέτρηση αναφορών, μια τυχαία ανάγνωση μπορεί επίσης να καταστρέψει τη μέτρηση αναφορών μιας συμβολοσειράς.

Η θωράκιση δεν ενεργοποίησε ξανά τον έλεγχο εύρους για την κρίσιμη διαδρομή. Κατέστησε πρώτα τους δείκτες αποδεδειγμένα έγκυρους: πριν πάρει την κορυφή της στοίβας τελεστέων, ο διερμηνέας ελέγχει ότι η στοίβα δεν είναι άδεια, και κάθε φύλακας δείκτη γράφτηκε ως αυστηρά μικρότερος από το πλήθος αντί για μικρότερος ή ίσος που επιτρέπει το σφάλμα off-by-one. Η οδηγία μεταφέρει την ευθύνη για τα όρια από τον μεταγλωττιστή σε εσάς, και η επικύρωση που αφαίρεσε πρέπει να τοποθετηθεί ξανά με το χέρι σε κάθε σημείο εισόδου.

Απεριόριστη αναδρομή σε έναν διερμηνέα charstring

Ένα Type2 charstring μπορεί να καλέσει μια υπορουτίνα, και μια υπορουτίνα είναι η ίδια ένα charstring που μπορεί να καλέσει μια άλλη, οπότε οι τελεστές κλήσης τοπικών και καθολικών υπορουτινών επιτρέπουν στο αρχείο να αποφασίσει πόσο βαθιά θα πάει. Μια υπορουτίνα που καλεί τον εαυτό της, άμεσα ή μέσω ενός κύκλου, αναδρομεί χωρίς τέλος μέχρι να εξαντληθεί η στοίβα του συστήματος και η διεργασία να πεθάνει. Αυτό είναι το CWE-674, ανεξέλεγκτη αναδρομή.

Ο διερμηνέας Type1 προστατευόταν ήδη από αυτό. Έφερε έναν μετρητή βάθους κλήσης και μια οροφή, την PLType1MaxCallDepth, και αρνιόταν να κατέβει πέρα από αυτήν, γεγονός που αντανακλά το όριο βάθους που ορίζει η ίδια η προδιαγραφή Type1. Ο διερμηνέας Type2, που προστέθηκε αργότερα και είναι δομικά παρόμοιος, δεν έφερε τον ίδιο φύλακα, και μια χειροποίητη γραμματοσειρά με μια υπορουτίνα που καλεί τον δικό της αριθμό περνούσε κατευθείαν μέσα από τον ελλιπή έλεγχο σε υπερχείλιση στοίβας.

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

Μη αρχικοποιημένη μνήμη που διαρρέει στην έξοδο

Το πιο ανεπαίσθητο ελάττωμα διέρρεε περιεχόμενα του σωρού (heap) στην αποκρυπτογραφημένη έξοδο, και η αιτία είναι μια ιδιότητα της SetLength που ξεχνιέται εύκολα. Όταν μεγαλώνετε μια AnsiString με τη SetLength, το Delphi εκχωρεί τα byte αλλά δεν τα μηδενίζει, οπότε η νέα περιοχή κρατά ό,τι υπήρχε προηγουμένως σε αυτήν τη μνήμη του σωρού. Εάν κάθε byte γραφτεί στη συνέχεια, αυτό δεν έχει σημασία. Εάν μια διαδρομή αφήσει μέρος του buffer άγραφο και στη συνέχεια το επιστρέψει ως δεδομένα, αυτά τα παλιά byte εξέρχονται με το αποτέλεσμα. Αυτό είναι το CWE-457 (χρήση μη αρχικοποιημένης μνήμης), και όταν το αποτέλεσμα ξεπερνά ένα όριο εμπιστοσύνης γίνεται διαρροή πληροφοριών.

Η διαδρομή αποκρυπτογράφησης AES-CBC αντιμετώπισε ακριβώς αυτό. Το buffer εξόδου ορίστηκε με τη SetLength και ο αποκρυπτογραφητής επεξεργαζόταν το κρυπτογραφημένο κείμενο (ciphertext) ένα μπλοκ 16 byte τη φορά. Όταν το μήκος του κρυπτογραφημένου κειμένου δεν ήταν πολλαπλάσιο του 16, ένα μήκος που μπορεί να επιλέξει ένας επιτιθέμενος, το τελικό μερικό μπλοκ δεν γραφόταν ποτέ, οπότε αυτά τα τελευταία byte διατηρούσαν τα περιεχόμενα του σωρού που άφησε πίσω η SetLength και το buffer παραδιδόταν πίσω ως το αποκρυπτογραφημένο απλό κείμενο (plaintext) ενός αντικειμένου εγγράφου. Η θεραπεία είναι δύο φύλακες, και κανένας μόνος του δεν είναι αρκετός: το σημείο εισόδου αποκρυπτογράφησης απορρίπτει τώρα οποιοδήποτε κρυπτογραφημένο κείμενο του οποίου το μήκος δεν είναι πολλαπλάσιο του μεγέθους του μπλοκ, και ως ασπίδα προστασίας η έξοδος καθαρίζεται με FillChar πριν από τη χρήση, έτσι ώστε οποιαδήποτε διαδρομή αποτυγχάνει να γράψει μια περιοχή να επιστρέφει μηδενικά αντί για κατάλοιπα του σωρού.

Με τι σας αφήνει αυτή η διαδικασία

Τα πέντε ελαττώματα είναι διαφορετικά σφάλματα, αλλά μοιάζουν. Ένα πλάτος ακεραίου που αναδιπλώνει ένα γινόμενο, ένας τύπος πεδίου που καρφιτσώνει έναν φύλακα σε μια σταθερά ψευδή, ένας έλεγχος εύρους απενεργοποιημένος εκεί όπου οι δείκτες έπαψαν να είναι ασφαλείς, μια αναδρομή χωρίς όριο, και ένα buffer που η γλώσσα αρνήθηκε να μηδενίσει. Σε καθένα από αυτά το Delphi έκανε ακριβώς ό,τι ορίζει, επειδή η γλώσσα σάς δίνει αριθμητική που αναδιπλώνεται, σιωπηλή μείωση πλάτους, ελέγχους εύρους που μπορείτε να απενεργοποιήσετε, αναδρομή χωρίς ενσωματωμένο όριο, και κατανομή μνήμης που δεν αρχικοποιεί. Αυτό είναι το συμβόλαιο, και ένας αναλυτής Pascal το εκπληρώνει ελέγχοντας χειροκίνητα τέσσερα πράγματα σε κάθε όριο που ελέγχει το αρχείο: το πλάτος των ακεραίων, τον έλεγχο εύρους, το βάθος της αναδρομής και την αρχικοποίηση του buffer.

Αυτά τα ελαττώματα έχουν κλείσει στις τρέχουσες εκδόσεις του PDFlibPas, της μηχανής για Delphi και C++Builder. Εάν η εργασία σας επεκτείνεται επίσης στο πώς ένα αρχείο ισχυρίζεται ότι προστατεύεται, οι συνοδευτικές σημειώσεις σχετικά με τον έλεγχο κρυπτογράφησης και δικαιωμάτων και τον προκαταρκτικό έλεγχο (preflight) PDF/A και PDF/UA καλύπτουν την πλευρά της ανάλυσης του ίδιου αναλυτή, και όλα αυτά παρέχονται μέσα στη PDFlibPas Delphi PDF Library μαζί με τα API φόρτωσης, απόδοσης και υπογραφής που καλύπτονται σε άλλα σημεία αυτού του ιστολογίου.