Technical Article

Memperkeras Binding PDFium VCL: ABI dan Keamanan Memori

Binding Pascal di atas pustaka C terlihat seperti Pascal biasa. Anda memanggil metode, Anda mendapatkan record kembali, Anda membebaskan apa yang Anda alokasikan. Masalahnya adalah PDFium adalah pustaka C dan C++ dengan konvensi pemanggilannya sendiri, lebar integer sendiri, dan aturannya sendiri tentang siapa yang memiliki memori dan siapa yang membebaskannya. Tidak ada satu pun dari hal tersebut yang melewati batas bahasa dengan sendirinya. Setiap kontrak tersebut harus dinyatakan kembali secara manual dalam deklarasi Pascal, dan satu kata yang salah mengubah panggilan yang terlihat bersih menjadi kerusakan tumpukan (stack corruption), offset yang terpotong (truncated offset), atau pembebasan ganda (double free). Audit v1.61.0 dari binding PDFium VCL menemukan satu cacat dari setiap jenis. Cacat-cacat ini layak untuk dibahas karena tidak spesifik untuk binding ini saja. Cacat tersebut adalah bahaya konstan saat membungkus API C apa pun di Delphi atau Lazarus.

cdecl adalah bagian dari tipe fungsi, bukan dekorasi

PDFium adalah C yang dikompilasi. Pada Win32, ekspornya dan, yang lebih penting, callback yang dipanggilnya menggunakan konvensi pemanggilan cdecl. Di bawah cdecl, pemanggil membersihkan stack setelah panggilan kembali. Default asli Delphi adalah register, dan standar C Win32 untuk callback adalah stdcall di beberapa pustaka, di mana penerima panggilan (callee) yang membersihkannya. Ketika sebuah struktur menyerahkan pointer fungsi ke PDFium dan Anda melupakan cdecl pada tipe pointer tersebut, kedua belah pihak tidak setuju tentang siapa yang menyesuaikan pointer stack. Keduanya memperbaikinya, atau tidak sama sekali, dan pointer stack bergeser sebesar ukuran argumen pada setiap pemanggilan.

Alasan mengapa cacat ini sulit ditemukan adalah karena kerusakannya bersifat non-lokal. Pemanggilan yang rusak kembali dan terlihat baik-baik saja. Ketidaksejajaran muncul kemudian, di beberapa fungsi yang tidak terkait yang frame-nya sekarang berada pada pointer stack yang meleset beberapa byte, dan itu terwujud sebagai pembacaan liar (wild read), alamat pengembalian yang buruk (bad return address), atau crash dengan backtrace yang menunjuk jauh dari callback yang sebenarnya salah. Form-fill adalah tempat klasik di mana masalah ini terjadi, karena antarmuka form-fill adalah record yang penuh dengan callback yang dipanggil kembali oleh PDFium. Salah satunya, FFI_OpenFile, menyerahkan fungsi ke PDFium yang akan dipanggil untuk membuka file eksternal, dideklarasikan sebagai function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Akhiran cdecl adalah bagian yang penting untuk dicantumkan. Hapus bagian tersebut dan kode masih tetap dikompilasi, masih ditautkan, dan still berjalan dengan baik hingga PDFium memanggil fungsi tersebut. Konvensi pemanggilan adalah bagian dari tipe fungsi itu sendiri. Itu bukan hiasan opsional, dan kompiler tidak akan memperingatkan Anda ketika itu hilang karena tipe fungsi biasa adalah tipe Pascal yang sepenuhnya legal. Satu-satunya pertahanan adalah memperlakukan konvensi pemanggilan sebagai kolom wajib dari setiap tanda tangan yang diimpor dan setiap callback yang Anda kirimkan ke luar.

size_t memiliki lebar pointer, dan pada FPC Win64 itu berarti 64 bit

Cacat kedua adalah ketidakcocokan lebar integer yang hanya muncul pada satu target. size_t pada C didefinisikan cukup lebar untuk menampung ukuran objek apa pun, yang pada platform 64-bit berarti integer tanpa tanda 64-bit. Antarmuka pemuatan progresif (progressive-loading) PDFium berkomunikasi dalam offset byte size_t. Record FX_FILEAVAIL milik penyedia ketersediaan membawa callback IsDataAvail yang dipanggil oleh PDFium dengan offset dan ukuran, dan callback AddSegment milik record FX_DOWNLOADHINTS menerima hal yang sama. Kedua parameter tersebut adalah size_t.

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

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

Jika Anda mendeklarasikan offset tersebut sebagai tipe 32-bit, binding akan berfungsi pada Win32 dan Delphi Win64, lalu secara diam-diam rusak pada FPC and Lazarus Win64. Penyebabnya halus. Pada FPC Win64, NativeUInt adalah tipe 64-bit dengan lebar pointer asli, dan size_t dialiaskan ke tipe tersebut. Binding memiliki komentar di bagian tipe yang memperingatkan dengan tepat agar tidak membayangi NativeUInt pada FPC, karena mendefinisikannya kembali ke alias 32-bit di sana akan memaksa size_t menjadi 32 bit dan merusak setiap parameter size_t yang diteruskan ke atau ditulis oleh pustaka. Offset 64-bit yang tiba di parameter 32-bit akan kehilangan setengah bagian atasnya. Untuk file kecil, setiap offset muat dalam 32 bit dan tidak ada yang salah. Untuk file besar, saat offset melintasi garis empat gigabyte, nilai yang terpotong menunjuk ke tempat lain sama sekali, PDFium menanyakan apakah rentang byte yang salah tersedia, dan pemuatan progresif terhenti atau membaca sampah. Cacat ini tidak terlihat sampai file cukup besar dan targetnya adalah target di mana size_t benar-benar melebar.

Pengecualian Pascal tidak boleh di-unwind melalui frame C

Kelas ketiga adalah tentang model pengecualian (exception model), yang tidak dimiliki oleh C. Ketika PDFium memanggil salah satu callback Anda, kode Pascal Anda berjalan di dalam stack frame C dan C++ yang tidak tahu apa-apa tentang mesin pengecualian Delphi. Jika callback Anda memunculkan pengecualian dan membiarkannya menyebar (propagate), ia akan me-unwind melalui frame yang tidak pernah dibuat untuk di-unwind. Pembersihan internal PDFium sendiri tidak berjalan, invariant internalnya dibiarkan setengah diperbarui, dan proses sekarang berada dalam keadaan yang tidak pernah diantisipasi oleh pustaka. Kontrak untuk callback ini adalah kode kembalian, bukan pengecualian.

Dua callback membuat hal ini menjadi konkret. FPDF_FILEWRITE adalah sink tempat PDFium menulis dokumen yang disimpan, dan FPDF_FILEACCESS adalah sumber tempat ia membaca dokumen masukan. Keduanya diimplementasikan di sini melalui Delphi TStream, dan keduanya dapat gagal seperti kegagalan stream lainnya: disk penuh, stream ditutup di bawah Anda, pembacaan berjalan melewati akhir. Callback penulisan membungkus penulisan stream-nya dan mengubah kegagalan apa pun menjadi kode kegagalan PDFium daripada membiarkannya lolos.

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;

Sisi pembacaan melakukan hal yang sama: pembacaan yang gagal melaporkan nol agar sesuai dengan kontrak FPDF_FILEACCESS daripada memunculkan pengecualian di seluruh batas. Blok except kosong tanpa pemunculan ulang (re-raise) terlihat salah bagi programmer Pascal yang dilatih untuk tidak pernah menelan pengecualian, dan dalam Pascal biasa itu memang salah. Pada batas ABI, ini adalah bentuk yang benar, karena satu-satunya nilai aman yang dapat diserahkan kembali ke pemanggil C adalah kode status yang ia ketahui cara menafsirkannya. Kegagalan tersebut tetap menyebar, hanya saja melalui nilai kembalian, dan kode pemanggil di atas pustaka menampilkannya sebagai EPdfError setelah kontrol kembali ke sisi Pascal.

Pembebasan ganda tersembunyi di jalur kesalahan

Cacat keempat adalah kepemilikan. Handle dokumen PDFium dibuka oleh pustaka dan harus ditutup tepat satu kali, oleh FPDF_CloseDocument. Bahayanya adalah jalur kesalahan yang membebaskan handle yang juga dimiliki oleh pembersihan kedua. Bayangkan rutinitas yang membuat objek wrapper, menetapkan handle dokumen yang baru dibuka ke dalamnya, dan kemudian melakukan lebih banyak pengaturan yang mungkin gagal. Jika pengaturan tersebut memunculkan pengecualian, handler pengembalian cepat (early-return) yang memanggil FPDF_CloseDocument pada handle mentah akan menutupnya, dan kemudian destruktor objek wrapper itu sendiri akan menutupnya lagi ketika objek dibebaskan. Handle dibebaskan dua kali, yang merupakan perilaku tidak terdefinisi (undefined behavior) dan kemungkinan besar menyebabkan crash.

Audit menemukan hal ini pada jalur impor gaya imposisi (imposition-style) yang membangun TPdf di sekitar handle yang sudah terbuka. Perbaikannya adalah menjadikan transfer kepemilikan sebagai satu-satunya sumber kebenaran (single source of truth). Setelah handle ditetapkan ke kolom wrapper, wrapper memilikinya, dan satu-satunya pembersihan pada jalur kesalahan adalah membebaskan wrapper tersebut. Destruktor wrapper memanggil FPDF_CloseDocument untuk Anda, sehingga penutupan eksplisit kedua akan membebaskan ganda (double-free) dokumen yang sama. Handler kesalahan yang diperbaiki membebaskan objek dan memunculkan kembali pengecualian, dan hanya ada satu jalur ke penutupan tersebut.

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;

Record terkelola dan pustaka yang penuh dengan ekspor keduanya memerlukan pembongkaran eksplisit

Kelas terakhir adalah tentang memori yang dikelola kompiler atas nama Anda, yang akan dirusak secara diam-diam oleh kebiasaan pemrograman C. Banyak dari fungsi pembantu binding ini mengmengembalikan record yang berisi WideString atau array dinamis. Kolom tersebut adalah kolom yang dihitung referensinya (reference-counted), dan kompiler memancarkan pembukuan tersembunyi untuk mempertahankan hitungannya. Naluri yang dibawa dari C adalah menghapus record baru dengan FillChar(Result, SizeOf(Result), 0). Tindakan tersebut membubuhkan nol di atas referensi terkelola di dalam record tanpa mendekremennya terlebih dahulu. Kompiler menggunakan kembali satu variabel sementara tersembunyi untuk hasil fungsi di seluruh iterasi loop, sehingga pada iterasi kedua FillChar menimpa pointer string hidup yang tidak pernah dirilis, dan string yang ditunjuknya bocor. Panggil fungsi dalam loop di atas seribu anotasi maka Anda membocorkan seribu string.

Perbaikannya adalah membiarkan bahasa membersihkan record dengan cara yang diketahuinya, yaitu dengan Default(T), yang merilis kolom terkelola apa pun sebelum mengosongkannya.

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

Masalah kepemilikan terkait ada di batas pemuatan pustaka. Binding ini menyelesaikan beberapa ratus pointer fungsi dari PDFium DLL dengan GetProcAddress setelah LoadLibrary. Jika satu ekspor wajib yang diperlukan hilang, status terikat sebagian tersebut berbahaya: puluhan pointer valid, sisanya nil atau usang, dan panggilan apa pun di kemudian hari melalui salah satunya melompat ke modul yang mungkin sudah dibongkar dari memori. Binding menangani hal ini dengan membongkar pustaka dan menjalankan ClearAllBindings penuh yang mengatur ulang setiap pointer yang diimpor kembali ke nil setiap kali ekspor wajib gagal diselesaikan. Setelah itu, no function pointer dangles into an unloaded module, and a later call fails cleanly with a nil-pointer check instead of branching into freed code.

Wrapper adalah tempat di mana empat kontrak dinyatakan kembali secara manual

Tidak ada satu pun dari kelima cacat ini yang eksotis. Cacat-cacat ini adalah mode kegagalan yang dapat diprediksi dari lapisan Pascal tipis di atas API C, dan mereka berkumpul karena lapisan itulah tempat di mana empat kontrak terpisah harus dideklarasikan ulang. Konvensi pemanggilan harus dieja cdecl pada setiap callback. Lebar integer harus cocok dengan size_t pada satu target di mana ia benar-benar melebar. Model pengecualian harus dikonversi ke kode kembalian di setiap callback yang keluar dari Pascal. Kepemilikan setiap handle dan setiap kolom terkelola harus dinyatakan satu kali dan dipatuhi pada setiap jalur, termasuk jalur kesalahan yang tidak pernah dijalankan oleh siapa pun hingga tahap produksi. Melewatkan salah satunya dan Anda mendapatkan cacat yang gejalanya muncul jauh dari penyebabnya, yang membuat kategori ini mahal. Nilai audit ini bukan pada perbaikan tunggal apa pun, melainkan memperlakukan masing-masing hal ini sebagai disiplinnya sendiri untuk diperiksa di seluruh binding.

Jika Anda ingin melihat binding melakukan pekerjaan nyata daripada menjaga tepinya, teknik render-cache dan zoom di catatan kami tentang kinerja render-cache dan zoom menunjukkan jalur rendering, dan panduan lintas-kompiler dalam membangun penampil Lazarus dan FPC adalah tempat di mana perilaku size_t Win64 yang dijelaskan di sini benar-benar penting. Keduanya dibangun di atas pekerjaan memori-aman dan ABI yang sama yang dikirimkan dalam PDFium Component untuk Delphi, Lazarus, and C++Builder, bersama dengan API rendering, ekstraksi teks, dan form yang dibahas di bagian lain di blog ini.