مقاله فنی

تجزیهٔ حافظه‌امن PDF در دلفی: یک راهنمای دفاعی

یک خط پردازش دریافت اسناد فایل‌هایی را می‌پذیرد که غریبه‌ها نوشته‌اند. فاکتورها، اسکن‌ها، پیوست‌های یک فرم وب: هر کدام ادعا می‌کند PDF است و صدها عدد حمل می‌کند که انتظار می‌رود تجزیه‌گر شما بر پایهٔ آن‌ها عمل کند. طول استریم‌ها، ابعاد تصویر، افست‌های بایتی، ارجاع‌های شیء — هر کدام را همان کسی برگزیده که فایل را تولید کرده، و بارگذاری بریده یا سندی که عمداً بدشکل ساخته شده دیر یا زود یکی از این اعداد را جایی می‌گذارد که آسیب بزند. تفاوت میان تجزیه‌گری که از آن فایل جان سالم به در می‌برد و تجزیه‌گری که فرو می‌ریزد، یا با حافظهٔ خراب به کار ادامه می‌دهد، مجموعهٔ کوچکی از عادت‌هاست که به هیچ کتابخانهٔ PDF خاصی وابسته نیست

این عادت‌ها یک فرض مشترک دارند: مقداری که از فایل خوانده می‌شود یک ادعاست، نه یک اندازه‌گیری. تنها پس از سنجیده شدن در برابر چیزی که خود تجزیه‌گر اندازه گرفته قابل استفاده می‌شود — اندازهٔ واقعی فایل، شمار واقعی بایت‌هایی که یک رمزگشا تولید کرده، ژرفای واقعی یک بازگشت. آنچه در ادامه می‌آید همین فرض است که بر جاهایی اعمال می‌شود که تجزیه‌گرهای سند در عمل آنجا می‌شکنند

نقشهٔ مقادیر PDF زیر کنترل مهاجم شامل ‎/Length، startxref و استریم‌های flate در کنار کران‌های اندازه‌گیری‌شده‌ای که تجزیهٔ PDF در دلفی را حافظه‌امن نگه می‌دارند
هر عددی که یک PDF خصمانه عرضه می‌کند تا وقتی تجزیه‌گر چیزی واقعی را اندازه نگرفته فقط یک ادعاست، از طول استریم‌ها تا افست‌های xref و انبساط flate

طول اعلام‌شده یک ادعاست، نه یک اندازه‌گیری

ساده‌ترین ناهمخوانی طول استریم است. یک شیء استریم PDF شمار بایت‌های خود را در کلید /Length اعلام می‌کند، و دادهٔ واقعی میان کلمه‌های کلیدی stream و endstream می‌نشیند. هیچ‌چیز این دو را وادار به توافق نمی‌کند. فایل بریده بایت‌های واقعی کمتری از شمار اعلام‌شده دارد؛ فایلی از یک مولد خراب می‌تواند طولی اعلام کند که از انتهای فایل یا به درون شیء همسایه بگذرد. از روی مقدار اعلام‌شده تخصیص دهید و تا endstream کپی کنید، بافر را سرریز می‌کنید؛ دقیقاً همان شمار اعلام‌شده را بدون بررسی موجودی بخوانید، از انتهای فایل بیرون می‌روید. بگذارید مقدار اعلام‌شده تنها پس از محدود شدن در برابر فاصلهٔ اندازه‌گیری‌شده تا انتهای داده تخصیص را هدایت کند، و اختلاف را یک نقطهٔ تصمیم بدانید — ترمیم با پویش برای یافتن endstream، یا رد کردن استریم — و هرگز چیزی که بی‌صدا باورش کنید

پارامترهای تصویری که رستری بزرگ‌تر از تخصیص شما را توصیف می‌کنند

استریم‌های تصویری خطر را بالا می‌برند چون دو دستهٔ مستقل از اعداد همان پیکسل‌ها را توصیف می‌کنند. دیکشنری تصویر /Width و /Height را حمل می‌کند و بافرهای رستر معمولاً از روی آن‌ها اندازه می‌گیرند. فیلتر رمزگشایی هندسهٔ خود را دارد: CCITTFaxDecode مقادیر /Columns، /Rows و /K را از DecodeParms خود می‌گیرد، جایی که /K طرح Group 3 یا Group 4 را برمی‌گزیند و رمزگشا به‌ازای هر خط پویش (Columns + 7) div 8 بایت بیرون می‌دهد. فایلی که /Width 100 اعلام می‌کند اما /Columns 1728 — مقدار پیش‌فرض — را به فیلتر می‌سپارد، رمزگشا را وادار می‌کند بیش از شانزده برابر بایت‌های مورد انتظار بافر را در هر ردیف تولید کند، و سرریز خط‌به‌خط روی هرچه پس از تخصیص نشسته فرود می‌آید. وقتی /Rows غایب باشد، رمزگشا تا وقتی داده بگوید بس، پیش می‌رود، پس شمار ردیف‌ها را هم کراندار کنید. DCTDecode همان درز را دارد: دادهٔ JPEG عرض و ارتفاع خود را در نشانگر SOF حمل می‌کند و هیچ‌چیز آن‌ها را به هم‌خوانی با دیکشنری ملزم نمی‌کند

قاعدهٔ دفاعی مکانیکی است: اندازهٔ رستر مورد انتظار را از روی پارامترهای رمزگشایی اعتبارسنجی‌شده حساب کنید — /Columns و /Rows خود فیلتر برای CCITT، ابعاد SOF برای DCT — آن را در برابر محدودیت‌هایتان بسنجید، از روی آن تخصیص دهید، و حین رمزگشایی راستی‌آزمایی کنید که خروجی هرگز از تخصیص فراتر نرود. وقتی دیکشنری و فیلتر دربارهٔ هندسه اختلاف دارند، آشتی‌شان دهید یا تصویر را رد کنید. کاری که تجزیه‌گر هرگز نباید بکند این است که بافر را از روی یک دسته عدد اندازه بگیرد و بگذارد رمزگشا با دستهٔ دیگر بدود

جریان تصمیمی که ‎/Width و /Height دیکشنری تصویر را پیش از تخصیص بافر رستر در دلفی با ‎/Columns و /Rows در CCITTFaxDecode آشتی می‌دهد
بافرهای رستر باید از روی پارامترهای رمزگشایی اعتبارسنجی‌شده اندازه بگیرند، هرگز فقط از روی ابعاد دیکشنری

دام‌های محاسبه و تخصیص در دلفی

سه رفتار دلفی حتی تجزیه‌گری را که قصد اعتبارسنجی دارد تضعیف می‌کند. نخستین، ضرب 32 بیتی است: دلفی حاصل‌ضرب دو عملوند Integer را صرف‌نظر از پهنای مقصد در 32 بیت ارزیابی می‌کند، پس Width * Height * BytesPerPixel می‌تواند حتی وقتی هر عامل بررسی سلامت خود را رد کند سرریز و دور بزند. یک اسکن 30000 در 30000 با سه بایت بر پیکسل 2.7 میلیارد بایت است که در حساب 32 بیتی علامت‌دار به عدد منفی می‌پیچد؛ عامل‌های اندکی متفاوت به طولی مثبت و کوچک می‌پیچند که تخصیص می‌گیرد و بافر را کم‌اندازه می‌کند. با تبدیل نوع عملوند نخست کل عبارت را پهن کنید — Size := Int64(Width) * Height * BytesPerPixel — و سپس پیش از آنکه چیزی به SetLength برسد آن را با یک سقف صریح بسنجید

دومین، بررسی بازه است. پیکربندی انتشار پیش‌فرض دلفی آن را خاموش عرضه می‌کند، پس ایندکسی خارج از بازه که از دادهٔ فایل حساب شده استثنا نمی‌دهد — حافظهٔ مجاور آرایه را می‌خواند یا می‌نویسد. آن را با {$R+}{$Q+} برای سرریز محاسباتی) در بالای هر واحدی که با مقادیر برگرفته از فایل ایندکس می‌زند دوباره روشن کنید. هزینه‌اش در کنار ورودی/خروجی‌ای که تجزیه‌گر به‌هرحال انجام می‌دهد قابل اندازه‌گیری نیست، و خرابی خاموش را به یک ERangeError قابل گرفتن تبدیل می‌کند

سومین، TMemoryStream.SetSize با یک Int64 برگرفته از فایل است. روی کتابخانهٔ اجرایی امروزی هرچه فایل خواسته تخصیص می‌دهد، پس یک استریم که چهار گیگابایت ادعا می‌کند وسط دریافت به شکست کمبود حافظه بدل می‌شود. روی کتابخانه‌های اجرایی قدیمی‌تر، جایی که SetSize یک Longint می‌گیرد، مقدار نخست بی‌صدا باریک می‌شود: مقدار اعلام‌شدهٔ $100000010 می‌شود 16، تخصیص موفق می‌شود، و نوشتن دادهٔ واقعی بسیار فراتر از آن می‌رود. هر اندازه را پیش از آنکه فراخوانی تخصیصی آن را ببیند در برابر اندازهٔ اندازه‌گیری‌شدهٔ منبع و یک سقف سخت اعتبارسنجی کنید

افست‌هایی که بیرون از فایل را نشانه می‌گیرند

جدول ارجاع متقاطع شماره‌های شیء را به افست‌های بایتی مطلق نگاشت می‌کند، و تجزیه‌گر به هر جا که اشاره کند می‌رود. در فایلی آسیب‌دیده یا خصمانه، آن افست‌ها فراتر از انتهای فایل یا درون ساختارهای بی‌ربط فرود می‌آیند. TStream این شکست را خاموش می‌کند: تنظیم Position فراتر از Size خطا نیست، و یک Read ساده پس از انتها فقط بایت‌های کمتری از درخواست برمی‌گرداند، پس کدی که بررسی شمارش را رد می‌کند بایت‌های کهنهٔ شیء پیشین را همچنان تجزیه می‌کند. دفاع یک گلوگاه است — یک تابع کمکی که هر جست‌وجو و خواندن هدایت‌شده با فایل از آن می‌گذرد و افست و شمارش را پیش از حرکت استریم در برابر اندازهٔ اندازه‌گیری‌شدهٔ فایل اعتبارسنجی می‌کند

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // هیچ شیء تکی نباید از 64 مگابایت بگذرد

type
  EPdfBoundsError = class(Exception);

// هر جست‌وجو و خواندن هدایت‌شده با فایل از اینجا می‌گذرد. Offset و Count
// ادعاهای برگرفته از فایل‌اند؛ Source.Size اندازه‌ای است که باید در آن بگنجند.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

افست‌های ارجاع متقاطع، گسترهٔ استریم‌ها و خواندن فایل‌های توکار را از راه آن مسیردهی کنید تا افست بد به جای یک نقض دسترسی سه فراخوانی بعد، به ردی تمیز بدل شود که اعداد را نام می‌برد

چرخه و ژرفا در گراف اشیا

یک PDF گراف است، نه درخت. هر مقداری می‌تواند ارجاع غیرمستقیم باشد، یک ارجاع می‌تواند به ارجاعی دیگر حل شود — /Length 12 0 R، جایی که شیء 12 مقدار 13 0 R را نگه می‌دارد — و هیچ‌چیز مانع بسته شدن زنجیره روی خودش نیست. حل‌کننده‌ای که ساده‌لوحانه ارجاع‌ها را دنبال می‌کند تا تهی شدن پشتهٔ بومی بازگشت می‌زند، و تهی شدن پشته چیزی نیست که بگیریدش؛ فرایند را تمام می‌کند. آرایه‌ها و دیکشنری‌های عمیقاً تودرتو بدون هیچ چرخه‌ای به همان پایان می‌رسند

از دو پاسبان با هم استفاده کنید: یک شمارندهٔ ژرفای صریح حالت صادق‌اما‌عمیق را در حدی کراندار می‌کند که هیچ فایل مشروعی به آن نزدیک نمی‌شود، و یک مجموعهٔ بازدیدشده چرخهٔ واقعی را در دومین بازدید می‌گیرد و آن را به خطایی دقیق و قابل گزارش بدل می‌کند نه به برخورد با یک محدودیت

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // بسیار عمیق‌تر از هر زنجیرهٔ ارجاع مشروع

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // وقتی Kind = pvReference باشد معنا دارد
    // ... فیلدهای بار برای گونه‌های باقی‌مانده
  end;

// LoadObject روال خود شماست: افست xref را برای ObjNumber می‌یابد،
// شیء را با ReadBounded می‌خواند و آن را تجزیه می‌کند.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('reference chain exceeds depth limit');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'circular reference through object %d', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // برای نمونه /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // خواهران و برادران قانوناً می‌توانند این شیء را مشترک بدانند
  end;
end;

بازگشایی فشرده یک تقویت‌کننده است

چند کیلوبایت ورودی FlateDecode می‌تواند تا گیگابایت‌ها باد کند؛ فشرده‌سازی همه‌منظوره به متن آشکار تکراری پاداش می‌دهد، و مهاجم می‌تواند آن را بیشینه تکراری بسازد. اندازهٔ باد‌کردهٔ هر استریم را در حدی که مصرف‌کننده‌اش به‌طور محتمل نیاز دارد سقف بگذارید، و یک بودجهٔ دوم برای هر سند نگه دارید: پانصد استریم که هر کدام کمی زیر سقف تک‌استریم باشند به همان قطعیتِ یک استریم غول‌پیکر حافظه را تمام می‌کنند. جای این بررسی درون حلقهٔ بادکردن است، با شمردن بایت‌های خروجی همچنان که تولید می‌شوند و لغو کردن هنگام تخطی، نه پس از حلقه که حافظه دیگر خرج شده است. بودجهٔ سند که به شکل ضریبی از اندازهٔ فایل فشرده بیان شود خوب کار می‌کند، چون اسناد مشروع بسیار پایین‌تر از نسبت‌هایی که یک استریم دست‌ساز به آن‌ها می‌رسد خوشه می‌زنند

PDF: مقایسهٔ دوپنلی میان بادکردن مهارنشدهٔ FlateDecode که به شکست کمبود حافظه می‌انجامد و بودجه‌های خروجی که درون حلقهٔ بادکردن اعمال می‌شوند
بازگشایی فشرده ورودی‌های خصمانهٔ کوچک را تقویت می‌کند، پس بودجه‌های خروجی باید درون حلقهٔ بادکردن شمرده شوند نه اینکه بعداً ممیزی گردند

دفاع لایه‌لایه فراتر از واحدهای خودتان

همان دسته‌های نقص درون کتابخانه‌ها هم زندگی می‌کنند. دو مطالعهٔ موردی در همین وبلاگ نمونه‌های واقعی را می‌پیمایند: سرریزهای عدد صحیح، بازگشت بی‌کران و بافرهای مقداردهی‌نشده که در یک موتور بومی پاسکال بسته شدند در سخت‌سازی یک تجزیه‌گر PDF پاسکالی در برابر فایل‌های مخرب، و خطرهای قرارداد فراخوانی، پهنای عدد صحیح و مالکیت در پیوند زدن به یک موتور C در سخت‌سازی پیوند PDFium Component. برای دریافت واقعاً نامعتمد — یک فرم بارگذاری عمومی، یک صندوق پستی احرازهویت‌نشده — کار تجزیه و رمزگشایی را در فرایندی جداگانه با سطح دسترسی پایین اجرا کنید تا فایلی که همهٔ پاسبان‌های درون‌فرایندی را شکست می‌دهد به قیمت یک کار ناموفق تمام شود نه یک سرویس از کارافتاده

یک سیاههٔ پیش‌پرواز

پیش از آنکه ساخت بعدی عرضه شود، تجزیه‌گر را در برابر این فهرست بپیمایید: هر بافر استریم از روی طولی محدودشده اندازه بگیرد نه از روی طول اعلام‌شده؛ هر رستر از روی پارامترهای اعتبارسنجی‌شدهٔ رمزگشا اندازه بگیرد و در برابر خروجی رمزگشا سنجیده شود؛ هر حاصل‌ضرب ابعاد در Int64 ارزیابی و با سقفی صریح مقایسه شود؛ دستور {$R+} در هر واحدی که با مقادیر برگرفته از فایل ایندکس می‌زند فعال باشد؛ هر جست‌وجو در برابر اندازهٔ اندازه‌گیری‌شدهٔ فایل کران‌سنجی شود؛ هر حل ارجاع محدود به ژرفا و بررسی‌شده از نظر چرخه باشد؛ هر حلقهٔ بادکردن خروجی را در برابر بودجه‌های هر استریم و هر سند بشمارد. هیچ‌یک از این بررسی‌ها روی سندی مشروع زمان قابل اندازه‌گیری خرج نمی‌کند، و هر کدام خرابی حافظه را به ردی تمیز و قابل ثبت در گزارش بدل می‌کند

یادداشت: محصولات losLab یعنی HotPDF Delphi Component، PDF Library for Delphi Delphi PDF Library و PDFium Component این کران‌سنجی‌ها، محدودیت‌های ژرفا و سقف‌های انبساط را درونی اعمال می‌کنند، پس خط پردازش دریافتی که بر آن‌ها ساخته شود از یک خط پایهٔ سخت‌شده آغاز می‌کند