Technical Article

Θωράκιση ενός PDFium VCL Binding: ABI και ασφάλεια μνήμης

Μια σύνδεση (binding) Pascal πάνω από μια βιβλιοθήκη C διαβάζεται σαν συνηθισμένη Pascal. Καλείτε μια μέθοδο, παίρνετε πίσω μια εγγραφή (record), ελευθερώνετε ό,τι εκχωρήσατε. Το πρόβλημα είναι ότι το PDFium είναι μια βιβλιοθήκη C και C++ με τη δική της σύμβαση κλήσης (calling convention), τα δικά της πλάτη ακεραίων και τους δικούς της κανόνες σχετικά με το ποιος κατέχει τη μνήμη και ποιος την ελευθερώνει. Τίποτα από όλα αυτά δεν ξεπερνά το όριο της γλώσσας από μόνο του. Κάθε ένα από αυτά τα συμβόλαια πρέπει να επαναδιατυπωθεί με το χέρι στις δηλώσεις Pascal, και μια λάθος λέξη μετατρέπει μια καθαρή κλήση σε καταστροφή στοίβας (stack corruption), περικομμένη μετατόπιση (truncated offset) ή διπλή απελευθέρωση (double free). Ένας έλεγχος έκδοσης v1.61.0 μιας VCL σύνδεσης PDFium αποκάλυψε ένα ελάττωμα από κάθε είδος. Αξίζει να τα εξετάσουμε επειδή δεν αφορούν αποκλειστικά αυτή τη σύνδεση. Είναι οι μόνιμοι κίνδυνοι της ενθυλάκωσης οποιουδήποτε API C σε Delphi ή Lazarus.

Το cdecl είναι μέρος του τύπου της συνάρτησης, όχι διακόσμηση

Το PDFium είναι μεταγλωττισμένο σε C. Στο Win32 οι εξαγωγές του και, κυρίως, οι ανακλήσεις (callbacks) που καλεί χρησιμοποιούν τη σύμβαση κλήσης cdecl. Υπό τη σύμβαση cdecl, ο καλών (caller) καθαρίζει τη στοίβα μετά την επιστροφή της κλήσης. Η εγγενής προεπιλογή του Delphi είναι η register, και το πρότυπο C του Win32 για τις ανακλήσεις είναι η stdcall σε ορισμένες βιβλιοθήκες, όπου αντίθετα καθαρίζει ο καλούμενος (callee). Όταν μια δομή παραδίδει στο PDFium έναν δείκτη συνάρτησης και ξεχάσετε το cdecl στον τύπο αυτού του δείκτη, οι δύο πλευρές διαφωνούν σχετικά με το ποιος προσαρμόζει τον δείκτη στοίβας (stack pointer). Είτε το διορθώνουν και οι δύο, είτε κανένας, και ο δείκτης στοίβας αποκλίνει κατά το μέγεθος των ορισμάτων σε κάθε επίκληση.

Ο λόγος που αυτό το ελάττωμα είναι δύσκολο να βρεθεί είναι ότι η ζημιά δεν είναι τοπική. Η κατεστραμμένη κλήση επιστρέφει και φαίνεται εντάξει. Η κακή ευθυγράμμιση εμφανίζεται αργότερα, σε κάποια άσχετη συνάρτηση της οποίας το πλαίσιο (frame) βρίσκεται τώρα σε έναν δείκτη στοίβας που είναι λίγα byte εκτός, και εκδηλώνεται ως μη έγκυρη ανάγνωση, κακή διεύθυνση επιστροφής ή κατάρρευση με ένα backtrace που δεν δείχνει πουθενά κοντά στην ανάκληση στην οποία κάνατε λάθος. Η συμπλήρωση φορμών (form-fill) είναι το κλασικό σημείο όπου εμφανίζεται αυτό, επειδή η διεπαφή συμπλήρωσης φορμών είναι μια εγγραφή γεμάτη με ανακλήσεις που το PDFium καλεί πίσω. Μία από αυτές, η FFI_OpenFile, παραδίδει στο PDFium μια συνάρτηση που θα καλέσει για να ανοίξει ένα εξωτερικό αρχείο, η οποία δηλώνεται ως function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Το τελικό cdecl είναι το σημείο που αξίζει να αντιγράψετε. Παραλείψτε το και ο κώδικας εξακολουθεί να μεταγλωττίζεται, να συνδέεται και να εκτελείται κανονικά, μέχρι τη στιγμή που το PDFium θα καλέσει τη συνάρτηση. Η σύμβαση ανήκει στον ίδιο τον τύπο της συνάρτησης. Δεν είναι προαιρετική διακόσμηση, και ο μεταγλωττιστής δεν θα σας προειδοποιήσει όταν λείπει, επειδή ένας απλός τύπος συνάρτησης είναι ένας απόλυτα νόμιμος τύπος Pascal. Η μόνη άμυνα είναι να αντιμετωπίζετε τη σύμβαση κλήσης ως υποχρεωτικό πεδίο κάθε εισαγόμενης υπογραφής και κάθε ανάκλησης που μεταβιβάζετε προς τα έξω.

Το size_t έχει το πλάτος δείκτη, και στο FPC Win64 αυτό σημαίνει 64 bit

Το δεύτερο ελάττωμα είναι μια αναντιστοιχία πλάτους ακεραίου που εμφανίζεται μόνο σε έναν στόχο (target). Το size_t της C ορίζεται να είναι αρκετά ευρύ ώστε να χωράει οποιοδήποτε μέγεθος αντικειμένου, το οποίο σε μια πλατφόρμα 64 bit σημαίνει έναν μη προσημασμένο ακέραιο 64 bit. Οι διεπαφές προοδευτικής φόρτωσης (progressive-loading) του PDFium επικοινωνούν με μετατοπίσεις byte τύπου size_t. Η εγγραφή FX_FILEAVAIL του παρόχου διαθεσιμότητας φέρει μια ανάκληση IsDataAvail την οποία καλεί το PDFium με μια μετατόπιση και ένα μέγεθος, και η ανάκληση AddSegment της εγγραφής FX_DOWNLOADHINTS λαμβάνει το ίδιο. Και οι δύο παράμετροι είναι τύπου size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Εάν δηλώσετε αυτές τις μετατοπίσεις ως τύπο 32 bit, η σύνδεση λειτουργεί σε Win32 και σε Delphi Win64, αλλά στη συνέχεια σπάει σιωπηλά σε FPC και Lazarus Win64. Η αιτία είναι ανεπαίσθητη. Στο FPC Win64, το NativeUInt είναι ένας γνήσιος τύπος 64 bit με πλάτος δείκτη, και το size_t είναι ψευδώνυμο (alias) αυτού. Η σύνδεση έχει ένα σχόλιο στην ενότητα τύπων που προειδοποιεί ακριβώς ενάντια στη σκίαση του NativeUInt στο FPC, επειδή ο επανακαθορισμός του σε ψευδώνυμο 32 bit εκεί θα ανάγκαζε το size_t σε 32 bit και θα κατέστρεφε κάθε παράμετρο size_t που περνάει ή γράφεται από τη βιβλιοθήκη. Μια μετατόπιση 64 bit που φτάνει σε μια παράμετρο 32 bit χάνει το επάνω μισό της. Για ένα μικρό αρχείο κάθε μετατόπιση χωράει στα 32 bit και δεν υπάρχει πρόβλημα. Για ένα μεγάλο αρχείο, τη στιγμή που μια μετατόπιση ξεπερνά το όριο των τεσσάρων gigabyte, η περικομμένη τιμή δείχνει κάπου εντελώς αλλού, το PDFium ρωτά αν το λάθος εύρος byte είναι διαθέσιμο και η προοδευτική φόρτωση παγώνει ή διαβάζει σκουπίδια. Το ελάττωμα είναι αόρατο μέχρι το αρχείο να γίνει αρκετά μεγάλο και ο στόχος να είναι αυτός όπου το size_t πράγματι διευρύνθηκε.

Μια εξαίρεση Pascal δεν πρέπει ποτέ να εκτελεί unwind μέσω ενός πλαισίου C

Η τρίτη κατηγορία αφορά το μοντέλο εξαιρέσεων (exception model), το οποίο δεν διαθέτει η C. Όταν το PDFium καλεί μία από τις ανακλήσεις σας, ο κώδικας Pascal εκτελείται μέσα σε μια στοίβα από πλαίσια C και C++ που δεν γνωρίζουν τίποτα για τον μηχανισμό εξαιρέσεων του Delphi. Εάν η ανάκλησή σας προκαλέσει σφάλμα και αφήσει την εξαίρεση να εξαπλωθεί, εκτελείται unwind μέσω πλαισίων που δεν σχεδιάστηκαν ποτέ για αυτό. Ο καθαρισμός του ίδιου του PDFium δεν εκτελείται, οι εσωτερικές του σταθερές παραμένουν μισο-ενημερωμένες και η διεργασία βρίσκεται πλέον σε μια κατάσταση που η βιβλιοθήκη δεν προέβλεψε ποτέ. Το συμβόλαιο για αυτές τις ανακλήσεις είναι ένας κωδικός επιστροφής, όχι μια εξαίρεση.

Δύο ανακλήσεις το κάνουν αυτό συγκεκριμένο. Η FPDF_FILEWRITE είναι ο δέκτης (sink) στον οποίο το PDFium γράφει ένα αποθηκευμένο έγγραφο, και η FPDF_FILEACCESS είναι η πηγή από την οποία διαβάζει ένα έγγραφο εισόδου. Και οι δύο υλοποιούνται εδώ πάνω από ένα Delphi TStream, και οι δύο μπορούν να αποτύχουν με τον τρόπο που αποτυγχάνει κάθε ροή: ο δίσκος γεμίζει, η ροή κλείνει από κάτω σας, μια ανάγνωση ξεπερνά το τέλος. Η ανάκληση εγγραφής τυλίγει την εγγραφή ροής της και μετατρέπει οποιαδήποτε αποτυχία σε κωδικό αποτυχίας του PDFium αντί να την αφήσει να διαφύγει.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Η πλευρά της ανάγνωσης κάνει το ίδιο: μια αποτυχημένη ανάγνωση αναφέρει μηδέν για να ταιριάζει με το συμβόλαιο της FPDF_FILEACCESS αντί να εγείρει εξαίρεση πέρα από το όριο. Ένα απλό except χωρίς re-raise φαίνεται λάθος σε έναν προγραμματιστή Pascal εκπαιδευμένο να μην καταπίνει ποτέ εξαιρέσεις, και στην κανονική Pascal είναι λάθος. Σε ένα όριο ABI είναι το σωστό σχήμα, επειδή η μόνη ασφαλής τιμή για να παραδοθεί πίσω στον καλών C είναι ένας κωδικός κατάστασης που ξέρει πώς να ερμηνεύσει. Η αποτυχία εξακολουθεί να διαδίδεται, απλά μέσω της τιμής επιστροφής, και ο κώδικας κλήσης πάνω από τη βιβλιοθήκη την εμφανίζει ως EPdfError μόλις ο έλεγχος επιστρέψει στην πλευρά της Pascal.

Η διπλή απελευθέρωση (double free) κρύβεται στη διαδρομή σφάλματος

Το τέταρτο ελάττωμα είναι η ιδιοκτησία (ownership). Η λαβή (handle) εγγράφου του PDFium ανοίγει από τη βιβλιοθήκη και πρέπει να κλείσει ακριβώς μία φορά, με την FPDF_CloseDocument. Ο κίνδυνος είναι μια διαδρομή σφάλματος που απελευθερώνει μια λαβή την οποία κατέχει επίσης ένας δεύτερος καθαρισμός. Φανταστείτε μια ρουτίνα που δημιουργεί ένα αντικείμενο ενθυλάκωσης (wrapper object), του εκχωρεί μια πρόσφατα ανοιγμένη λαβή εγγράφου και στη συνέχεια κάνει περισσότερες ρυθμίσεις που ενδέχεται να αποτύχουν. Εάν η ρύθμιση προκαλέσει σφάλμα, ένας χειριστής πρόωρης επιστροφής που καλεί την FPDF_CloseDocument στην πρωτογενή λαβή θα την κλείσει, και στη συνέχεια ο καταστροφέας (destructor) του ίδιου του αντικειμένου ενθυλάκωσης θα την κλείσει ξανά όταν το αντικείμενο ελευθερωθεί. Η λαβή ελευθερώνεται δύο φορές, κάτι που αποτελεί απροσδιόριστη συμπεριφορά και πιθανή κατάρρευση.

Ο έλεγχος εντόπισε αυτό το πρόβλημα σε μια διαδρομή εισαγωγής τύπου imposition που δημιουργεί ένα TPdf γύρω από μια ήδη ανοιχτή λαβή. Η διόρθωση είναι να γίνει η μεταφορά ιδιοκτησίας η μοναδική πηγή αλήθειας. Μόλις η λαβή εκχωρηθεί στο πεδίο του wrapper, ο wrapper την κατέχει, και ο μόνος καθαρισμός στη διαδρομή σφάλματος είναι η απελευθέρωση του wrapper. Ο καταστροφέας του wrapper καλεί την FPDF_CloseDocument για εσάς, επομένως ένα δεύτερο ρητό κλείσιμο θα απελευθέρωνε διπλά το ίδιο έγγραφο. Ο διορθωμένος χειριστής σφάλματος ελευθερώνει το αντικείμενο και προκαλεί ξανά την εξαίρεση (re-raise), και υπάρχει ακριβώς μία διαδρομή προς το κλείσιμο.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Οι διαχειριζόμενες εγγραφές (managed records) και μια βιβλιοθήκη γεμάτη εξαγωγές χρειάζονται και οι δύο ρητή αποδέσμευση

Η τελευταία κατηγορία αφορά τη μνήμη που διαχειρίζεται ο μεταγλωττιστής για λογαριασμό σας, την οποία μια συνήθεια από τη C θα καταστρέψει αθόρυβα. Πολλές από τις βοηθητικές συναρτήσεις αυτής της σύνδεσης επιστρέφουν μια εγγραφή (record) που περιέχει μια WideString ή έναν δυναμικό πίνακα. Αυτά είναι πεδία με μέτρηση αναφορών (reference-counted), και ο μεταγλωττιστής εκπέμπει κρυφή διαχείριση για τη διατήρηση των μετρήσεών τους. Το ένστικτο που μεταφέρεται από τη C είναι να καθαρίσει μια νέα εγγραφή με τη FillChar(Result, SizeOf(Result), 0). Αυτό γράφει μηδενικά πάνω στη διαχειριζόμενη αναφορά μέσα στην εγγραφή χωρίς να τη μειώσει πρώτα. Ο μεταγλωττιστής επαναχρησιμοποιεί μια κρυφή προσωρινή μεταβλητή για το αποτέλεσμα της συνάρτησης σε όλες τις επαναλήψεις του βρόχου, έτσι στη δεύτερη επανάληψη η FillChar αντικαθιστά έναν ενεργό δείκτη συμβολοσειράς που δεν απελευθερώθηκε ποτέ, και η συμβολοσειρά στην οποία έδειχνε διαρρέει. Καλέστε τη συνάρτηση σε έναν βρόχο πάνω από χίλια σχόλια και θα διαρρεύσετε χίλιες συμβολοσειρές.

Η διόρθωση είναι να αφήσετε τη γλώσσα να καθαρίσει την εγγραφή με τον τρόπο που γνωρίζει, με την Default(T), η οποία απελευθερώνει οποιοδήποτε διαχειριζόμενο πεδίο πριν το μηδενίσει.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Ένα σχετικό πρόβλημα ιδιοκτησίας υπάρχει στο όριο φόρτωσης της βιβλιοθήκης. Αυτή η σύνδεση επιλύει αρκετές εκατοντάδες δείκτες συναρτήσεων από το DLL του PDFium με τη GetProcAddress μετά από μια LoadLibrary. Εάν λείπει μια απαιτούμενη εξαγωγή, η μερικώς συνδεδεμένη κατάσταση είναι επικίνδυνη: δεκάδες δείκτες είναι έγκυροι, οι υπόλοιποι είναι nil ή παλιοί, και οποιαδήποτε μεταγενέστερη κλήση μέσω ενός από αυτούς μεταπηδά σε μια μονάδα (module) που μπορεί να έχει ήδη αποφορτωθεί. Η σύνδεση το χειρίζεται αυτό αποφορτώνοντας τη βιβλιοθήκη και εκτελώντας μια πλήρη ClearAllBindings που επαναφέρει κάθε εισαγόμενο δείκτη σε nil κάθε φορά που μια απαιτούμενη εξαγωγή αποτυγχάνει να επιλυθεί. Μετά από αυτό, κανένας δείκτης συνάρτησης δεν κρέμεται σε μια αποφορτωμένη μονάδα, και μια μεταγενέστερη κλήση αποτυγχάνει καθαρά με έναν έλεγχο δείκτη nil αντί να διακλαδωθεί σε ελευθερωμένο κώδικα.

Ο wrapper είναι το σημείο όπου τέσσερα συμβόλαια επαναδιατυπώνονται με το χέρι

Κανένα από αυτά τα πέντε ελαττώματα δεν είναι εξωτικό. Είναι οι προβλέψιμες καταστάσεις αποτυχίας ενός λεπτού στρώματος Pascal πάνω από ένα API C, και ομαδοποιούνται επειδή αυτό το στρώμα είναι ακριβώς το σημείο όπου τέσσερα ξεχωριστά συμβόλαια πρέπει να επαναδηλωθούν. Η σύμβαση κλήσης πρέπει να γραφτεί ως cdecl σε κάθε ανάκληση. Το πλάτος του ακεραίου πρέπει να ταιριάζει με το size_t στον μοναδικό στόχο όπου πραγματικά διευρύνεται. Το μοντέλο εξαιρέσεων πρέπει να μετατραπεί σε κωδικούς επιστροφής σε κάθε ανάκληση που εξέρχεται από την Pascal. Η ιδιοκτησία κάθε λαβής και κάθε διαχειριζόμενου πεδίου πρέπει να δηλώνεται μία φορά και να τηρείται σε κάθε διαδρομή, συμπεριλαμβανομένων των διαδρομών σφάλματος που κανείς δεν δοκιμάζει μέχρι την παραγωγή. Αν παραλείψετε οποιοδήποτε, θα έχετε ένα ελάττωμα του οποίου το σύμπτωμα εμφανίζεται μακριά από την αιτία του, κάτι που καθιστά αυτή την κατηγορία δαπανηρή. Η αξία του ελέγχου ήταν λιγότερο σε οποιαδήποτε μεμονωμένη διόρθωση και περισσότερο στην αντιμετώπιση καθενός από αυτά ως δική του πειθαρχία προς έλεγχο σε ολόκληρη τη σύνδεση.

Εάν θέλετε να δείτε τη σύνδεση να κάνει πραγματική εργασία αντί να προστατεύει τα όριά της, οι τεχνικές προσωρινής μνήμης απόδοσης (render-cache) και εστίασης (zoom) στη σημείωσή μας για την απόδοση του render-cache και του zoom δείχνουν τη διαδρομή απόδοσης, και ο οδηγός cross-compiler για τη δημιουργία ενός προγράμματος προβολής Lazarus και FPC είναι το σημείο όπου η συμπεριφορά size_t του Win64 που περιγράφεται εδώ έχει πραγματικά σημασία. Και τα δύο βασίζονται στην ίδια εργασία ασφάλειας μνήμης και ABI που παρέχεται στο PDFium Component για Delphi, Lazarus και C++Builder, μαζί με τα API απόδοσης, εξαγωγής κειμένου και φορμών που καλύπτονται σε άλλα σημεία αυτού του ιστολογίου.