مقاله فنی

بمب‌های Decode در PDF در Delphi: بودجه زنجیره فیلتر HotPDF

یک PDF ۲۰ کیلوبایتی که یک process سرویس را می‌خکوب می‌کند تا OOM killer آن را بگیرد، یک باگ در کد شما نیست، یک بمب decompression است. HotPDF، کامپوننت VCL بومی PDF برای Delphi و C++Builder، آن را با DecodeBudgetBytes محدود می‌کند، سقفی به‌ازای هر زنجیره فیلتر که به‌طور پیش‌فرض ۲۶۸۴۳۵۴۵۶ بایت است و هر مرحله decode را در برابر یک بودجه مشترک واحد حساب می‌کند

فایل ۲۰ کیلوبایتی‌ای که یک process worker را خورد

شکل حادثه همیشه یکسان است. یک queue worker که thumbnail رندر می‌کند یک upload را برمی‌دارد، حافظه resident در کمتر از دو ثانیه از ۱۲ گیگابایت بالاتر می‌رود، و process بدون هیچ stack trace‌ای ناپدید می‌شود. فایل ۲۰ کیلوبایت است. یک صفحه دارد، یک content stream، و یک آرایه /Filter با پنج entry. هر نامی در آن آرایه فیلتری است که مشخصات تعریف می‌کند، هر مرحله بدون خطا decode می‌شود، و هیچ‌چیز در فایل ناقص نیست. همین است که این دسته از ورودی را دشوار می‌کند: هیچ بایت خرابی برای رد کردن وجود ندارد

این همان مشکل decode کردن درست یک فیلتر واحد نیست. درست کردن LZWDecode و predictor در /DecodeParms موضوع خودش است، که در بررسی LZW، predictorها و DecodeParms روی اسناد بارگذاری‌شده پوشش داده شده. اینجا هر decoder از قبل درست است. شکست این است که decoderهای درست هنگامی که پنج تای آن‌ها را پشت‌سرهم اجرا کنید و هیچ‌کس مجموع را نشمارد چه می‌کنند. ISO 32000-1 §7.4 صریح است که /Filter می‌تواند یک نام واحد یا یک آرایه از نام‌ها باشد، و یک آرایه به ترتیب اعمال می‌شود، اول entry اول. هیچ‌چیز درباره اینکه یک مرحله چقدر می‌تواند ورودی خود را گسترش دهد نمی‌گوید، و هیچ‌چیز درباره مجموع در سراسر زنجیره. یک مرحله ASCIIHexDecode ورودی خود را تقریباً نصف می‌کند، که بی‌ضرر به‌نظر می‌رسد. یک مرحله FlateDecode روی یک اجرای بایت‌های صفر به نسبت‌هایی در هزاران می‌رسد. آن‌ها را زنجیر کنید و حساب ضربی است: ۲۰ کیلوبایت به ۲۰ مگابایت تبدیل می‌شود، به ۲۰ گیگابایت تبدیل می‌شود، و هر گام مجزا یک decode مطابق از یک stream قانونی است

چرا یک محدودیت به‌ازای هر فیلتر از توقف یک بمب decode ناتوان است؟

چون یک محدودیت به‌ازای هر فیلتر در هر عنصر از آرایه /Filter دوباره مسلح می‌شود. یک زنجیره پنج‌مرحله‌ای زیر یک سقف ۲۵۶ MiB به‌ازای هر مرحله ۱٫۲۵ GiB را مجاز می‌کند، و مرحله آخر همچنان با یک مجاز کاملاً تازه شروع می‌شود، صرف‌نظر از اینکه چهار مرحله پیش از آن چه تولید کرده باشند. محدودیت صادقانه اعمال می‌شود و هیچ‌چیزی که اهمیت دارد را محدود نمی‌کند. HotPDF دقیقاً همان شکل را پیش از v2.447.0 داشت، و یک شکاف دوم هم کنارش داشت. decompressor LZW یک سقف MaxOutputBytes حمل می‌کرد و مسیر predictor تصویر ردیف‌های خودش را حساب می‌کرد، پس آن دو محلی محدود بودند. FlateDecode، ASCIIHexDecode، ASCII85Decode و RunLengthDecode اصلاً هیچ سقفی نداشتند: هر کدام در یک TMemoryStream می‌نوشت تا ورودی تمام شود یا allocator تسلیم شود. پس یک زنجیره خصمانه دو راه عبور داشت. می‌توانست از یک فیلتر کاملاً بی‌محافظ استفاده کند، یا می‌توانست از فیلترهای محافظت‌شده استفاده کند و صرفاً بیشتر از آن‌ها اضافه کند

یک جزئیات سوم هست که یک رفع ساده‌لوحانه از دست می‌دهد. عددی که به آن اهمیت می‌دهید اندازه خروجی نهایی decode شده نیست. اوج است، و اوج معمولاً در یک بافر میانی زندگی می‌کند. زنجیره‌ای که به یک content stream متوسط ۴ مگابایتی پایان می‌یابد می‌تواند در مرحله سوم ۸ گیگابایت تخصیص دهد و چیزی کاملاً معقول به‌نظر برساند بازگرداند. بررسی طول نتیجه پس از واقعه هیچ‌چیز درباره تخصیصی که process را کشته به شما نمی‌گوید

یک ردیاب بودجه به‌ازای هر زنجیره فیلتر

رفع در HotPDF v2.447.0 این است که حسابداری در سراسر زنجیره باشد نه مرحله. هر زنجیره فیلتر یک THPDFDecodeBudgetTracker می‌سازد، و هر decoder از طریق یک THPDFBudgetWriteStream می‌نویسد که هدف واقعی را می‌پیچد. این wrapper پیش از آنکه یک بایت واحد را forward کند Budget.Consume(Count) را فراخوانی می‌کند، پس امتناع درحالی رخ می‌دهد که stream مقصد هنوز اندازه قدیمی خودش است. آن ترتیب کل هدف است: بررسی‌ای که پس از رشد از قبل بافر انجام شود یک تشخیص است، نه یک دفاع

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

سقف‌های محلی از بین نرفتند، آن‌ها فرافکنی‌های بودجه مشترک شدند. مرحله LZW اکنون Decoder.MaxOutputBytes := Budget.RemainingBytes را تنظیم می‌کند، پس سقف خصوصی آن هرچه زنجیره باقی گذاشته است نه یک مجاز مستقل. مرحله predictor تصویر با BeginFilter باز می‌شود و نیازمندی ردیف خود را از طریق Consume پیش از تخصیص حساب می‌کند، که یعنی خروجی predictor به همان بودجه‌ای صورتحساب می‌شود که فیلترهای عمومی‌ای که آن را تغذیه کردند. این به‌ویژه در مسیر تصویر اهمیت دارد، جایی که زنجیره فیلتر و predictor دو نیمه یک عملیات هستند، همان‌طور که در استخراج تصاویر از اسناد بارگذاری‌شده از طریق فیلترهای decode آن‌ها پوشش داده شده

وقتی بودجه امتناع می‌کند، فراخواننده چه چیزی می‌بیند؟

در پایین پشته، یک امتناع EHPDFDecodeBudgetError بلند می‌کند. بالای آن، پاسخ به قراردادی بستگی دارد که API فراخواننده از قبل داشت. متدهای خواندن سطح‌بالا که شکست را از طریق False یا nil گزارش می‌کردند دقیقاً همان کار را ادامه می‌دهند، چون تبدیل یک نتیجه boolean مستند به یک exception فراخوانندگانی را می‌شکند که از قبل درست با ورودی ناقص برخورد می‌کردند. مسیر محتوای صفحه بارگذاری‌شده استثنای عمدی است: به‌جای اجازه دادن به یک content stream بریده‌شده که به‌عنوان صفحه‌ای که صرفاً خالی درآمده رندر شود، EHPDFDecodeBudgetError را دوباره بلند می‌کند. آن طراحی یعنی یک False ساده به‌تنهایی مبهم است، پس بودجه یک رکورد تشخیص در کنار آن منتشر می‌کند: THotPDF.GetLastDecodeBudgetInfo وضعیت اخیرترین زنجیره‌ای که instance decode کرده را برمی‌گرداند

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

آن فیلدها را با هم بخوانید و دو شکل حمله را از هم جدا می‌کنند. وقتی PeakStageBytes نزدیک به DecodedBytes است، یک مرحله همه آسیب را انجام داده و شما به یک فیلتر با نسبت بالا نگاه می‌کنید. وقتی PeakStageBytes کسر کوچکی از DecodedBytes است و FilterCount بالاست، هیچ مرحله مجزایی افراطی نبود و زنجیره راه خود را تا فراتر از سقف انباشت کرد، که دقیقاً همان موردی است که یک محدودیت به‌ازای هر فیلتر نمی‌تواند ببیند. یک هشدار که ارزش نوشتن در handler شما را دارد: GetLastDecodeBudgetInfo تا زمانی که instance دست‌کم یک فیلتر decode نکرده باشد False برمی‌گرداند، پس یک False از آن مدرکی بر پاک بودن سند نیست

کجا بودجه ریست می‌شود و کِی صفر پاسخ صادقانه است

DecodeBudgetBytes یک زنجیره stream را محدود می‌کند، نه یک سند، و آن مرز عمدی است اما راحت اشتباه خوانده می‌شود. هر content stream، هر فایل embed شده، هر cross-reference stream و هر object stream با ۲۵۶ MiB تازه شروع می‌شود. یک سند ۴۰۰۰صفحه‌ای بنابراین ۴۰۰۰ فرصت مستقل برای خرج کردن کل سقف دارد، و object streamها تعداد را بیشتر ضرب می‌کنند چون هر کدام خودش یک container فشرده است که اشیاء زیادی نگه می‌دارد، همان‌طور که در یادداشت‌های object streamها و به‌روزرسانی‌های افزایشی شرح داده شده. اگر نیازمندی واقعی شما یک محدودیت روی کل حافظه process است، این ویژگی یک ورودی به آن است، نه کل آن، و باید پشت یک سقف سطح job یا سطح container قرار بگیرد

صفر یعنی نامحدود، و یک تنظیم مشروع است نه یک راه فرار. آن را وقتی خود ورودی را مالک هستید تنظیم کنید: یک pipeline بازپردازش آرشیو روی اسنادی که سیستم خودتان تولید کرده، یا یک گام رستریزاسیون که در آن یک زنجیره اسکن رنگی ۶۰۰ dpi واحد واقعاً به بیشتر از هر سقفی که با hard-code کردن راحت باشید نیاز دارد. مقادیر منفی از ابتدا با ERangeError رد می‌شوند، چون یک بودجه منفی هیچ معنای منسجمی ندارد و clamp کردن خاموش آن یک باگ پیکربندی را پنهان می‌کند

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

انتخاب عدد سزاوار مراقبت بیشتری است از آنچه معمولاً می‌گیرد، چون یک بودجه تنظیم‌شده خیلی پایین یک قطعی خودزده است. corpus موجود خود را با پیش‌فرض اجرا کنید، PeakStageBytes و DecodedBytes را برای هر زنجیره ثبت کنید، و سقف را بالاتر از بیشینه مشاهده‌شده با فضای واقعی تنظیم کنید. یک عدد رند که چون امن به‌نظر می‌رسید انتخاب شده یک اسکن بزرگ مشروع را در بدترین لحظه ممکن رد می‌کند، و شکست دقیقاً شبیه یک حمله در log های شما به‌نظر می‌رسد

کپی‌ای که دیگر رخ نمی‌دهد

مسیردهی هر مرحله از طریق یک wrapper بودجه معلوم شد زنجیره را ارزان‌تر می‌کند نه گران‌تر. وقتی یک stream فیلتر دارد، مرحله اول اکنون مستقیماً stream منبع را می‌خواند به‌جای کپی کردن بایت‌های کدگذاری‌شده در یک بافر scratch اول، و از آنجا فقط دو بافر همزمان زنده‌اند: ورودی فعلی و خروجی مرحله‌ای که نوشته می‌شود. کپی خام در دو حالتی که به آن نیاز دارند جان سالم به در می‌برد، یعنی یک stream بدون هیچ فیلتری اصلاً و یک تصویر که فراخواننده می‌خواهد آخرین کدگذاری حفظ شود، چون هر دو یک stream برمی‌گردانند که فراخواننده مالک آن است و می‌تواند مستقلاً seek کند. نسخه بی‌محافظ این کد بیشتر تخصیص می‌داد و کمتر محدود می‌کرد، که رابطه معمول بین آن دو است. اما ارزش گفتن صریح دارد: هیچ‌کدام از این‌ها یک PDF دلبخواه را برای بارگذاری امن نمی‌کند. این یک بردار denial-of-service خاص و بسیار ارزان را می‌بندد، همانی که در آن یک فایل کوچک یک تخصیص بزرگ را از طریق فیلترهای تودرتو می‌خرد. سرریز عدد صحیح در حسابداری بایت جداگانه محافظت می‌شود، و سؤال گسترده‌تر parse کردن اسناد خصمانه بدون اعتماد به offsetهای داخلی‌شان یک انضباط متفاوت است. بودجه decode یک محدودیت در میان چندین محدودیت است، و ارزش آن این است که همان چیزی است که می‌توانید از یک ویژگی واحد پیش از دست‌زدن به فایل تنظیم کنید

بودجه به‌ازای هر زنجیره، رکورد تشخیص آن، و مسیرهای decode سند بارگذاری‌شده‌ای که محافظت می‌کند همگی به‌عنوان بخشی از خود کامپوننت عرضه می‌شوند، بدون هیچ وابستگی decompression خارجی برای پیکربندی یا وصله کردن. اگر دارید ارزیابی می‌کنید چگونه ورودی PDF غیرقابل‌اعتماد را داخل یک سرویس Delphi یا C++Builder محدود کنید، صفحه کامپوننت HotPDF Delphi PDF جعبه‌ابزار سند بارگذاری‌شده‌ای را فهرست می‌کند که این محدودیت‌ها روی آن اعمال می‌شوند