Technical Article

مقاوم‌سازی یک بایندینگ PDFium VCL: امنیت ABI و حافظه

یک بایندینگ Pascal روی یک کتابخانه C مانند Pascal معمولی خوانده می‌شود. شما یک متد را فراخوانی می‌کنید، یک رکورد دریافت می‌کنید و آنچه را که تخصیص داده‌اید آزاد می‌کنید. مشکل این است که PDFium یک کتابخانه C و C++ با قرارداد فراخوانی (Calling convention) خود، عرض‌های صحیح خود و قوانین خود در مورد مالکیت حافظه و آزادکننده آن است. هیچ‌کدام از این‌ها به خودی خود از مرز زبان عبور نمی‌کنند. تک‌تک آن قراردادها باید به صورت دستی در اعلان‌های Pascal دوباره بیان شوند، و یک کلمه اشتباه، یک فراخوانی با ظاهر تمیز را به خرابکاری در پشته (Stack corruption)، یک آفست کوتاه شده یا یک آزادکننده دوگانه (Double free) تبدیل می‌کند. یک ممیزی نسخه v1.61.0 از یک بایندینگ PDFium VCL، یک نقص از هر نوع را نشان داد. آن‌ها ارزش بررسی دارند زیرا مخصوص این بایندینگ نیستند. آن‌ها خطرات همیشگی بسته‌بندی هر API از C در Delphi یا Lazarus هستند

cdecl بخشی از نوع تابع است، نه یک تزئین

کتابخانه PDFium به صورت C کامپایل شده است. در سیستم‌عامل Win32، خروجی‌های آن و از همه مهم‌تر، فراخوانی‌هایی که احضار می‌کند از قرارداد فراخوانی cdecl استفاده می‌کنند. تحت cdecl، فراخوان‌کننده پس از بازگشت فراخوانی، پشته را پاک‌سازی می‌کند. پیش‌فرض بومی Delphi در واقع register است و استاندارد Win32 C برای فراخوانی‌ها در برخی کتابخانه‌ها stdcall است، جایی که در عوض فراخوانی‌شونده پاک‌سازی را انجام می‌دهد. هنگامی که یک ساختار یک اشاره‌گر تابع را به PDFium تحویل می‌دهد و شما فراموش می‌کنید که cdecl را روی نوع آن اشاره‌گر قرار دهید، دو طرف در مورد اینکه چه کسی اشاره‌گر پشته را تنظیم می‌کند اختلاف پیدا می‌کنند. یا هر دو آن را اصلاح می‌کنند، یا هیچ‌کدام، و اشاره‌گر پشته در هر بار احضار به اندازه آرگومان‌ها منحرف می‌شود

دلیل سخت بودن یافتن این نقص این است که آسیب به صورت غیرمحلی رخ می‌دهد. فراخوانی خراب برمی‌گرداند و درست به نظر می‌رسد. عدم هم‌ترازی بعداً در برخی از توابع نامربوط ظاهر می‌شود که فریم آن‌ها اکنون روی یک اشاره‌گر پشته قرار دارد که چند بایت جابه‌جا شده است، و خود را به صورت یک خواندن تصادفی، یک آدرس بازگشتی بد یا کرش با یک 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 این یعنی ۶۴ بیت

نقص دوم عدم تطابق عرض عدد صحیح است که فقط روی یک هدف ظاهر می‌شود. نوع داده size_t در C طوری تعریف شده است که به اندازه کافی گسترده باشد تا هر اندازه شیء را نگه دارد، که در یک پلتفرم ۶۴ بیتی به معنای یک عدد صحیح بدون علامت ۶۴ بیتی است. رابط‌های بارگذاری تدریجی (Progressive-loading) در PDFium با آفست‌های بایت 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;

اگر آن آفست‌ها را به عنوان یک نوع ۳۲ بیتی اعلام کنید، بایندینگ روی Win32 و روی Delphi Win64 کار می‌کند، سپس به طور بی‌صدا در FPC و Lazarus Win64 خراب می‌شود. علت این امر ظریف است. در FPC Win64، نوع NativeUInt یک نوع ۶۴ بیتی واقعی هم‌عرض اشاره‌گر است و size_t به عنوان نام مستعار برای آن تعریف شده است. بایندینگ دارای کامنتی در بخش نوع است که دقیقاً در مورد سایه انداختن بر روی NativeUInt در FPC هشدار می‌دهد، زیرا تعریف مجدد آن به یک نام مستعار ۳۲ بیتی در آنجا باعث می‌شود size_t به ۳۲ بیت محدود شود و هر پارامتر size_t ارسالی به کتابخانه یا نوشته‌شده توسط آن را خراب کند. یک آفست ۶۴ بیتی که به یک پارامتر ۳۲ بیتی می‌رسد، نیمه بالایی خود را از دست می‌دهد. برای یک فایل کوچک، هر آفست در ۳۲ بیت جا می‌شود و هیچ مشکلی پیش می‌آید. اما برای یک فایل بزرگ، لحظه‌ای که یک آفست از مرز چهار گیگابایت عبور می‌کند، مقدار کوتاه شده به جای دیگری اشاره می‌کند، PDFium می‌پرسد آیا محدوده بایت اشتباه در دسترس است یا خیر، و بارگذاری تدریجی متوقف شده یا داده‌های بیهوده را می‌خواند. این نقص تا زمانی که فایل به اندازه کافی بزرگ نباشد و هدف همان هدفی نباشد که size_t در آن عریض‌تر شده است، نامرئی می‌ماند

یک استثنا در Pascal هرگز نباید از طریق یک فریم C باز شود

کلاس سوم در مورد مدل استثنا است که زبان C فاقد آن است. وقتی PDFium یکی از فراخوانی‌های شما را احضار می‌کند، کد Pascal شما در داخل پشته‌ای از فریم‌های C و C++ اجرا می‌شود که چیزی درباره مکانیسم استثنای Delphi نمی‌دانند. اگر فراخوانی شما استثنایی را ایجاد کند و اجازه دهد منتشر شود، از طریق فریم‌هایی باز می‌شود (Unwind) که هرگز برای باز شدن ساخته نشده‌اند. پاک‌سازی خود PDFium اجرا نمی‌شود، ثابت‌های داخلی آن نیمه‌کاره به‌روزرسانی می‌شوند و فرآیند اکنون در حالتی قرار می‌گیرد که کتابخانه هرگز پیش‌بینی نکرده بود. قرارداد این فراخوانی‌ها یک کد بازگشتی است، نه یک استثنا

دو فراخوانی این موضوع را ملموس می‌کنند. FPDF_FILEWRITE مقصدی است که PDFium سند ذخیره‌شده را در آن می‌نویسد، و FPDF_FILEACCESS منبعی است که سند ورودی را از آن می‌خواند. هر دو در اینجا بر روی یک TStream در Delphi پیاده‌سازی شده‌اند و هر دو می‌توانند به روشی که هر جریانی با شکست مواجه می‌شود، خراب شوند: دیسک پر می‌شود، جریان در زیر کار بسته می‌شود، یا یک خواندن فراتر از انتها می‌رود. فراخوانی نوشتن، عملیات نوشتن جریان خود را بسته‌بندی می‌کند و به جای اجازه فرار به خطا، هرگونه خرابی را به کد خطای 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 خالی بدون پرتاب مجدد خطا برای برنامه‌نویس Pascal که آموزش دیده هرگز استثناها را نادیده نگیرد، اشتباه به نظر می‌رسد و در Pascal معمولی واقعاً اشتباه است. اما در مرز ABI، این شکل درست است، زیرا تنها مقدار ایمن برای بازگرداندن به فراخوان‌کننده C، یک کد وضعیت است که روش تفسیر آن را می‌داند. خرابی همچنان منتشر می‌شود، اما فقط از طریق مقدار بازگشتی، و کد فراخوان‌کننده در بالای کتابخانه، پس از بازگشت کنترل به سمت Pascal، آن را به عنوان EPdfError ظاهر می‌کند

آزادکننده دوگانه در مسیر خطا پنهان می‌شود

نقص چهارم مالکیت است. یک هندل سند PDFium توسط کتابخانه باز می‌شود و باید دقیقاً یک بار توسط FPDF_CloseDocument بسته شود. خطر اصلی در مسیر خطایی است که هندلی را که یک پاک‌ساز دوم نیز مالک آن است، آزاد می‌کند. روالی را تصور کنید که یک شیء بسته‌بند (Wrapper) ایجاد می‌کند، یک هندل سند تازه بازشده را به آن اختصاص می‌دهد و سپس تنظیمات بیشتری را انجام می‌دهد که ممکن است با شکست مواجه شوند. اگر این تنظیمات با خطا مواجه شود، کنترل‌کننده بازگشت زودهنگام که FPDF_CloseDocument را روی هندل خام فراخوانی می‌کند آن را می‌بندد، و سپس مخرب خود شیء بسته‌بند هنگام آزاد شدن شیء دوباره آن را خواهد بست. هندل دو بار آزاد می‌شود که این یک رفتار تعریف‌نشده و احتمالاً منجر به کرش است

ممیزی این مشکل را در یک مسیر وارد کردن به سبک تحمیل (Imposition-style) پیدا کرد که یک TPdf را در اطراف یک هندل از قبل بازشده می‌سازد. راه حل این است که انتقال مالکیت به عنوان تنها منبع حقیقت قرار گیرد. هنگامی که هندل به فیلد بسته‌بند اختصاص داده شد، بسته‌بند مالک آن است و تنها پاک‌سازی در مسیر خطا، آزاد کردن بسته‌بند است. مخرب بسته‌بند، تابع FPDF_CloseDocument را برای شما فراخوانی می‌کند، بنابراین یک بستن صریح دوم باعث آزاد شدن دوگانه همان سند می‌شود. کنترل‌کننده خطای اصلاح‌شده شیء را آزاد می‌کند و مجدداً خطا را ایجاد می‌نماید، و دقیقاً یک مسیر برای بستن وجود دارد

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;

رکوردهای مدیریت‌شده و کتابخانه‌ای پر از توابع خروجی، هر دو نیاز به تخریب صریح دارند

کلاس آخر مربوط به حافظه‌ای است که کامپایلر به نمایندگی از شما مدیریت می‌کند، که یک عادت به سبک زبان C می‌تواند آن را به آرامی خراب کند. بسیاری از توابع کمکی این بایندینگ رکوردی را بازمی‌گردانند که شامل یک 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 یا قدیمی هستند، و هر فراخوانی بعدی از طریق یکی از آن‌ها به ماژولی می‌پرد که ممکن است قبلاً تخلیه شده باشد. بایندینگ این مشکل را با تخلیه کتابخانه و اجرای یک ClearAllBindings کامل حل می‌کند که هر زمان یک تابع خروجی مورد نیاز حل نشد، هر اشاره‌گر واردشده را به nil بازنشانی می‌کند. پس از آن، هیچ اشاره‌گر تابعی به ماژول تخلیه‌شده آویزان نمی‌ماند و فراخوانی بعدی به جای پرش به کد آزادشده، با بررسی اشاره‌گر nil به طور تمیز شکست می‌خورد

شیء بسته‌بند جایی است که چهار قرارداد به صورت دستی تکرار می‌شوند

هیچ‌یک از این پنج نقص عجیب و غریب نیستند. آن‌ها حالت‌های شکست قابل پیش‌بینی یک لایه نازک Pascal روی یک API از C هستند و به این دلیل متمرکز می‌شوند که آن لایه دقیقاً همان جایی است که چهار قرارداد جداگانه باید دوباره اعلام شوند. قرارداد فراخوانی باید به عنوان cdecl در هر فراخوانی املا شود. عرض عدد صحیح باید با size_t در هدفی که واقعاً عریض‌تر می‌شود مطابقت داشته باشد. مدل استثنا باید در هر فراخوانی که از Pascal خارج می‌شود به کدهای بازگشتی تبدیل شود. مالکیت هر هندل و هر فیلد مدیریت‌شده باید یک بار بیان شده و در هر مسیر، از جمله مسیرهای خطایی که هیچ‌کس تا زمان تولید از آن‌ها استفاده نمی‌کند، رعایت شود. هرکدام را که از دست بدهید، نقصی دریافت می‌کنید که علامت آن بسیار دور از علت آن ظاهر می‌شود، که این همان چیزی است که این دسته را پرهزینه می‌کند. ارزش ممیزی کمتر در تک‌تک اصلاحات بود، بلکه بیشتر در برخورد با هر یک از این‌ها به عنوان انضباط خاص خود برای بررسی در کل بایندینگ بود

اگر می‌خواهید بایندینگ را در حال انجام کار واقعی به جای محافظت از لبه‌هایش ببینید، تکنیک‌های کش رندر و بزرگ‌نمایی در یادداشت ما درباره عملکرد کش رندر و بزرگ‌نمایی مسیر رندر را نشان می‌دهند، و راهنمای کامپایلر متقاطع در ساخت یک نمایشگر Lazarus و FPC جایی است که رفتار size_t در Win64 که در اینجا توضیح داده شد واقعاً اهمیت پیدا می‌کند. هر دو بر روی همان کارهای امنیت حافظه و ABI ساخته شده‌اند که در کامپوننت PDFium برای Delphi، Lazarus و C++Builder همراه با APIهای رندر، استخراج متن و فرم که در بخش‌های دیگر این وبلاگ پوشش داده شده‌اند، عرضه می‌شود