مقال تقني

قنابل فك ترميز PDF في Delphi: ميزانيات سلسلة المرشحات في HotPDF

ملف PDF بحجم 20 كيلوبايت يُجمّد عملية خدمة حتى يقضي عليها مُنهي نفاد الذاكرة (OOM killer) ليس خللًا في كودك، إنه قنبلة فك ضغط. يُحدّد HotPDF، مكوّن VCL الأصلي لـ PDF في Delphi وC++Builder، واحدة منها بـ DecodeBudgetBytes، سقف لكل سلسلة مرشّحات يبلغ افتراضيًا 268435456 بايت ويُحاسب كل مرحلة فك ترميز مقابل ميزانية واحدة مشتركة

ملف الـ 20 كيلوبايت الذي التهم عملية عامل

شكل الحادثة هو نفسه دائمًا. عامل طابور يُصغّر الصور المصغّرة يلتقط رفعًا، وتتسلق الذاكرة المُقيمة فوق 12 غيغابايت في أقل من ثانيتين، وتختفي العملية دون تتبع مكدس. الملف 20 كيلوبايت. له صفحة واحدة، ودفق محتوى واحد، ومصفوفة /Filter بخمسة مدخلات. كل اسم في تلك المصفوفة مرشّح تُعرّفه المواصفة، وكل مرحلة تُفكّ ترميزها دون خطأ، ولا شيء في الملف مشوَّه. هذا ما يجعل هذه الفئة من المدخلات مُحرجة: لا يوجد بايت تالف لرفضه

هذه ليست نفس مشكلة فك ترميز مرشّح واحد بشكل صحيح. ضبط LZWDecode ومُتنبِّئ /DecodeParms بشكل صحيح موضوع خاص به، مشروح في شرح LZW، والمتنبئات، وDecodeParms على المستندات المُحمَّلة. هنا كل مُفكِّك ترميز صحيح بالفعل. الفشل هو ما يفعله مُفكِّكو الترميز الصحيحون عندما تُشغّل خمسة منهم تباعًا ولا أحد يعدّ الإجمالي. الفقرة §7.4 من ISO 32000-1 صريحة بأن /Filter يمكن أن يكون اسمًا واحدًا أو مصفوفة أسماء، وأن المصفوفة تُطبَّق بالتسلسل، المدخل الأول أولًا. لا تقول شيئًا عن مقدار ما يمكن أن توسّعه مرحلة من مدخلها، ولا شيئًا عن الإجمالي عبر السلسلة. مرحلة ASCIIHexDecode تُقلّل مدخلها إلى النصف تقريبًا، وهذا يبدو غير ضار. مرحلة FlateDecode على سلسلة من بايتات صفرية تصل إلى نسب في الآلاف. اربطهما وتصبح الحسابات ضربية: 20 كيلوبايت تصبح 20 ميجابايت تصبح 20 غيغابايت، وكل خطوة فردية فك ترميز مطابق لدفق قانوني

لماذا يفشل حد لكل مرشّح في إيقاف قنبلة فك ترميز؟

لأن الحد لكل مرشّح يُعاد تسليحه عند كل عنصر من مصفوفة /Filter. سلسلة من خمس مراحل تحت سقف 256 ميبيبايت لكل مرحلة تُصرِّح بـ 1.25 غيبيبايت، والمرحلة الأخيرة لا تزال تبدأ بمخصص جديد تمامًا بصرف النظر عمّا أنتجته الأربع قبلها. الحد يُطبَّق بأمانة ولا يُقيّد شيئًا مهمًا. كان لدى HotPDF بالضبط هذا الشكل قبل الإصدار v2.447.0، وكانت لديه ثغرة ثانية إلى جانبها. حمل مُفكِّك ضغط LZW سقف MaxOutputBytes ومسار مُتنبِّئ الصور احتسب صفوفه الخاصة، فكان هذان الاثنان مقيَّدين محليًا. أما FlateDecode وASCIIHexDecode وASCII85Decode وRunLengthDecode فلم يكن لها سقف إطلاقًا: كل منها كتب إلى TMemoryStream حتى نفد المدخل أو استسلم المُخصِّص. لذا كانت للسلسلة العدائية طريقان: يمكنها استخدام مرشّح غير محمي تمامًا، أو استخدام مرشّحات محمية وإضافة المزيد منها فقط

هناك تفصيلة ثالثة يُفوّتها الإصلاح الساذج. الرقم الذي يهمك ليس حجم الإخراج المفكوك الترميز النهائي. إنه الذروة، والذروة عادةً ما تعيش في مخزن مؤقت وسيط. سلسلة تنتهي بدفق محتوى متواضع بحجم 4 ميجابايت يمكن أن تُخصّص 8 غيغابايت في المرحلة الثالثة وتُعيد شيئًا يبدو معقولًا تمامًا. فحص طول النتيجة لاحقًا لا يخبرك شيئًا عن التخصيص الذي قتل العملية

متتبِّع ميزانية واحد لكل سلسلة مرشّحات

الإصلاح في HotPDF v2.447.0 هو جعل المحاسبة تمتد عبر السلسلة بدلًا من المرحلة. كل سلسلة مرشّحات تُنشئ THPDFDecodeBudgetTracker واحدًا، وكل مُفكِّك ترميز يكتب عبر THPDFBudgetWriteStream يُغلّف الهدف الحقيقي. المُغلِّف يستدعي Budget.Consume(Count) قبل تمرير بايت واحد، بحيث يحدث الرفض بينما دفق الهدف لا يزال بحجمه القديم. هذا الترتيب هو بيت القصيد بالكامل: فحص يُنفَّذ بعد أن نما المخزن المؤقت بالفعل تشخيص، لا دفاع

// 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، بحيث يكون سقفها الخاص هو ما تبقى للسلسلة بدلًا من مخصص مستقل. مرحلة مُتنبِّئ الصورة تبدأ بـ BeginFilter وتُحاسب متطلب صفوفها عبر Consume قبل التخصيص، مما يعني أن إخراج المُتنبِّئ يُحاسَب على نفس الميزانية التي تُحاسَب عليها المرشّحات العامة التي غذّته. هذا مهم بشكل خاص في مسار الصور، حيث تكون سلسلة المرشّحات والمُتنبِّئ نصفي عملية واحدة، كما هو مشروح في استخراج الصور من المستندات المُحمَّلة عبر مرشّحات فك ترميزها

ماذا يرى المستدعي عندما ترفض الميزانية؟

في أسفل المكدس، الرفض يُثير EHPDFDecodeBudgetError. فوق ذلك، تعتمد الإجابة على العقد الذي كانت تحمله الواجهة البرمجية المستدعية بالفعل. طرق القراءة عالية المستوى التي أبلغت عن الفشل عبر False أو nil تستمر في فعل ذلك بالضبط، لأن تحويل نتيجة منطقية موثَّقة إلى استثناء سيُعطّل المستدعين الذين كانوا يتعاملون بالفعل بشكل صحيح مع مدخلات مشوَّهة. مسار محتوى الصفحة المُحمَّلة هو الاستثناء المتعمَّد: يُعيد إثارة EHPDFDecodeBudgetError بدلًا من السماح لدفق محتوى مقطوع بأن يُعرض كصفحة خرجت فارغة فحسب. هذا التصميم يعني أن False مجردة غامضة بحد ذاتها، لذا تنشر الميزانية سجل تشخيص إلى جانبها: تُعيد THotPDF.GetLastDecodeBudgetInfo حالة أحدث سلسلة فكّت النسخة ترميزها

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 مرتفعًا، لم تكن أي مرحلة فردية فاحشة وتراكمت السلسلة طريقها متجاوزةً السقف، وهذه بالضبط الحالة التي لا يستطيع حد لكل مرشّح رؤيتها. تحذير واحد يستحق كتابته في مُعالِجك: تُعيد GetLastDecodeBudgetInfo القيمة False حتى تُفكّ النسخة ترميز مرشّح واحد على الأقل، لذا فإن False منها ليست دليلًا على أن المستند كان نظيفًا

أين تُعاد ضبط الميزانية، ومتى يكون الصفر هو الإجابة الصادقة

DecodeBudgetBytes يُحدّد سلسلة دفق واحدة، لا مستندًا واحدًا، وذلك الحد متعمَّد لكن يسهل إساءة قراءته. كل دفق محتوى، وكل ملف مضمّن، وكل دفق مراجع متقاطعة، وكل دفق كائنات يبدأ بـ 256 ميبيبايت جديدة. مستند من 4000 صفحة إذن لديه 4000 فرصة مستقلة لإنفاق السقف الكامل، ودفقات الكائنات تُضاعف العدد أكثر لأن كل واحد منها بحد ذاته حاوية مضغوطة تحمل كائنات عديدة، كما هو موصوف في ملاحظات دفقات الكائنات والتحديثات التزايدية. إذا كان متطلبك الحقيقي هو حد على إجمالي ذاكرة العملية، فهذه الخاصية مدخل واحد لذلك، لا كله، ويجب أن تقع خلف سقف مستوى مهمة أو مستوى حاوية

الصفر يعني بلا حدود، وهو إعداد شرعي لا منفذ هروب. عيّنه عندما تملك المدخل: خط أنابيب إعادة معالجة أرشيف على مستندات أنتجها نظامك بنفسك، أو خطوة رسترة حيث تحتاج سلسلة مسح ملون واحدة بدقة 600 نقطة في البوصة أكثر مما قد تشعر بالراحة تجاه ترميزه ثابتًا. القيم السالبة تُرفض مقدمًا بـ ERangeError، لأن ميزانية سالبة ليس لها معنى متماسك وتقييدها بصمت سيُخفي خطأ تهيئة

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

اختيار الرقم يستحق عناية أكبر مما يحظى به عادةً، لأن ميزانية مُعيَّنة منخفضة جدًا انقطاع ذاتي. شغّل مجموعتك الحالية بالإعداد الافتراضي، سجّل PeakStageBytes وDecodedBytes لكل سلسلة، وعيّن السقف فوق الحد الأقصى المُلاحَظ بهامش حقيقي. رقم مُستدير اختير لأنه بدا آمنًا سيرفض مسحًا ضوئيًا كبيرًا شرعيًا في أسوأ لحظة ممكنة، وسيبدو الفشل تمامًا كهجوم في سجلاتك

النسخ الذي لم يعد يحدث

تمرير كل مرحلة عبر مُغلِّف ميزانية جعل السلسلة في النهاية أرخص لا أغلى. عندما يحمل دفق مرشّحات، تقرأ المرحلة الأولى الآن دفق المصدر مباشرةً بدلًا من نسخ البايتات المُرمَّزة إلى مخزن مؤقت مسودة أولًا، ومن هناك لا يبقى حيًا سوى مخزنين مؤقتين: المدخل الحالي وإخراج المرحلة قيد الكتابة. النسخ الخام يبقى في الحالتين اللتين تحتاجانه، وهما دفق بلا مرشّحات إطلاقًا وصورة يريد فيها المستدعي الحفاظ على الترميز الأخير، لأن كلتيهما تُعيدان دفقًا يملكه المستدعي ويمكنه البحث فيه بشكل مستقل. النسخة غير المحمية من هذا الكود خصّصت أكثر وحدّت أقل، وهذه هي العلاقة المعتادة بين الاثنين. يستحق ذكره بوضوح رغم ذلك: لا شيء من هذا يجعل أي ملف PDF عشوائي آمنًا للتحميل. إنه يُغلق ناقل حرمان خدمة واحدًا محددًا ورخيصًا جدًا، ذلك الذي يشتري فيه ملف صغير تخصيصًا كبيرًا عبر مرشّحات متداخلة. فيض العدد الصحيح في محاسبة البايتات مُحمي بشكل منفصل، والسؤال الأوسع حول تحليل مستندات عدائية دون الثقة بإزاحاتها الداخلية انضباط مختلف. ميزانية فك الترميز حد واحد بين عدة حدود، وقيمتها أنها الحد الذي يمكنك تعيينه من خاصية واحدة قبل أن تلمس الملف

ميزانية كل سلسلة، وسجل تشخيصها، ومسارات فك ترميز المستند المُحمَّل التي تحميها كلها تُشحن كجزء من المكوّن نفسه، دون اعتمادية فك ضغط خارجية تحتاج إلى تهيئتها أو ترقيعها. إذا كنت تُقيّم كيفية تحديد مدخل PDF غير موثوق داخل خدمة Delphi أو C++Builder، تُدرِج صفحة مكوّن HotPDF لـ Delphi مجموعة أدوات المستند المُحمَّل التي تُطبَّق عليها هذه الحدود