Technical Article

Zabezpieczanie powiązań VCL dla PDFium: ABI i bezpieczeństwo pamięci

Opakowanie biblioteki C w kodzie w Pascalu wygląda jak zwykły Pascal. Wywołujesz metodę, otrzymujesz z powrotem rekord, zwalniasz to, co przydzieliłeś. Problem w tym, że PDFium to biblioteka C i C++ z własną konwencją wywoływania, własnymi szerokościami liczb całkowitych i własnymi regułami dotyczącymi tego, kto jest właścicielem pamięci, a kto ją zwalnia. Nic z tego nie przekracza automatycznie granicy języka. Każdy z tych kontraktów musi zostać przepisany ręcznie w deklaracjach w Pascalu, a jedno błędne słowo zamienia czyste z pozoru wywołanie w uszkodzenie stosu, obcięte przesunięcie lub podwójne zwolnienie pamięci. Audyt wersji v1.61.0 powiązań VCL dla PDFium ujawnił po jednym defekcie każdego rodzaju. Warto je przeanalizować, ponieważ nie są one specyficzne dla tych powiązań. Są to stałe zagrożenia przy owijaniu dowolnego API C w Delphi lub Lazarusie.

cdecl jest częścią typu funkcji, a nie dekoracją

Biblioteka PDFium jest skompilowana w C. W systemie Win32 jej eksporty oraz, co ważniejsze, wywołania zwrotne (callbacks), które uruchamia, używają konwencji wywoływania cdecl. W konwencji cdecl to wywołujący czyści stos po powrocie z funkcji. Rodzimym domyślnym ustawieniem Delphi jest register, a standardem Win32 C dla wywołań zwrotnych w niektórych bibliotekach jest stdcall, gdzie to funkcja wywoływana czyści stos. Gdy struktura przekazuje do PDFium wskaźnik funkcji, a Ty zapomnisz o cdecl w typie tego wskaźnika, obie strony nie zgadzają się co do tego, kto koryguje wskaźnik stosu. Albo obie to robią, albo żadna, a wskaźnik stosu przesuwa się o rozmiar argumentów przy każdym wywołaniu.

Powodem, dla którego ten defekt jest trudny do wykrycia, jest to, że uszkodzenie ma charakter nielokalny. Uszkodzone wywołanie powraca i wygląda dobrze. Niezgodność ujawnia się później, w jakiejś niepowiązanej funkcji, której ramka znajduje się teraz na wskaźniku stosu przesuniętym o kilka bajtów, co objawia się dzikim odczytem, złym adresem powrotnym lub awarią ze śladem stosu wskazującym zupełnie gdzie indziej niż błędne wywołanie zwrotne. Wypełnianie formularzy to klasyczne miejsce, w którym ten problem daje o sobie znać, ponieważ interfejs ten jest rekordem pełnym wywołań zwrotnych, których PDFium używa do komunikacji zwrotnej. Jedno z nich, FFI_OpenFile, przekazuje do PDFium funkcję otwieranego pliku zewnętrznego, zadeklarowaną jako function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Końcowe słowo cdecl to szczegół warty skopiowania. Pomiń je, a kod nadal się skompiluje, zlinkuje i uruchomi – aż do momentu, gdy PDFium wywoła tę funkcję. Konwencja wywoływania należy do samego typu funkcji. Nie jest opcjonalnym dodatkiem, a kompilator nie ostrzeże Cię o jej braku, ponieważ zwykły typ funkcji jest całkowicie legalnym typem w Pascalu. Jedyną obroną jest traktowanie konwencji wywoływania jako obowiązkowego pola każdej importowanej sygnatury i każdego wywołania zwrotnego przekazywanego na zewnątrz.

size_t to szerokość wskaźnika, co w FPC Win64 oznacza 64 bity

Drugi defekt to niezgodność szerokości liczb całkowitych, która pojawia się tylko na jednej platformie docelowej. Typ size_t w C jest zdefiniowany jako wystarczająco szeroki, by pomieścić rozmiar dowolnego obiektu, co na platformie 64-bitowej oznacza 64-bitową liczbę całkowitą bez znaku. Interfejsy progresywnego ładowania PDFium operują na przesunięciach bajtowych typu size_t. Dostawca dostępności pliku (rekord FX_FILEAVAIL) zawiera wywołanie zwrotne IsDataAvail, które PDFium wywołuje z przesunięciem i rozmiarem, a wywołanie zwrotne AddSegment rekordu FX_DOWNLOADHINTS otrzymuje te same dane. Oba parametry to size_t.

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

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

Jeśli zadeklarujesz te przesunięcia jako typ 32-bitowy, powiązanie działa na Win32 oraz Delphi Win64, a następnie po cichu psuje się na FPC i Lazarusie Win64. Przyczyna jest subtelna. W FPC Win64, NativeUInt jest prawdziwym 64-bitowym typem o szerokości wskaźnika, a size_t jest jego aliasem. Powiązanie zawiera w sekcji typów komentarz ostrzegający przed przesłanianiem NativeUInt w FPC, ponieważ przedefiniowanie go tam do 32-bitowego aliasu wymusiłoby 32 bity dla size_t i uszkodziło każdy parametr tego typu przekazywany do lub z biblioteki. Przesunięcie 64-bitowe trafiające do parametru 32-bitowego traci swoją górną połowę. W przypadku małego pliku każde przesunięcie mieści się w 32 bitach i nic złego się nie dzieje. W przypadku dużego pliku, gdy tylko przesunięcie przekroczy granicę czterech gigabajtów, obcięta wartość wskazuje zupełnie gdzie indziej, PDFium pyta o dostępność niewłaściwego zakresu bajtów, a ładowanie progresywne zawiesza się lub odczytuje śmieci. Defekt jest niewidoczny, dopóki plik nie jest odpowiednio duży, a celem jest platforma, na której size_t uległo rozszerzeniu.

Wyjątek w Pascalu nie może nigdy odwijać stosu przez ramkę C

Trzecia klasa problemów dotyczy modelu wyjątków, którego C nie posiada. Gdy PDFium wywołuje jedno z wywołań zwrotnych, kod w Pascalu działa wewnątrz stosu ramek C i C++, które nic nie wiedzą o mechanizmach wyjątków Delphi. Jeśli wywołanie zwrotne zgłosi wyjątek i pozwoli mu się propagować, odwinie on ramki, które nigdy nie były do tego przystosowane. Własne czyszczenie PDFium nie zostanie uruchomione, jego wewnętrzne niezmienniki zostaną na wpół zaktualizowane, a proces znajdzie się w stanie, którego biblioteka nigdy nie przewidziała. Kontraktem dla tych wywołań zwrotnych jest kod błędu, a nie wyjątek.

Dwa wywołania zwrotne czynią to konkretnym. FPDF_FILEWRITE to ujście, do którego PDFium zapisuje zapisywany dokument, a FPDF_FILEACCESS to źródło, z którego czyta dokument wejściowy. Oba są zaimplementowane tutaj w oparciu o obiekt TStream z Delphi i oba mogą ulec awarii, jak każdy strumień: dysk się zapełni, strumień zostanie zamknięty, odczyt wyjdzie poza koniec. Wywołanie zapisu owija zapis strumienia i zamienia każdą awarię w kod błędu PDFium, zamiast pozwolić jej uciec na zewnątrz.

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;

Strona odczytu robi to samo: nieudany odczyt zwraca zero, aby dopasować się do kontraktu FPDF_FILEACCESS, zamiast zgłaszać wyjątek poza granicę biblioteki. Pusty blok except bez ponownego zgłoszenia wyjątku wygląda na błąd dla programisty Pascala, którego nauczono, by nigdy nie połykać wyjątków. Na granicy ABI jest to jednak właściwy kształt, ponieważ jedyną bezpieczną wartością do przekazania z powrotem do wywołującego C jest kod statusu, który potrafi on zinterpretować. Awaria nadal się propaguje, tyle że poprzez wartość zwracaną, a kod wywołujący nad biblioteką ujawnia ją jako EPdfError, gdy kontrola powróci na stronę Pascala.

Podwójne zwolnienie pamięci ukrywa się na ścieżce obsługi błędów

Czwarty defekt to kwestia własności. Uchwyt dokumentu PDFium jest otwierany przez bibliotekę i musi zostać zamknięty dokładnie raz, za pomocą FPDF_CloseDocument. Niebezpieczeństwo polega na tym, że ścieżka obsługi błędu zwalnia uchwyt, który posiada również druga procedura czyszcząca. Wyobraź sobie rutynę, która tworzy obiekt opakowujący, przypisuje do niego nowo otwarty uchwyt dokumentu, a następnie wykonuje dalszą konfigurację, która może się nie udać. Jeśli konfiguracja zgłosi błąd, moduł obsługi wczesnego powrotu, który wywołuje FPDF_CloseDocument na surowym uchwycie, zamknie go, a następnie własny destruktor obiektu opakowującego zamknie go ponownie po zwolnieniu obiektu. Uchwyt zostanie zwolniony dwukrotnie, co jest zachowaniem niezdefiniowanym i prawdopodobną awarią.

Audyt wykrył to na ścieżce importu w stylu impozycji, która buduje obiekt TPdf wokół już otwartego uchwytu. Rozwiązaniem jest uczynienie transferu własności jedynym źródłem prawdy. Po przypisaniu uchwytu do pola opakowania, opakowanie staje się jego właścicielem, a jedyną czynnością czyszczącą na ścieżce obsługi błędu jest zwolnienie opakowania. Destruktor opakowania wywołuje FPDF_CloseDocument za Ciebie, więc drugie jawne zamknięcie spowodowałoby podwójne zwolnienie tego samego dokumentu. Poprawiony moduł obsługi błędu zwalnia obiekt i ponownie zgłasza wyjątek, co daje dokładnie jedną ścieżkę do zamknięcia.

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;

Zarządzane rekordy i biblioteka pełna eksportów potrzebują jawnego usuwania

Ostatnia klasa problemów dotyczy pamięci zarządzanej przez kompilator w Twoim imieniu, którą nawyki z języka C mogą po cichu uszkodzić. Wiele funkcji pomocniczych tego powiązania zwraca rekord zawierający pole WideString lub tablicę dynamiczną. Są to pola zliczające referencje, a kompilator emituje ukrytą księgowość w celu utrzymania ich liczników. Instynkt przeniesiony z C nakazuje wyczyszczenie nowego rekordu za pomocą FillChar(Result, SizeOf(Result), 0). To zapisuje zera na zarządzanej referencji wewnątrz rekordu bez uprzedniego zmniejszenia jej licznika. Kompilator używa ponownie jednego ukrytego obiektu tymczasowego dla wyniku funkcji w iteracjach pętli, więc w drugiej iteracji FillChar nadpisuje żywy wskaźnik ciągu znaków, który nigdy nie został zwolniony, a ciąg znaków, na który wskazywał, wycieka. Wywołaj tę funkcję w pętli dla tysiąca adnotacji, a wycieknie tysiąc ciągów znaków.

Rozwiązaniem jest pozwolenie językowi na wyczyszczenie rekordu w znany mu sposób, za pomocą Default(T), co zwalnia wszelkie zarządzane pola przed ich wyzerowaniem.

// 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);

Powiązany problem z własnością występuje na granicy ładowania biblioteki. Powiązanie to rozpoznaje kilkaset wskaźników funkcji z biblioteki DLL PDFium za pomocą GetProcAddress po wywołaniu LoadLibrary. Jeśli brakuje jednego wymaganego eksportu, częściowo powiązany stan jest niebezpieczny: dziesiątki wskaźników są prawidłowe, reszta jest pusta (nil) lub nieaktualna, a każde późniejsze wywołanie przez jeden z nich skacze do modułu, który może być już wyładowany. Powiązanie radzi sobie z tym poprzez wyładowanie biblioteki i uruchomienie pełnego ClearAllBindings, które resetuje każdy zaimportowany wskaźnik z powrotem do nil, gdy tylko nie uda się rozpoznać wymaganego eksportu. Po tym kroku żaden wskaźnik funkcji nie wskazuje na wyładowany moduł, a późniejsze wywołanie kończy się czystym niepowodzeniem ze sprawdzeniem wskaźnika nil zamiast rozgałęziania do zwolnionego kodu.

Opakowanie to miejsce, w którym cztery kontrakty są ręcznie przepisywane

Żaden z tych pięciu defektów nie jest egzotyczny. Są to przewidywalne tryby awarii cienkiej warstwy Pascala nad API C. Kumulują się, ponieważ ta warstwa to dokładnie miejsce, w którym cztery oddzielne kontrakty muszą być zadeklarowane ręcznie. Konwencja wywoływania musi być zapisana jako cdecl przy każdym wywołaniu zwrotnym. Szerokość liczby całkowitej musi pasować do size_t na tej jednej platformie, na której faktycznie się rozszerza. Model wyjątków musi być przekonwertowany na kody błędów przy każdym wywołaniu zwrotnym przekraczającym granicę Pascala. Własność każdego uchwytu i każdego zarządzanego pola musi być określona raz i przestrzegana na każdej ścieżce, włączając w to ścieżki błędów, których nikt nie testuje przed wdrożeniem produkcyjnym. Pomiń dowolny z nich, a otrzymasz defekt, którego objaw ujawni się daleko od przyczyny. Wartość audytu polegała mniej na pojedynczych poprawkach, a bardziej na traktowaniu każdego z nich jako osobnej dyscypliny do sprawdzenia w całym powiązaniu.

Jeśli chcesz zobaczyć powiązanie wykonujące rzeczywistą pracę, a nie tylko zabezpieczające swoje krawędzie, techniki buforowania renderowania i powiększania opisane w naszej notatce o wydajności bufora renderowania i powiększenia pokazują ścieżkę renderowania, a przewodnik po kompilatorach skrośnych w artykule o budowaniu przeglądarki Lazarus i FPC to miejsce, w którym zachowanie size_t w Win64 opisane tutaj ma rzeczywiste znaczenie. Oba te rozwiązania bazują na tych samych pracach nad bezpieczeństwem pamięci i ABI, które są dostarczane w komponencie PDFium Component dla Delphi, Lazarusa i C++Builder, obok interfejsów API do renderowania, ekstrakcji tekstu i formularzy omówionych w innych miejscach tego bloga.