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

PDF Decode Bombs σε Delphi: Budgets Αλυσίδας Φίλτρων HotPDF

Ένα PDF 20 KB που καρφώνει μια διεργασία υπηρεσίας μέχρι να την πάρει ο OOM killer δεν είναι bug στον κώδικά σας, είναι βόμβα αποσυμπίεσης. Το HotPDF, το εγγενές VCL component PDF για Delphi και C++Builder, οριοθετεί μία με το DecodeBudgetBytes, ένα ανώτατο όριο ανά αλυσίδα φίλτρων που έχει προεπιλογή 268435456 bytes και χρεώνει κάθε στάδιο αποκωδικοποίησης σε ένα ενιαίο κοινό budget

Το αρχείο 20 KB που έφαγε μια διεργασία worker

Το σχήμα του περιστατικού είναι πάντα το ίδιο. Ένας worker ουράς που αποδίδει thumbnails παίρνει ένα upload, η μνήμη που χρησιμοποιεί σκαρφαλώνει πάνω από 12 GB σε λιγότερο από δύο δευτερόλεπτα, και η διεργασία εξαφανίζεται χωρίς stack trace. Το αρχείο είναι 20 KB. Έχει μία σελίδα, ένα ρεύμα περιεχομένου, και έναν πίνακα /Filter με πέντε καταχωρίσεις. Κάθε όνομα σε αυτόν τον πίνακα είναι φίλτρο που ορίζει η προδιαγραφή, κάθε στάδιο αποκωδικοποιεί χωρίς σφάλμα, και τίποτα στο αρχείο δεν είναι κακοσχηματισμένο. Αυτό είναι που κάνει αυτή την κατηγορία εισόδου δύσκολη: δεν υπάρχει κατεστραμμένο byte να απορριφθεί

Αυτό δεν είναι το ίδιο πρόβλημα με τη σωστή αποκωδικοποίηση ενός μεμονωμένου φίλτρου. Το να κάνετε σωστά το LZWDecode και τον predictor /DecodeParms είναι δικό του θέμα, που καλύπτεται στην περιγραφή LZW, predictors και DecodeParms σε φορτωμένα έγγραφα. Εδώ κάθε αποκωδικοποιητής είναι ήδη σωστός. Η αποτυχία είναι τι κάνουν οι σωστοί αποκωδικοποιητές όταν τρέχετε πέντε στη σειρά και κανείς δεν μετράει το σύνολο. Το ISO 32000-1 §7.4 είναι ρητό ότι το /Filter μπορεί να είναι ένα μόνο όνομα ή πίνακας ονομάτων, και ότι ένας πίνακας εφαρμόζεται με σειρά, πρώτη καταχώριση πρώτη. Δεν λέει τίποτα για το πόσο μπορεί ένα στάδιο να διευρύνει την είσοδό του, και τίποτα για το άθροισμα κατά μήκος της αλυσίδας. Ένα στάδιο ASCIIHexDecode περίπου υποδιπλασιάζει την είσοδό του, που ακούγεται αβλαβές. Ένα στάδιο FlateDecode πάνω σε μια σειρά μηδενικών bytes φτάνει σε λόγους της τάξης των χιλιάδων. Αλυσιδώστε τα και η αριθμητική είναι πολλαπλασιαστική: τα 20 KB γίνονται 20 MB γίνονται 20 GB, και κάθε μεμονωμένο βήμα είναι μια συμμορφούμενη αποκωδικοποίηση ενός νόμιμου stream

Γιατί ένα όριο ανά φίλτρο αποτυγχάνει να σταματήσει μια decode bomb;

Επειδή ένα όριο ανά φίλτρο επανοπλίζεται σε κάθε στοιχείο του πίνακα /Filter. Μια αλυσίδα πέντε σταδίων κάτω από ένα όριο ανά στάδιο 256 MiB εξουσιοδοτεί 1,25 GiB, και το τελικό στάδιο εξακολουθεί να ξεκινά με μια εντελώς φρέσκια επιτρεπόμενη ποσότητα ανεξάρτητα από το τι παρήγαγαν τα προηγούμενα τέσσερα. Το όριο επιβάλλεται με ειλικρίνεια και δεν περιορίζει τίποτα σημαντικό. Το HotPDF είχε ακριβώς αυτό το σχήμα πριν την v2.447.0, και είχε και δεύτερο κενό δίπλα του. Ο αποσυμπιεστής LZW έφερε ένα όριο MaxOutputBytes και η διαδρομή predictor εικόνας λογάριαζε τις δικές της γραμμές, οπότε αυτά τα δύο ήταν τοπικά οριοθετημένα. Τα FlateDecode, ASCIIHexDecode, ASCII85Decode και RunLengthDecode δεν είχαν κανένα όριο: καθένα έγραφε σε ένα TMemoryStream μέχρι να εξαντλήσει την είσοδο ή να τα παρατήσει ο allocator. Οπότε μια εχθρική αλυσίδα είχε δύο τρόπους διέλευσης. Μπορούσε να χρησιμοποιήσει ένα εντελώς αφύλακτο φίλτρο, ή μπορούσε να χρησιμοποιήσει φυλαγμένα και απλώς να προσθέσει περισσότερα από αυτά

Υπάρχει μια τρίτη λεπτομέρεια που μια αφελής διόρθωση χάνει. Ο αριθμός που σας ενδιαφέρει δεν είναι το μέγεθος της τελικής αποκωδικοποιημένης εξόδου. Είναι η αιχμή, και η αιχμή συνήθως ζει σε ένα ενδιάμεσο buffer. Μια αλυσίδα που καταλήγει σε ένα μέτριο ρεύμα περιεχομένου 4 MB μπορεί να δεσμεύσει 8 GB στο στάδιο τρία και να επιστρέψει κάτι που φαίνεται εντελώς λογικό. Ο έλεγχος του μήκους του αποτελέσματος εκ των υστέρων δεν σας λέει τίποτα για τη δέσμευση που σκότωσε τη διεργασία

Ένας παρακολουθητής budget ανά αλυσίδα φίλτρων

Η διόρθωση στο HotPDF v2.447.0 είναι να κάνει τη λογιστική να καλύπτει την αλυσίδα αντί για το στάδιο. Κάθε αλυσίδα φίλτρων κατασκευάζει έναν THPDFDecodeBudgetTracker, και κάθε αποκωδικοποιητής γράφει μέσω ενός THPDFBudgetWriteStream που τυλίγει τον πραγματικό στόχο. Ο wrapper καλεί το Budget.Consume(Count) πριν προωθήσει ούτε ένα byte, οπότε η άρνηση συμβαίνει ενώ το stream στόχος έχει ακόμη το παλιό του μέγεθος. Αυτή η σειρά είναι όλο το νόημα: ένας έλεγχος που εκτελείται αφού το buffer έχει ήδη μεγαλώσει είναι διάγνωση, όχι άμυνα

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

Τα τοπικά όρια δεν εξαφανίστηκαν, έγιναν προβολές του κοινού budget. Το στάδιο LZW τώρα ορίζει Decoder.MaxOutputBytes := Budget.RemainingBytes, οπότε το ιδιωτικό του ανώτατο όριο είναι ό,τι έχει απομείνει στην αλυσίδα αντί για ανεξάρτητη επιτρεπόμενη ποσότητα. Το στάδιο predictor εικόνας ανοίγει με BeginFilter και χρεώνει την απαίτηση γραμμών του μέσω του Consume πριν δεσμεύσει, που σημαίνει ότι η έξοδος predictor χρεώνεται στο ίδιο budget με τα γενικά φίλτρα που το τροφοδότησαν. Αυτό έχει ιδιαίτερη σημασία στη διαδρομή εικόνας, όπου η αλυσίδα φίλτρων και ο predictor είναι δύο μισά μίας λειτουργίας, όπως καλύπτεται στο εξαγωγή εικόνων από φορτωμένα έγγραφα μέσω των φίλτρων αποκωδικοποίησής τους

Τι βλέπει ο καλών όταν το budget αρνείται;

Στη βάση της στοίβας, μια άρνηση προκαλεί EHPDFDecodeBudgetError. Πάνω από αυτό, η απάντηση εξαρτάται από το συμβόλαιο που είχε ήδη το καλούμενο API. Οι μέθοδοι ανάγνωσης υψηλού επιπέδου που ανέφεραν αποτυχία μέσω False ή nil συνεχίζουν να κάνουν ακριβώς αυτό, επειδή η μετατροπή ενός τεκμηριωμένου boolean αποτελέσματος σε εξαίρεση θα έσπαγε καλούντες που ήδη χειρίζονταν σωστά κακοσχηματισμένη είσοδο. Η διαδρομή περιεχομένου φορτωμένης σελίδας είναι η σκόπιμη εξαίρεση: επαναπροκαλεί το EHPDFDecodeBudgetError αντί να αφήσει ένα περικομμένο ρεύμα περιεχομένου να αποδοθεί ως σελίδα που απλώς βγήκε κενή. Αυτός ο σχεδιασμός σημαίνει ότι ένα γυμνό False είναι διφορούμενο από μόνο του, οπότε το budget δημοσιεύει μια εγγραφή διάγνωσης δίπλα του: η THotPDF.GetLastDecodeBudgetInfo επιστρέφει την κατάσταση της πιο πρόσφατης αλυσίδας που αποκωδικοποίησε το instance

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

Διαβάστε αυτά τα πεδία μαζί και διαχωρίζουν τα δύο σχήματα επίθεσης. Όταν το PeakStageBytes είναι κοντά στο DecodedBytes, ένα στάδιο έκανε όλη τη ζημιά και κοιτάτε ένα μεμονωμένο φίλτρο υψηλού λόγου. Όταν το PeakStageBytes είναι μικρό κλάσμα του DecodedBytes και το FilterCount είναι υψηλό, κανένα μεμονωμένο στάδιο δεν ήταν εξωφρενικό και η αλυσίδα συσσωρεύτηκε πέρα από το ανώτατο όριο, που είναι ακριβώς η περίπτωση που ένα όριο ανά φίλτρο δεν μπορεί να δει. Μια προειδοποίηση αξίζει να γράψετε στον handler σας: η GetLastDecodeBudgetInfo επιστρέφει False μέχρι το instance να έχει αποκωδικοποιήσει τουλάχιστον ένα φίλτρο, οπότε ένα False από αυτήν δεν είναι απόδειξη ότι το έγγραφο ήταν καθαρό

Πού επαναφέρεται το budget, και πότε το μηδέν είναι η ειλικρινής απάντηση

Το DecodeBudgetBytes οριοθετεί μία αλυσίδα stream, όχι ένα έγγραφο, και αυτό το όριο είναι σκόπιμο αλλά εύκολο να παρερμηνευτεί. Κάθε ρεύμα περιεχομένου, κάθε ενσωματωμένο αρχείο, κάθε cross-reference stream και κάθε object stream ξεκινά με φρέσκα 256 MiB. Ένα έγγραφο 4.000 σελίδων επομένως έχει 4.000 ανεξάρτητες ευκαιρίες να ξοδέψει ολόκληρο το ανώτατο όριο, και τα object streams πολλαπλασιάζουν περαιτέρω τον αριθμό επειδή καθένα είναι το ίδιο ένα συμπιεσμένο container που κρατά πολλά αντικείμενα, όπως περιγράφεται στις σημειώσεις για τα object streams και τις αυξητικές ενημερώσεις. Αν η πραγματική σας απαίτηση είναι ένα όριο στη συνολική μνήμη διεργασίας, αυτή η ιδιότητα είναι μία είσοδος σε αυτό, όχι όλο, και θα έπρεπε να βρίσκεται πίσω από ένα ανώτατο όριο επιπέδου εργασίας ή container

Μηδέν σημαίνει απεριόριστο, και είναι μια νόμιμη ρύθμιση παρά μια πόρτα διαφυγής. Ορίστε το όταν κατέχετε την είσοδο: μια pipeline επανεπεξεργασίας αρχείου πάνω σε έγγραφα που παρήγαγε το δικό σας σύστημα, ή ένα βήμα rasterization όπου μια μεμονωμένη έγχρωμη σάρωση αλυσίδας 600 dpi γνησίως χρειάζεται περισσότερο από όποιο ανώτατο όριο θα νιώθατε άνετα να ορίσετε σκληρά. Οι αρνητικές τιμές απορρίπτονται εκ των προτέρων με ERangeError, επειδή ένα αρνητικό budget δεν έχει καμία συνεκτική σημασία και το σιωπηλό περιορισμό του θα έκρυβε ένα σφάλμα διαμόρφωσης

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

Η επιλογή του αριθμού αξίζει περισσότερη προσοχή απ' ό,τι συνήθως παίρνει, επειδή ένα budget ορισμένο πολύ χαμηλά είναι αυτοπροκαλούμενη διακοπή λειτουργίας. Τρέξτε το υπάρχον corpus σας με την προεπιλογή, καταγράψτε το PeakStageBytes και το DecodedBytes για κάθε αλυσίδα, και ορίστε το ανώτατο όριο πάνω από το παρατηρημένο μέγιστο με πραγματικό περιθώριο. Ένας στρογγυλός αριθμός επιλεγμένος επειδή ακουγόταν ασφαλής θα απορρίψει μια νόμιμη μεγάλη σάρωση την χειρότερη δυνατή στιγμή, και η αποτυχία θα μοιάζει ακριβώς με επίθεση στα logs σας

Η αντιγραφή που δεν συμβαίνει πια

Η δρομολόγηση κάθε σταδίου μέσω ενός budget wrapper αποδείχθηκε ότι κάνει την αλυσίδα φθηνότερη αντί για ακριβότερη. Όταν ένα stream έχει φίλτρα, το πρώτο στάδιο τώρα διαβάζει το stream πηγής απευθείας αντί να αντιγράφει πρώτα τα κωδικοποιημένα bytes σε ένα προσωρινό buffer, και από εκεί μόνο δύο buffers είναι ζωντανά ταυτόχρονα: η τρέχουσα είσοδος και η έξοδος σταδίου που γράφεται. Η ακατέργαστη αντιγραφή επιβιώνει στις δύο περιπτώσεις που τη χρειάζονται, δηλαδή ένα stream χωρίς κανένα φίλτρο και μια εικόνα όπου ο καλών θέλει να διατηρηθεί η τελευταία κωδικοποίηση, επειδή και οι δύο επιστρέφουν ένα stream που ο καλών κατέχει και μπορεί να αναζητήσει ανεξάρτητα. Η αφύλακτη έκδοση αυτού του κώδικα δέσμευε περισσότερο και οριοθετούσε λιγότερο, που είναι η συνηθισμένη σχέση μεταξύ των δύο. Αξίζει να ειπωθεί ξεκάθαρα, ωστόσο: τίποτα από αυτά δεν κάνει ένα οποιοδήποτε PDF ασφαλές να φορτωθεί. Κλείνει ένα συγκεκριμένο και πολύ φθηνό vector άρνησης υπηρεσίας, αυτό όπου ένα μικρό αρχείο αγοράζει μια μεγάλη δέσμευση μέσω εμφωλευμένων φίλτρων. Η υπερχείλιση ακεραίων στη λογιστική byte φυλάσσεται ξεχωριστά, και το ευρύτερο ερώτημα της ανάλυσης εχθρικών εγγράφων χωρίς εμπιστοσύνη στις εσωτερικές τους θέσεις είναι διαφορετική πειθαρχία. Ένα budget αποκωδικοποίησης είναι ένα όριο ανάμεσα σε πολλά, και η αξία του είναι ότι είναι αυτό που μπορείτε να ορίσετε από μία μόνο ιδιότητα πριν αγγίξετε το αρχείο

Το budget ανά αλυσίδα, η εγγραφή διαγνωστικών του, και οι διαδρομές αποκωδικοποίησης φορτωμένου εγγράφου που προστατεύει διατίθενται όλα ως μέρος του ίδιου του component, χωρίς εξωτερική εξάρτηση αποσυμπίεσης να διαμορφωθεί ή να επιδιορθωθεί. Αν αξιολογείτε πώς να οριοθετήσετε μη αξιόπιστη είσοδο PDF μέσα σε μια υπηρεσία Delphi ή C++Builder, η σελίδα του HotPDF Delphi PDF component αναφέρει το εργαλειοκιτίο φορτωμένου εγγράφου στο οποίο εφαρμόζονται αυτά τα όρια