Ένα αρχείο xlsx είναι ένα αρχείο ZIP, και το ZIP δεν έχει ενιαίο έγκυρο πίνακα περιεχομένων. Το HotXLS Excel Library για Delphi και C++Builder αντιμετωπίζει αυτή την ασάφεια ως επιφάνεια επίθεσης: ο parser end of central directory δέχεται έναν υποψήφιο εγγραφής μόνο αφού συμφωνήσουν τέσσερις ανεξάρτητοι διασταυρωμένοι έλεγχοι, οπότε ένας πλαστός κατάλογος κρυμμένος σε σχόλιο ZIP ποτέ δεν κερδίζει
Το σενάριο που το κάνει συγκεκριμένο είναι απλό. Ένας server δέχεται uploads υπολογιστικών φύλλων από πελάτες. Το αρχείο περνά έναν έλεγχο antivirus, γράφεται σε έναν κατάλογο spool, και η υπηρεσία σας Delphi το ανοίγει για να τραβήξει τρεις στήλες. Όλα φαίνονται εντάξει, εκτός που ο scanner και ο parser σας δεν συμφώνησαν για το τι περιείχε το αρχείο. Ο scanner απαρίθμησε ένα σύνολο μελών· ο loader σας απαρίθμησε διαφορετικό σύνολο από τα ίδια bytes. Κανένα από τα δύο δεν έχει bug με τη συνηθισμένη έννοια. Απλώς έλυσαν μια ασάφεια στη μορφή ZIP προς δύο διαφορετικές κατευθύνσεις, και ένας επιτιθέμενος επέλεξε τα bytes ώστε να το κάνουν
Πού ζει στην πραγματικότητα η αλήθεια για ένα αρχείο ZIP;
Ζει στο ίδιο το τέλος, σε μια δομή 22 bytes που ονομάζεται εγγραφή end of central directory. Ένα αρχείο ZIP δεν διαβάζεται από την αρχή προς το τέλος: κάθε μέλος φέρει μια τοπική κεφαλίδα αρχείου ακριβώς πριν από τα συμπιεσμένα δεδομένα του, αλλά ο έγκυρος δείκτης είναι ο κεντρικός κατάλογος, μια σειρά εγγραφών κοντά στο τέλος που ονομάζει κάθε καταχώριση και δίνει τη θέση της τοπικής κεφαλίδας της. Για να βρείτε τον κεντρικό κατάλογο πρέπει πρώτα να βρείτε το EOCD, επειδή το EOCD είναι αυτό που λέει πού ξεκινά ο κατάλογος και πόσες εγγραφές κρατά. Το HotXLS το μοντελοποιεί ως TEndOfCentralDirectoryRecord, του οποίου τα πεδία αντιστοιχίζονται ένα προς ένα στη διάταξη επί δίσκου: FDiskNumber στη θέση 4, FStartDisk στο 6, FThisDiskEntries στο 8, FTotalEntries στο 10, FSizeOfCD στο 12, FOffsetOfStartCD στο 16, και FCommentLen στο 20. Αυτό το σύνολο είναι το FMinSize, υπολογισμένο στον κατασκευαστή ως 4*3 + 5*2. Μετά έρχεται το σχόλιο αρχείου, έως 65535 bytes αυθαίρετου περιεχομένου, που κάνει το FMaxSize 65557 και σημαίνει ότι η εγγραφή δεν είναι σε σταθερή θέση. Πρέπει να πάτε να την ψάξετε
Γιατί η σάρωση προς τα πίσω για την υπογραφή EOCD δεν είναι αρκετή;
Επειδή τα τέσσερα bytes που σαρώνετε, PK\005\006, μπορούν νόμιμα να εμφανιστούν μέσα στο σχόλιο αρχείου, μέσα σε συμπιεσμένα δεδομένα, ή μέσα σε ένα δεύτερο EOCD που πρόσθεσε επίτηδες ένας επιτιθέμενος. Ένας parser που σταματά στην πρώτη υπογραφή που συναντά περπατώντας προς τα πίσω είναι τετριμμένα κατευθυνόμενος: τοποθετήστε ένα δόλωμα EOCD κοντά στην ουρά και ο αφελής parser το ακολουθεί, ενώ ένας parser που σαρώνει με διαφορετική σειρά, ή που αντιμετωπίζει την τελευταία υπογραφή στο αρχείο ως έγκυρη, ακολουθεί την πραγματική. Αυτή είναι η οικογένεια επιθέσεων ασάφειας ZIP, και το κέρδος της είναι ακριβώς η διάσπαση που περιγράφηκε παραπάνω, όπου η μηχανή σάρωσης και η καταναλώτρια εφαρμογή βλέπουν διαφορετικά σύνολα καταχωρίσεων από ένα αρχείο
Η TEndOfCentralDirectoryRecord.Parse όντως σαρώνει προς τα πίσω. Ορίζει το startscan στο τελευταίο byte, περιορίζει το endscan στο lsize - FMaxSize ή μηδέν, και περπατά το παράθυρο σε buffers 256 bytes που επικαλύπτονται κατά τρία bytes ώστε μια υπογραφή που εκτείνεται πάνω από ένα όριο buffer να μην χαθεί ποτέ. Η διαφορά είναι τι συμβαίνει σε ένα χτύπημα. Η εύρεση της υπογραφής παράγει μόνο μια θέση Candidate. Το HotXLS τότε διαβάζει τα 22 bytes σε αυτή τη θέση, τα αναλύει με το ReadEOCD, και απαιτεί τα προκύπτοντα πεδία να είναι εσωτερικά συνεπή με το αρχείο που ισχυρίζονται ότι περιγράφουν πριν ανατεθεί καθόλου το FOffsetEOCD
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
Διαβάστε το κατηγόρημα ως τέσσερις ξεχωριστούς ισχυρισμούς που μια πλαστογράφηση πρέπει να ικανοποιήσει ταυτόχρονα. Το Candidate + FMinSize + FCommentLen = lsize απαιτεί το δηλωμένο μήκος σχολίου να φτάνει ακριβώς στο τέλος του αρχείου, που είναι αυτό που σκοτώνει το κόλπο δολώματος-στο-σχόλιο: ένα ψεύτικο EOCD θαμμένο μέσα σε πραγματικό σχόλιο δεν μπορεί επίσης να λογαριάζει για κάθε byte μετά από αυτό. Τα FDiskNumber = 0 και FStartDisk = 0 απορρίπτουν τα πεδία διάσπασης πολλαπλών δίσκων που κανένα xlsx ποτέ δεν έχει χρησιμοποιήσει νόμιμα και που υπάρχουν σε κατασκευασμένα αρχεία μόνο για να προκαλέσουν σύγχυση. Το FThisDiskEntries = FTotalEntries απορρίπτει το κόλπο διαχωρισμένου μετρήματος όπου ένας parser μεγεθύνει τον βρόχο του από το ένα πεδίο και άλλος parser από το άλλο. Και το Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate απαιτεί ο κεντρικός κατάλογος να τελειώνει ακριβώς εκεί όπου αρχίζει το EOCD, ώστε ο κατάλογος να μην μπορεί να δείχνει σε κάποιο άσχετο blob αλλού στο αρχείο. Το casting Int64 σε αυτό το τελευταίο έχει σημασία: και τα δύο ορίσματα είναι 32-bit, και χωρίς διεύρυνση, ένα κατασκευασμένο ζεύγος θα μπορούσε να τυλιχτεί γύρω και να ικανοποιήσει τη δοκιμή αριθμητικά ενώ δείχνει πουθενά λογικό
Οι τοπικές κεφαλίδες πρέπει να συμφωνούν με τον κεντρικό κατάλογο
Οι έλεγχοι EOCD καθορίζουν ποιος κατάλογος είναι έγκυρος· δεν εγγυώνται ακόμη ότι ο κατάλογος λέει την αλήθεια για μεμονωμένα μέλη. Κάθε καταχώριση περιγράφεται δύο φορές σε ένα αρχείο ZIP, μία κεντρικά και μία στην τοπική κεφαλίδα της, και τίποτα στη μορφή δεν αναγκάζει τις δύο περιγραφές να ταιριάζουν, οπότε ένας reader που εμπιστεύεται τον κεντρικό κατάλογο και ένας reader που εμπιστεύεται τοπικές κεφαλίδες μπορούν να εξάγουν διαφορετικό περιεχόμενο από ένα αρχείο. Η TZipEntry.ParseLocalHeader κλείνει αυτό το κενό αναλύοντας την τοπική κεφαλίδα στο FCdFile.LocalFileHeaderOffset και συγκρίνοντας τα δύο αντίγραφα πεδίο προς πεδίο, επιστρέφοντας έναν διακριτό αρνητικό κωδικό για κάθε είδος διαφωνίας: το κανονικοποιημένο όνομα καταχώρισης, τη μέθοδο συμπίεσης, τις σημαίες γενικού σκοπού, και, όταν η σημαία περιγραφέα δεδομένων είναι καθαρή, το CRC32 και τα δύο μεγέθη. Με εκείνη τη σημαία ενεργή τα τοπικά αντίγραφα μπορούν να είναι μηδέν, αφού οι πραγματικές τιμές ζουν σε έναν επακόλουθο περιγραφέα, αλλά οποιαδήποτε μη μηδενική τοπική τιμή πρέπει ακόμη να ταιριάζει. Ένας τελικός έλεγχος απορρίπτει καταχωρίσεις των οποίων τα δεδομένα θα έτρεχαν πέρα από το τέλος του αρχείου, συγκρίνοντας Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) έναντι του inputstream.Size. Οποιαδήποτε αποτυχία διαδίδεται έξω από την TCentralDirectory.Parse ως αποτέλεσμα διαφορετικό από 1 και η TZipArchive.OpenArchive το μετατρέπει σε Can't open zip archive, αντί να σας δίνει ένα αντικείμενο αρχείου μερικώς αξιόπιστο. Όταν χρειάζεστε μόνο να ξέρετε ποια φύλλα περιέχει ένα αρχείο, η εκτέλεση αυτής της επικύρωσης πριν από πλήρη ανάλυση είναι φθηνή, και η ελαφριά διαδρομή επιθεώρησης φύλλων σας το δίνει ακριβώς χωρίς να υλοποιήσει δεδομένα κελιών
Τι συμβαίνει όταν τα ίδια τα bytes λένε ψέματα;
Η δομική συμφωνία εξακολουθεί να μη λέει τίποτα για το payload, οπότε το HotXLS τυλίγει κάθε stream καταχώρισης σε TZipVerifiedStream, που επιβάλλει το δηλωμένο μέγεθος και CRC32 καθώς διαβάζει ο καλών. Αυτό δεν είναι σκόπιμα ένας εκ των υστέρων έλεγχος: μια βόμβα αποσυμπίεσης της οποίας το δηλωμένο ασυμπίεστο μέγεθος είναι 4 KB αλλά που διογκώνεται σε gigabytes σταματά στο σημάδι των 4 KB, όχι μετά τη ζημιά. Ο wrapper περιορίζει κάθε ανάγνωση στα απομένοντα δηλωμένα bytes, προκαλεί ZIP entry ended before its declared size αν η πηγή στερέψει νωρίς, δοκιμάζει για ένα επιπλέον byte κατά την ολοκλήρωση και προκαλεί ZIP entry exceeds its declared size αν έχει απομείνει κάτι, και τέλος συγκρίνει το τρέχον CRC32 στο VerifyComplete, προκαλώντας ZIP entry uncompressed size mismatch ή ZIP entry CRC32 mismatch
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
Μια συνέπεια αξίζει να προγραμματίσετε. Το stream είναι μόνο-προς-τα-εμπρός εκ σχεδιασμού· ένα Seek σε οποιαδήποτε άλλη θέση εκτός από την τρέχουσα προκαλεί ZIP entry stream is forward-only, με μία μόνη παραχώρηση για soEnd με θέση μηδέν ώστε τα ερωτήματα μεγέθους να εξακολουθούν να λειτουργούν. Αυτή είναι η σωστή ανταλλαγή για μη αξιόπιστη είσοδο, επειδή ένα stream που μπορείτε να επαναφέρετε είναι ένα stream του οποίου τη λογιστική CRC μπορείτε να νικήσετε, αλλά όντως σημαίνει ότι ο κώδικας καταναλωτή που περιμένει ένα αναζητήσιμο stream χρειάζεται το δικό του buffer. Η ίδια πειθαρχία μόνο-προς-τα-εμπρός στηρίζει τον streaming άμεσο reader, που είναι το API στο οποίο να απλωθείτε όταν το φορτωμένο βιβλίο εργασίας είναι αρκετά μεγάλο ώστε να μη θέλετε να είναι καθόλου μόνιμο στη μνήμη
Όρια πόρων πριν τη δέσμευση, όχι μετά
Τρεις σταθερές στο lxZipArchive οριοθετούν τι μπορεί να ζητήσει ένα μόνο αρχείο από τη διεργασία να κάνει, και η TZipEntries.Add τις εφαρμόζει ενώ ο κεντρικός κατάλογος εξακολουθεί να διαβάζεται, πριν αγγιχτεί έστω ένα byte δεδομένων καταχώρισης. Το ZipMaxEntryUncompressedSize περιορίζει ένα μέλος στο 1 GiB, το ZipMaxTotalUncompressedSize περιορίζει το αρχείο στα 4 GiB, και το ZipMaxCompressionRatio των 10000 απορρίπτει κάθε αποσυμπιεσμένη καταχώριση της οποίας η δηλωμένη διεύρυνση υπερβαίνει τη δεκαχιλιοπλάσια, μαζί με την εκφυλισμένη περίπτωση ενός μη μηδενικού ασυμπίεστου μεγέθους συζευγμένου με μηδενικό συμπιεσμένο μέγεθος. Τα ονόματα καταχωρίσεων περνούν μέσα από το CanonicalZipEntryName στην ίδια κλήση, που απορρίπτει ενσωματωμένους χαρακτήρες NUL, άνω-κάτω τελείες, και οποιοδήποτε τμήμα διαδρομής .. με Invalid ZIP entry name, και που κάνει πεζά και κανονικοποιεί τμήματα ώστε δύο μέλη που διαφέρουν μόνο σε πεζά-κεφαλαία ή σε πλεονάζοντες διαχωριστές να συγκρούονται ως Duplicate ZIP entry name αντί να επισκιάζουν σιωπηλά το ένα το άλλο
Άμυνα σε βάθος πάνω από το επίπεδο ZIP
Το επίπεδο ZIP είναι μία βαθμίδα από πολλές, και το μοτίβο επαναλαμβάνεται όπου το HotXLS αναλύει δομή ελεγχόμενη από επιτιθέμενο. Το πιο ξεκάθαρο παράδειγμα βρίσκεται στον parser τύπων BIFF: η TXLSFormula.GetTranslated αναδρομεί μέσα από tokens tMemFunc, οπότε μια κατασκευασμένη ακολουθία tokens rgce σε ένα παλαιό .xls μπορεί να εμφωλευτεί αυθαίρετα βαθιά και να εξαντλήσει τη στοίβα. Η πύλη είναι μια σταθερά, MaxTranslateDepth = 256, επιλεγμένη έναντι ενός γνωστού γεγονότος upstream παρά μαντεμένη. Το Excel περιορίζει την εμφώλευση τύπων στο 64, οπότε το 256 αφήνει τετραπλάσιο περιθώριο και δεν μπορεί ποτέ να απορρίψει έναν τύπο που παρήγαγε ένα πραγματικό υπολογιστικό φύλλο, ενώ εξακολουθεί να τερματίζει ένα κακόβουλο stream αρκετά νωρίς πριν εξαντληθεί η στοίβα
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Σημειώστε ότι η πύλη επιστρέφει nil αντί να προκαλεί εξαίρεση. Ένας τύπος πολύ βαθύς για να είναι γνήσιος δεν αποδίδει δέντρο σύνταξης, η γύρω ανάλυση συνεχίζεται, και το βιβλίο εργασίας εξακολουθεί να φορτώνεται. Αυτή η ασυμμετρία είναι σκόπιμη και αξίζει να αντιγραφεί στα δικά σας όρια: ένα όριο που υπάρχει για να σταματήσει την εξάντληση πόρων πρέπει να υποβαθμίσει τη μικρότερη μονάδα που μπορεί, όχι να ματαιώσει το έγγραφο. Η ίδια λογική ισχύει όταν επεκτείνετε το επίπεδο υπολογισμού, οπότε αν καταχωρείτε τους δικούς σας handlers μέσω του API προσαρμοσμένων συναρτήσεων μηχανής τύπων, δώστε τους δικά τους όρια ορισμάτων και αναδρομής αντί να υποθέτετε ότι ο καλών ήδη έλεγξε
Τι δεν σας εξασφαλίζουν αυτοί οι έλεγχοι
Να είστε ακριβείς για το όριο. Οι τέσσερις διασταυρωμένοι έλεγχοι EOCD κάνουν τον δείκτη αρχείου μη διφορούμενο, οπότε το HotXLS και κάθε άλλος συμμορφούμενος reader επιλύουν το ίδιο αρχείο στο ίδιο σύνολο καταχωρίσεων· δεν λένε τίποτα για το αν αυτό το σύνολο καταχωρίσεων είναι αβλαβές. Η συμφωνία τοπικής κεφαλίδας σταματά το κόλπο δύο-όψεων, όχι ένα κακόβουλο payload που περιγράφεται με συνέπεια. Το επαληθευμένο stream σταματά περικοπή, υπερχείλιση και διαφθορά, όχι ένα τέλεια καλοσχηματισμένο τμήμα XML που κωδικοποιεί κάτι που δεν περιμένατε. Και τίποτα από αυτά δεν αγγίζει μακροεντολές: ένα project VBA μέσα σε ένα δομικά άψογο βιβλίο εργασίας είναι ακόμη ένα project VBA, και η απόφαση να το κρατήσετε, να το αφαιρέσετε, ή να το απορρίψετε ανήκει στο επίπεδο πολιτικής σας, όχι στον reader ZIP
Αυτό που παίρνετε σε αντάλλαγμα είναι ένα καθαρό όριο αποτυχίας. Ένα μη αξιόπιστο xlsx είτε ανοίγει ως ένα μη διφορούμενο αρχείο του οποίου τα μέλη ταιριάζουν με τα δηλωμένα μεγέθη και checksums τους, είτε προκαλεί εξαίρεση με ένα μήνυμα που ονομάζει τη συγκεκριμένη αναλλοίωτη που παραβίασε, και η υπηρεσία σας μπορεί να θέσει σε καραντίνα βάσει της εξαίρεσης αντί να μαντεύει. Ο reader ZIP και οι βαθμίδες parser πάνω από αυτόν διατίθενται ως μέρος του HotXLS Excel Component για Delphi και C++Builder, που δεν χρειάζεται ούτε Excel ούτε αυτοματισμό OLE στο μηχάνημα που κάνει την ανάλυση, και αυτή η απουσία είναι η ίδια μια σημαντική μείωση σε αυτό που μπορεί να προσεγγίσει ένα αρχείο που ανέβηκε