مقال تقني

تحليل قواميس PDF بأمان في Delphi: رموز الأسماء

عمليات البحث بالسلاسل الفرعية مثل Pos('/Length', Dict) هي الأداة الخاطئة لقراءة قاموس PDF، لأن مفاتيح الأسماء في PDF تتشارك البادئات: فـ /Length بادئة لـ /Length1، و/Encrypt بادئة لـ /EncryptMetadata. ويعرّف البند §7.3.5 من ISO 32000-1 الاسم بأنه رمز (token) لا ينتهي إلا عند محدِّد أو مسافة بيضاء، ولذلك لا يُعد المفتاح موجودًا إلا إذا كان البايت الذي يليه أحد تلك المحارف. وأي قارئ قواميس يتخطى هذا الفحص الوحيد سيقرأ في النهاية القيمة الخاطئة من ملف صالح تمامًا

الخلل الذي علّمنا هذا الدرس لم يكن يشبه مشكلة في المحلل المعجمي على الإطلاق. فقد بدأ فحص مطابقة يبلّغ عن تلف دفق FontFile: كان برنامج الخط بعد فك الضغط مجرد جزء مبتور في منتصف أحد الجداول. وكان الملف يُفتح دون مشكلة في كل عارض. وكانت بيانات الدفق على القرص سليمة. أما السبب الجذري فكان في سطر واحد من قارئ القواميس المشترك لدينا: فقد طابق Pos('/Length', ...) المفتاح /Length1، وهو مفتاح قياسي تحمله قواميس دفوق FontFile وفق الجدول 127 من ISO 32000-1، فأخذ القارئ العدد الصحيح الذي يلي /Length1 على أنه طول الدفق. وصادف أن كاتب ذلك الملف بعينه يسلسل /Length1 قبل /Length، وهو حر تمامًا في ذلك، لأن مدخلات القاموس غير مرتبة وفق البند §7.3.7. فقُطع الدفق عند عدد بايتات عشوائي، وأصبح كل فحص لاحق يستهلكه أعمى في صمت

لماذا تكسر مطابقة السلاسل الفرعية تحليل قواميس PDF؟

تنكسر مطابقة السلاسل الفرعية لأن فضاء الأسماء في PDF مليء بعائلات بادئات متعمدة، ولأن ترتيب مدخلات القاموس غير محدد. فالجدول 127 من ISO 32000-1 يعرّف /Length1 و/Length2 و/Length3 لدفوق الخطوط المضمّنة، وكلها تجاور /Length في القاموس نفسه. ويقرن قاموس التشفير /Encrypt في المقطورة (trailer) بـ /EncryptMetadata داخله. والمفاتيح القصيرة أسوأ: فبحث مبني على شكل Pos('/' + Key, ...) مع Key = 'N' يحط بكل سرور على /Name أو /Nums. ولا يتطلب أي من هذه التصادمات ملفًا مشوّهًا. فالكاتب الذي يرتّب /Length1 قبل /Length مطابق للمعيار بالكامل، ما يعني أن خلل السلاسل الفرعية ليس فجوة متانة أمام مدخلات معطوبة — بل فجوة صحة أمام مدخلات صالحة

قارئ قواميس PDF في Delphi يبحث بـ Pos('/Length') فيحط داخل /Length1 في قاموس دفق خط صالح ويقتطع الدفق، بينما يعيد فحص الرمز الكامل للبايت الذي يلي المفتاح قيمة /Length الحقيقية
قد يسلسل كاتب مطابق للمعيار /Length1 قبل /Length، فتقتطع أول إصابة للسلسلة الفرعية دفق الخط بينما يعيد فحص البايت التالي القيمة الحقيقية

ونمط الفشل من النوع الصامت أيضًا. فقيمة /Length الخاطئة لا تثير استثناءً؛ بل تسلّمك شريحة بايتات أقصر أو أطول مما يشغله الدفق فعلًا. وإذا غذّت تلك الشريحة فحصًا لمجموعة خط فرعية أو تحليلًا لـ CMap أو مسحًا لبيانات وصفية، فإن المستهلك يرى بيانات مهملة ولا يبلّغ عن شيء عادةً، لأن نصف دفق zlib يفشل ببساطة في فك الضغط فيمضي الكود قدمًا. لقد شحنّا هذه الفئة من العيوب بالضبط وأصلحناها في الإصدار v2.14.3 من قارئنا المشترك، بعد أن أشار تدقيق بندًا بندًا للبندين §7.2–§7.3 من ISO 32000-1 إلى كل بحث عن مفتاح بأسلوب Pos باعتباره مشبوهًا

ما الذي يعرّفه البند §7.3.5 من ISO 32000-1 فعلًا على أنه اسم

البند 7.3.5 قصير ودقيق: كائن الاسم هو شرطة مائلة (solidus) يتبعها تسلسل من المحارف العادية، وينتهي الرمز عند أول محرف محدِّد أو مسافة بيضاء. والمحدِّدات هي محارف الأقواس الثمانية إضافةً إلى الشرطة المائلة وعلامة النسبة المئوية — ( ) < > [ ] { } / % — والمسافة البيضاء هي المحرف الفارغ وعلامة الجدولة وتغذية السطر وتغذية الصفحة والإرجاع إلى بداية السطر والمسافة (§7.2.2–§7.2.3). وقاعدة الإنهاء هذه هي القصة كلها. فـ /Length1 ليس "/Length متبوعًا بـ 1"؛ بل هو رمز واحد لا يتجزأ، تمامًا كما أن LengthOne وLength معرّفان مختلفان في Pascal. وأي قارئ يعثر على المفاتيح بالبحث الخام في البايتات إنما يعيد تنفيذ المحلل المعجمي بعد حذف قاعدة الإنهاء

إليك شكل العيب مختزلًا إلى جوهره. هذه النسخة تُترجم، وتجتاز الاختبارات على ملفات يرتّب كاتبوها /Length أولًا، وتفسد الدفوق لدى الكاتبين الذين لا يفعلون

// خطأ: يطابق /Length1 و/Length2 و/Length3 أيضًا
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

مطابقة الرمز كاملًا: افحص البايت الذي يلي المفتاح

المسند الصحيح ينبع مباشرة من البند §7.3.5: التطابق المرشح مفتاح حقيقي فقط إذا كان المحرف الذي يليه مباشرة محدِّدًا أو مسافة بيضاء أو نهاية المخزن المؤقت. وكل ما عدا ذلك اسم أطول يتشارك البادئة فحسب، ولذلك يجب أن يتابع البحث بعده بدلًا من الاستسلام. وقد استبدل الإصلاح في قارئنا كل بحث خام بـ Pos بروتين مشترك واحد مبني على هذه القاعدة

مطابقة قاموس بالرمز الكامل في Delphi وفق البند 7.3.5 من ISO 32000-1: لا يُقبل مرشح Pos إلا عندما يلي المفتاح محدِّد أو مسافة بيضاء أو نهاية المخزن المؤقت، وإلا يواصل PosEx البحث بعد تصادم البادئة
لا تقبل الحلقة المرشح إلا عندما يلي المفتاح محرف إنهاء وتواصل البحث بعد تصادمات البادئات بدلًا من الفشل عند أول إخفاق
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// صحيح: مطابقة الرمز كاملًا وفق البند §7.3.5 من ISO 32000-1
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // ينتهي الرمز هنا: مفتاح حقيقي
    P := PosEx(Key, Dict, P + 1);    // بادئة لاسم أطول: تابع البحث
  end;
end;

تفصيلان في تلك الحلقة لهما وزنهما. أولًا، إنها تواصل البحث بدلًا من إرجاع الفشل عند أول تصادم بادئة، لأن /Length1 120 /Length 4076 ترتيب مشروع والمفتاح الحقيقي لا يزال أمامها. ثانيًا، تُعد حالة نهاية المخزن المؤقت محرف إنهاء، لأن جزءًا من قاموس قد ينتهي بشكل مشروع مباشرة بعد اسم. ونقطة أدق تستحق التدقيق في كودك أنت: تنطبق القاعدة نفسها على الجانب الأيسر من التطابق إذا كانت سلسلة البحث لا تتضمن الشرطة المائلة، وإلا فقد يحط Pos('Length', ...) داخل /PieceLength. وتثبيت سلسلة البحث بـ / البادئة، كما في الأعلى، يعالج الحافة اليسرى لأن / نفسها محدِّد ينهي الرمز السابق

كيف يحوّل ملف PDF معادٍ خللًا في المحلل إلى تخصيص بحجم جيجابايت؟

يصعّد الملف المشوّه أو الخبيث هذه الأخطاء المعجمية إلى استنزاف للموارد، لأن الأعداد الصحيحة في القواميس تغذّي أحجام التخصيص في كثير من الأحيان. وقد وجد تدقيقنا سلسلة بهذا الشكل بالضبط في توسيع دفوق الكائنات. فالمدخل /N في قاموس ObjStm يحدد عدد الكائنات المضغوطة التي يحملها الدفق، وكان كود التوسيع يستدعي SetLength على مصفوفة بحجم مأخوذ منه. غير أن محلل الأعداد الصحيحة كان يترك معامل الخرج دون مساس عند الفشل مع إرجاعه رغم ذلك — فكانت قيمة /N غير الرقمية تسلّم SetLength قيمة مكدس غير مهيأة. وعدد صحيح موجب مهمل هناك يعني طلب تخصيص بحجم جيجابايتات، تطلقه بضعة بايتات من مدخلات تالفة، بينما أنت تمسح مجرد مسح مستندًا لم توافق بعد على الوثوق به

كان للإصلاح جزءان مستقلان، وكلاهما قابل للتعميم. فالمحلل يعيد الآن صفرًا صريحًا عند الفشل، ولا يعيد ذاكرة غير مهيأة أبدًا. ولم يعد المستهلك يثق بـ /N دون حساب: فمنطقة رأس ObjStm قبل /First تخزّن زوجًا من الأعداد الصحيحة — رقم الكائن والإزاحة — لكل كائن مضغوط، ويشغل كل زوج أربعة بايتات على الأقل بما فيها الفواصل. ولذلك فإن أي قيمة /N تتجاوز FirstVal div 4 + 1 مستحيلة فيزيائيًا لحجم الرأس المعلن، فتُرفض قبل حدوث أي تخصيص. ويكلّف هذا الحد مقارنة واحدة ويُشتق من بيانات في المتناول أصلًا، وهذا هو النمط الذي ينبغي البحث عنه: سقف يثبته الملف نفسه، لا ثابت اعتباطي

تخصيصات دفوق PDFium في Delphi محدودة قبل الالتزام بأي ذاكرة: عدد الكائنات /N في ObjStm يُفحص مقابل سقف مشتق من الرأس، و/Length يُفحص مقابل حجم الملف، وخرج فك الضغط مقيد بـ 256 MiB ضد قنابل zlib
يُفحص كل حجم معلن مقابل سقف يثبته الملف نفسه قبل تخصيص بايت واحد
// قيمة /N يتحكم فيها المهاجم؛ قيّدها بما يمكن أن يستوعبه /First
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // صفر صريح، لا بيانات مكدس مهملة أبدًا
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // لا يمكن للرأس أن يحوي هذا العدد من الأزواج

// لا يمكن لـ /Length أن يتجاوز الملف الذي يحوي الدفق
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // ارفض قبل تخصيص المخزن المؤقت

سقفان آخران يكملان المحيط الدفاعي في قارئنا، وكلاهما شُحن في الإصدار v2.12.0. فقارئ الدفوق يرفض أي /Length أكبر من الملف كله قبل تخصيص المخزن المؤقت للنتيجة — فالدفق لا يمكن أن يكون أكبر من الحاوية التي يعيش فيها، ولذلك يخلو الفحص من الإيجابيات الكاذبة. ويحدّ مسار فك الضغط خرج البيانات المفكوكة عند 256 MiB، ما يوقف قنبلة zlib الكلاسيكية التي تتمدد فيها بضعة كيلوبايتات من المدخلات بلا حدود؛ والسقف سخي لأي دفق PDF حقيقي مع إبقاء أسوأ الحالات قابلة للنجاة. والموضوع المشترك بين الثلاثة واحد: كل حجم يعلنه الملف ادعاء، والمحلل يتحقق من كل ادعاء مقابل شيء يمكنه قياسه قبل الالتزام بذاكرة له. وينطبق موقف التدقيق نفسه طبقة أدنى عند حدود الربط، وهو ما تغطيه مقالة تقوية واجهة ABI لـ PDFium وسلامة الذاكرة في Delphi

أين لا تكفي قاعدة الرمز الكامل

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

إذا كنت تصون قارئ قواميس مكتوبًا يدويًا في Delphi أو Lazarus، فقائمة التحقق المستخلصة من هذه الحادثة قصيرة. ابحث بـ grep عن كل Pos('/ في قاعدة الكود ووجّه النتائج عبر مساعد واحد للرمز الكامل. واحصر عائلات البادئات التي تشارك فيها مفاتيحك — فـ /Length و/Encrypt و/N و/Type مقابل /Type1 تظهر كلها في ملفات حقيقية. ثم تتبّع كل عدد صحيح يصل إلى SetLength أو GetMem أو حلقة نسخ واسأل ما الذي يقيّده: حجم الملف، أم سقف مشتق من الرأس، أم لا شيء. وطبقة التحليل الموصوفة هنا هي الأساس الذي يقوم عليه PDFium Component لدينا، حيث يتحقق القارئ على مستوى البايتات ومحرك التصيير كل منهما من الآخر في كل مستند يلمسانه