مقال تقني

تقوية محلل Pascal PDF ضد الملفات الضارة

إن ملف PDF ليس مستنداً تفتحه. بل هو برنامج صغير تقوم بتشغيله. كل خط مضمن هو مفسر قائم على المكدس (stack-based) ينتظر سلاسل الأحرف (charstrings)، وكل صورة هي جهاز فك تشفير تتم تغذيته بحقول العرض والارتفاع وعمق البت التي اختارها الملف، وكل تيار يصل مغلفاً بمرشحات عين الملف معلماتها. لا شيء من هذه الأرقام لك. لقد جاءت من أي شخص أنتج الملف، والذي يكون في عبء عمل حقيقي فاتورة عميل أو مرفقاً من مرسل غير معروف. إن أجهزة فك التشفير التي تحول هذه البايتات إلى بكسلات ورموز (glyphs) هي سطح الهجوم، والمحلل الذي يثق في إدخاله هناك يكون على بُعد ملف مشوه واحد من الانهيار أو ما هو أسوأ

مر PDFlibPas بمرحلة تقوية عاملت مسار فك التشفير بالكامل على أنه عدائي، عبر برامج الخطوط (TrueType، و Type1، و CFF، وجداول CMap)، وأجهزة فك تشفير الصور (PNG، و GIF، و TIFF، و JBIG2، و CCITT Group 3 و Group 4)، ومرشحات التيار (LZW، و ASCII85، ومتنبئات Flate). ما يلي خمس فئات من العيوب أغلقها، كل منها متجذر في سلوك Delphi المحدد الذي جعلها ممكنة. تم إصلاحها في الإصدارات الحالية، وتتكرر الأشكال نفسها في أي كود Pascal يحلل إدخالاً غير موثوق به

تجاوز سعة العدد الصحيح يمنحك ذاكرة مؤقتة صغيرة الحجم

الخطأ الكلاسيكي في سلامة الذاكرة في جهاز فك تشفير الصور هو ناتج أبعاد يلتف (wraps). يقرأ جهاز فك التشفير العرض، والارتفاع، وعدد المكونات، وعمق البت، ويضربها لضبط حجم مخرجاته، ويخصص هذا العدد من البايتات، ثم يكتب الصورة بأبعادها الحقيقية. إذا تم الضرب بحساب 32 بت، فيمكن أن يلتف الناتج إلى قيمة صغيرة حتى عندما يكون كل عامل فردي ضمن نطاق معقول، لذلك ينجح التخصيص، ويخرج صغيراً جداً، ويخرج فك التشفير عن نهايته. هذا هو CWE-190، تجاوز سعة العدد الصحيح (integer overflow)، مما يؤدي إلى كتابة خارج حدود الكومة (heap out-of-bounds write) (CWE-787) في الخطوة التالية

قام مسار الصورة المشترك بالفعل بتقييد كل بُعد إلى 65535؛ لم ترث أجهزة فك التشفير المستقلة جميعها هذا القيد. تعبير بايتات الصف مضروباً في الارتفاع مثل ByteCount * FHeight، أو تعبير لكل بكسل مثل FWidth * Components * BitDepth، هو منتج 32 بت في Delphi عندما يكون كلا المعاملين أعداداً صحيحة 32 بت، بغض النظر عن مدى اتساع المتغير الذي تعين النتيجة له. العرض والارتفاع البالغان 60000 معقولان لمسح ضوئي كبير، ولكن منتجهما بالبايت يتجاوز نطاق 32 بت الموقع ويخرج الطول صغيراً. الفخ نفسه كان موجوداً في خطوة متنبئ ZLib، BitsPerComponent * Colors * Columns

الإصلاح هو جعل معامل واحد على الأقل Int64 بحيث يتم تقييم التعبير بأكمله في 64 بت، ثم المقارنة مع MaxInt ورفض الملف قبل التضييق مرة أخرى لاستدعاء SetLength

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

ما يجعل هذه مشكلة Delphi بدلاً من مشكلة عامة هو التضييق الصامت. إن تعيين تعبير واسع جداً في وجهة 32 بت هو تحويل قانوني لن يحذر منه المترجم بشكل افتراضي، ولا يلتقط التحقق من النطاق (range checking) التفافاً يحدث قبل استخدام القيمة كمؤشر أبداً. اترك الناتج عند 32 بت وتمنحك اللغة بهدوء طولاً يكذب بشأن مقدار الذاكرة الذي يوشك فك التشفير على لمسه

نوع حقل يجعل من المستحيل تشغيل الحارس (guard)

ملف TIFF عبارة عن سلسلة من أدلة ملفات الصور، يحمل كل منها إزاحة البايت (byte offset) للدليل التالي. يمكن لملف ضار أن يوجه هذه السلسلة إلى نفسها، والقارئ الذي يمشي خلالها دون شرط توقف يعمل إلى الأبد. هذا هو CWE-835، حلقة لا نهائية مدفوعة بإدخال يتحكم فيه المهاجم، والدفاع عبارة عن عداد يتوقف بمجرد تجاوزه حداً لن يصله أي ملف شرعي

تم الإعلان عن عداد الصفحة كـ Word، والذي يحمل في Delphi القيم من 0 إلى 65535. حملت الحلقة حارس إنهاء (termination guard) بالصيغة "توقف عندما يتجاوز عدد الصفحات 65535"، والذي يُقرأ على أنه صحيح حتى تلاحظ أن المعامل والحد الأدنى يشتركان في حد أعلى. لا يمكن لـ Word أبداً أن يكون أكبر من 65535، لذلك تكون المقارنة خاطئة هيكلياً دائماً: عندما يصل العداد إلى 65535، تعيده الزيادة التالية إلى 0، ولا يرى الحارس أبداً قيمة أعلى من السقف، وتبقي سلسلة IFD الحلقية القارئ يدور

كان الإصلاح هو توسيع الحقل بحيث يتمكن الحارس من التعبير عن قيمة يمكن للعداد الاحتفاظ بها فعلياً. مع الإعلان عن TPDFTIFF.FPageCount كـ Integer، تصبح مقارنة FPageCount > 65535 نفسها قابلة للوصول، وتنتهي الحلقة، ويتغير نوع خاصية PageCount العامة ليتطابق دون كسر أي مستدعي. كلما كان للتحقق من الحدود شكل Value > MaxValueOfType(Value) وكان المعامل مكتوباً بالفعل بالحد الأقصى بالضبط، فإن الشرط يكون خاطئاً ثابتاً: قم بتوسيع النوع، أو اختبر المساواة مع الحد الأقصى حتى يتمكن من التشغيل

إيقاف التحقق من النطاق (range checking) على مسار ساخن (hot path)

مع تشغيل التحقق من النطاق (range checking)، تُدخل Delphi تحققاً من الحدود على كل مصفوفة ومؤشر سلسلة (string index)، وهو الفرق بين مؤشر خارج النطاق يرفع ERangeError يمكن التقاطه ونفس المؤشر الذي يقرأ أو يكتب ذاكرة لا تنتمي إلى الهيكل. تقوم المسارات الساخنة أحياناً بتعطيل ذلك باستخدام توجيه {$R-} محلي، وهو أمر يمكن الدفاع عنه حتى تتوقف المؤشرات عن كونها جديرة بالثقة

مُوصِل القائمة الذي تعتمد عليه مفسرات الخطوط، TPDFlibStringList.Get، هو بالضبط مثل هذا المسار. في Windows، يتم تجميعه مع إيقاف التحقق من النطاق ويؤشر متجره الداعم مباشرة، لذلك فإن المؤشر خارج النطاق ليس خطأ ولكنه وصول خام إلى الذاكرة. هذا جيد عندما يكون المؤشر صالحاً دائماً، ويتوقف عن كونه جيداً داخل مفسر CFF أو Type2 charstring، حيث يمكن أن يأتي المؤشر من الملف. ينتج عن charstring الذي يخرج (pops) معاملاً من مكدس فارغ مؤشراً بسالب واحد؛ ويؤشر معرّف الرمز (glyph) الذي يبتعد بواحد مقابل عدد الرموز إلى فتحة واحدة بعد النهاية. مع إيقاف التحقق من النطاق، يصبح كلاهما وصولاً حقيقياً خارج الحدود بدلاً من استثناء يمكن التقاطه، ولأن الفتحات تحمل قيم AnsiString محسوبة المراجع (reference-counted)، يمكن أن تفسد قراءة شاردة حساب المرجع للسلسلة

لم يقم التقوية بتبديل التحقق من النطاق مرة أخرى للمسار الساخن. بل جعلت المؤشرات صالحة يمكن إثباتها أولاً: قبل أخذ أعلى مكدس المعامل، يتحقق المفسر من أن المكدس غير فارغ، وكل حارس مؤشر تمت كتابته كأصغر من بدقة مقابل العدد بدلاً من أصغر من أو يساوي الذي يعترف بخطأ الإزاحة بواحد (off-by-one). ينقل التوجيه مسؤولية الحدود من المترجم إليك، ويجب إرجاع التحقق الذي تمت إزالته يدوياً في كل نقطة دخول

عودية غير محدودة في مفسر charstring

يمكن لـ Type2 charstring استدعاء روتين فرعي (subroutine)، والروتين الفرعي هو في حد ذاته charstring يمكنه استدعاء آخر، لذلك تسمح عوامل استدعاء الروتين الفرعي المحلية والعالمية للملف بتحديد مدى عمقه. الروتين الفرعي الذي يستدعي نفسه، بشكل مباشر أو من خلال دورة، يعيد نفسه (recurses) بلا نهاية حتى يُستنفد المكدس الأصلي (native stack) وتموت العملية. هذا هو CWE-674، عودية غير خاضعة للرقابة (uncontrolled recursion)

قام مفسر Type1 بالحماية من ذلك بالفعل. فقد حمل عداد عمق الاستدعاء (call-depth counter) وسقفاً، PLType1MaxCallDepth، ورفض النزول بعده، مما يعكس حد العمق الذي تسميه مواصفات Type1 نفسها. مفسر Type2، الذي تمت إضافته لاحقاً وهو مشابه هيكلياً، لم يحمل نفس الحارس، وخط مبني يدوياً يحتوي على روتين فرعي يستدعي رقمه الخاص يسير مباشرة عبر التحقق المفقود إلى تجاوز سعة المكدس (stack overflow)

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

كان الإصلاح هو إعطاء مسار Type2 نفس العمق المحدود الذي امتلكه شقيقه Type1 بالفعل. أي نزول متكرر (recursive descent) على هيكل يتحكم فيه المهاجم، سواء أكانت إجراءات فرعية للخط، أم مصفوفة متداخلة، أم سلسلة إسناد ترافقي (cross-reference)، يحتاج إلى سقف عمق لا يمكن للإدخال رفعه

ذاكرة غير مهيأة تتسرب إلى الإخراج

سرب الخلل الأكثر دقة محتويات الكومة إلى الإخراج الذي تم فك تشفيره، والسبب هو خاصية SetLength التي يسهل نسيانها. عندما تقوم بتكبير AnsiString باستخدام SetLength، تخصص Delphi البايتات ولكنها لا تجعلها أصفاراً، لذلك تحتفظ المنطقة الجديدة بكل ما كان موجوداً سابقاً في ذاكرة الكومة هذه. إذا تم كتابة كل بايت لاحقاً، فإن هذا لا يهم أبداً؛ إذا ترك المسار جزءاً من الذاكرة المؤقتة (buffer) دون كتابة ثم أعاده كبيانات، فإن تلك البايتات القديمة تخرج مع النتيجة. هذا هو CWE-457، استخدام ذاكرة غير مهيأة (uninitialized memory)، وعندما تعبر النتيجة حد الثقة (trust boundary) فإنها تصبح تسريباً للمعلومات

اصطدم مسار فك تشفير AES-CBC بهذا بالضبط. تم تحديد حجم الذاكرة المؤقتة للإخراج باستخدام SetLength وقام جهاز فك التشفير بمعالجة النص المشفر كتلة واحدة بحجم 16 بايت في كل مرة. عندما لم يكن طول النص المشفر من مضاعفات 16، وهو طول يمكن للمهاجم اختياره، لم تتم كتابة الكتلة الجزئية اللاحقة أبداً، لذلك احتفظت هذه البايتات النهائية بمحتويات الكومة التي تركها SetLength وراءه وتم تسليم الذاكرة المؤقتة كنص عادي تم فك تشفيره لكائن مستند. العلاج هو حارسان، ولا يكفي أحدهما بمفرده: ترفض نقطة دخول فك التشفير الآن أي نص مشفر لا يكون طوله من مضاعفات حجم الكتلة، وكعائق أخير، يتم مسح الإخراج باستخدام FillChar قبل الاستخدام بحيث يعيد أي مسار يفشل في كتابة منطقة ما أصفاراً بدلاً من بقايا الكومة

ما يتركه المسار لك

العيوب الخمسة هي أخطاء مختلفة، لكنها تتناغم. عرض عدد صحيح يلتف حول ناتج، ونوع حقل يثبت حارساً على خطأ ثابت، وفحص نطاق تم تعطيله حيث توقفت المؤشرات عن كونها آمنة، وعودية بلا أرضية، وذاكرة مؤقتة رفضت اللغة تصفيرها. في كل منها فعلت Delphi بالضبط ما تحدده، لأن اللغة تمنحك حساباً يلتف (wraps)، وتضييقاً صامتاً، وعمليات فحص نطاق يمكنك إيقاف تشغيلها، وعودية بلا حد مدمج، وتخصيصاً لا يهيئ (initialize). هذا هو العقد، ويفي محلل Pascal به من خلال امتلاك أربعة أشياء يدوياً عند كل حد يتحكم فيه الملف: عرض العدد الصحيح، والتحقق من النطاق، وعمق العودية، وتهيئة الذاكرة المؤقتة

تم إغلاق هذه العيوب في إصدارات PDFlibPas الحالية، محرك Delphi و C++Builder. إذا كان عملك يصل أيضاً إلى كيفية ادعاء ملف بأنه محمي، فإن الملاحظات المصاحبة حول تدقيق التشفير والأذونات وحول اختبار PDF/A و PDF/UA المسبق تغطي جانب التحليل للمحلل نفسه، وتشحن جميعها داخل مكتبة PDFlibPas Delphi PDF جنباً إلى جنب مع واجهات برمجة تطبيقات التحميل، والعرض، والتوقيع المغطاة في مكان آخر في هذه المدونة