ملف PDF ليس مستنداً تفتحه فحسب. إنه برنامج صغير تقوم بتشغيله. كل خط مدمج هو مفسر قائم على المكدس ينتظر سلاسل الأحرف (charstrings)، وكل صورة هي مفكك ترميز يتغذى على حقول العرض والارتفاع وعمق البت التي اختارها الملف، ويصل كل تدفق ملفوفاً في مرشحات تحدد معالمها الملف. لا أحد من تلك الأرقام ملكك. لقد جاءت ممن قام بإنتاج الملف، والذي يكون في عبء العمل الحقيقي فاتورة عميل أو مرفقاً من مرسل غير معروف. إن مفككات الترميز التي تحول تلك البايتات إلى بكسلات وصور رمزية هي مساحة الهجوم، والمحلل الذي يثق بمدخلاته هناك يبعد ملفاً واحداً مشوهاً عن الانهيار أو ما هو أسوأ.
مر PDFlibPas بمرحلة تحصين تعاملت مع مسار فك الترميز بأكمله كمسار معادٍ، عبر برامج الخطوط (TrueType و Type1 و CFF وجداول CMap)، ومفككات ترميز الصور (PNG و GIF و TIFF و JBIG2 و CCITT Group 3 و Group 4)، ومرشحات التدفق (LZW و ASCII85 والمتنبئات Flate). ما يلي هو خمس فئات من العيوب التي أغلقها، وكل منها يرتكز على سلوك Delphi المحدد الذي جعلها ممكنة. لقد تم إصلاحها في الإصدارات الحالية، وتتكرر نفس الأشكال في أي كود Pascal يحلل مدخلات غير موثوقة.
تجاوز سعة عدد صحيح يسلمك حاوية أصغر من اللازم
خطأ سلامة الذاكرة الكلاسيكي في مفكك ترميز الصور هو حاصل ضرب الأبعاد الذي يلتف. يقرأ مفكك الترميز العرض والارتفاع وعدد المكونات وعمق البت، ويضربها لتحديد حجم إخراجه، ويخصص هذا العدد من البايتات، ثم يكتب الصورة بأبعادها الحقيقية. إذا تم إجراء الضرب في حساب 32 بت، فإن حاصل الضرب يمكن أن يلتف إلى قيمة صغيرة حتى عندما يكون كل عامل فردي ضمن نطاق سليم، لذا ينجح التخصيص، ويأتي صغيراً جداً، ويمشي فك الترميز خارج نهاية التخصيص. هذا هو تجاوز سعة العدد الصحيح (CWE-190)، مما يؤدي إلى كتابة خارج حدود الذاكرة المخصصة (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 بت هو تحويل قانوني لن يحذر المترجم منه افتراضياً، ولا يكتشف فحص النطاق الالتفاف الذي يحدث قبل استخدام القيمة كفهرس أبداً. اترك حاصل الضرب عند 32 بت وتمنحك اللغة بصمت طولاً يكذب بشأن كمية الذاكرة التي يوشك فك الترميز على لمسها.
نوع الحقل الذي يجعل إطلاق الحارس مستحيلاً
ملف TIFF عبارة عن سلسلة من أدلة ملفات الصور، يحمل كل منها إزاحة البايت للدليل التالي. يمكن لملف خبيث توجيه هذه السلسلة إلى نفسها، ويعمل القارئ الذي يتصفحها دون شرط توقف إلى الأبد. هذا هو CWE-835، حلقة مفرغة مدفوعة بمدخلات يتحكم فيها المهاجم، والدفاع هو عداد يتوقف بمجرد تجاوزه حداً لن يصل إليه أي ملف مشروع.
تم الإعلان عن عداد الصفحات كـ Word، والذي يحمل في Delphi من 0 إلى 65535. حملت الحلقة حارس إنهاء على شكل "توقف عندما يتجاوز عدد الصفحات 65535"، وهو ما يقرأ كأمر صحيح حتى تلاحظ أن المعامل والعتبة يتشاركان في الحد الأقصى. لا يمكن لكائن Word أن يكون أكبر من 65535، لذا فإن المقارنة هيكلياً خاطئة دائماً: عندما يصل العداد إلى 65535، فإن الزيادة التالية تلتف به إلى 0، ولا يرى الحارس أبداً قيمة فوق السقف، وتحافظ سلسلة IFD الدائرية على دوران القارئ.
كان الإصلاح هو توسيع الحقل بحيث يمكن للحارس التعبير عن قيمة يمكن للعداد الاحتفاظ بها بالفعل. مع إعلان TPDFTIFF.FPageCount كـ Integer، تصبح مقارنة FPageCount > 65535 نفسها قابلة للوصول، وتنتهي الحلقة، وتغير نوع خاصية PageCount العامة لتتطابق دون كسر أي مستدعي. كلما كان فحص الحدود على شكل Value > MaxValueOfType(Value) وكان المعامل مكتوباً بالفعل عند هذا الحد الأقصى بالضبط، تكون الحالة خاطئة باستمرار: قم بتوسيع النوع، أو اختبر المساواة ضد الحد الأقصى بحيث يمكن إطلاقه.
إيقاف فحص النطاق في مسار نشط
مع تشغيل فحص النطاق، يدرج Delphi فحص حدود في كل فهرس مصفوفة وسلسلة، وهو الفرق بين فهرس خارج النطاق يثير ERangeError يمكن التقاطه ونفس الفهرس الذي يقرأ أو يكتب في ذاكرة لا تنتمي إلى البنية. تقوم المسارات النشطة أحياناً بتعطيله باستخدام توجيه محلي {$R-}، وهو أمر يمكن الدفاع عنه حتى تتوقف الفهارس عن كونها جديرة بالثقة.
موصول القائمة الذي تعتمد عليه مفسرات الخطوط، TPDFlibStringList.Get، هو بالضبط مثل هذا المسار. في Windows، يتم تجميعه مع إيقاف فحص النطاق ويفهرس مخزنه الخلفي مباشرة، لذا فإن الفهرس خارج النطاق ليس خطأ بل وصولاً خاماً إلى الذاكرة. هذا جيد عندما يكون الفهرس صالحاً دائماً، ويتوقف عن كونه صالحاً داخل مفسر charstring لـ CFF أو Type2، حيث يمكن أن يأتي الفهرس من الملف. ينتج charstring الذي يسحب معامل من مكدس فارغ فهرساً بقيمة سالب واحد؛ ويفهرس معرف صورة رمزية منحرف بواحد مقابل عدد الصور الرمزية موضعاً واحداً بعد النهاية. مع إيقاف فحص النطاق، يصبح كلاهما وصولاً حقيقياً خارج الحدود بدلاً من استثناء يمكن التقاطه، ولأن المواضع تحتوي على قيم AnsiString ذات عدد مراجع، يمكن لقراءة ضالة أيضاً إفساد عدد مراجع السلسلة.
لم يقم التحصين بإعادة تشغيل فحص النطاق للمسار النشط. بل جعل الفهارس صالحة بشكل قابل للإثبات أولاً: قبل أخذ قمة مكدس المعاملات، يتحقق المفسر من أن المكدس ليس فارغاً، وتمت كتابة كل حارس فهرس كأقل من صارم ضد العدد بدلاً من أقل من أو يساوي الذي يقبل الانحراف بواحد. ينقل التوجيه المسؤولية عن الحدود من المترجم إليك، ويجب إعادة التحقق الذي أزاله يدوياً عند كل نقطة دخول.
تكرار غير محدود في مفسر charstring
يمكن لـ Type2 charstring استدعاء روتين فرعي، والروتين الفرعي هو نفسه charstring يمكنه استدعاء آخر، لذا تتيح مشغلات استدعاء الروتين الفرعي المحلية والعالمية للملف تحديد مدى عمق الاستدعاء. الروتين الفرعي الذي يستدعي نفسه، مباشرة أو من خلال دورة، يتكرر دون نهاية حتى يتم استنفاد المكدس الأصلي وتموت العملية. هذا هو CWE-674، تكرار غير خاضع للرقابة.
كان مفسر Type1 يحمي بالفعل ضد هذا. لقد حمل عداد عمق استدعاء وسقفاً، PLType1MaxCallDepth، ورفض النزول بعده، وهو ما يعكس حد العمق الذي تسميه مواصفات Type1 نفسها. مفسر Type2، المضاف لاحقاً والمماثل هيكلياً، لم يحمل نفس الحارس، والخط المصنوع يدوياً مع روتين فرعي يستدعي رقمه يمر مباشرة عبر الفحص المفقود إلى فيضان المكدس.
// 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 بالفعل. يحتاج أي نزول متكرر عبر بنية يتحكم فيها المهاجم، سواء كانت روتينات خط فرعية، أو مصفوفة متداخلة، أو سلسلة مراجع تقاطعية، إلى سقف عمق لا يمكن للمدخلات رفعه.
ذاكرة غير مهيأة تتسرب إلى المخرجات
أدى العيب الأكثر دقة إلى تسريب محتويات الذاكرة المخصصة (heap) إلى المخرجات التي تم فك تشفيرها، والسبب هو خاصية لـ SetLength يسهل نسيانها. عندما تزيد حجم AnsiString باستخدام SetLength، يخصص Delphi البايتات ولكنه لا يصفرها، لذا تحافظ المنطقة الجديدة على كل ما كان سابقاً في ذاكرة الذاكرة المخصصة تلك. إذا تمت كتابة كل بايت لاحقاً، فلن يهم هذا أبداً؛ أما إذا ترك مسار جزءاً من الحاوية دون كتابة ثم أعاده كبيانات، فإن تلك البايتات القديمة تخرج مع النتيجة. هذا هو استخدام ذاكرة غير مهيأة (CWE-457)، وعندما تعبر النتيجة حدود الثقة فإنها تصبح تسريباً للمعلومات.
اصطدم مسار فك تشفير AES-CBC بهذا تماماً. تم تحديد حجم حاوية المخرجات باستخدام SetLength وقام مفكك التشفير بمعالجة النص المشفر كتلة واحدة بحجم 16 بايت في كل مرة. عندما لم يكن طول النص المشفر مضاعفاً لـ 16، وهو طول يمكن للمهاجم اختياره، لم يتم كتابة الكتلة الجزئية المتبقية أبداً، لذا احتفظت تلك البايتات النهائية بمحتويات الذاكرة المخصصة التي خلفها SetLength وسلمت الحاوية كـ نص عادي مفكك تشفيره لكائن مستند. العلاج هو حارسان، ولا يكفي أحدهما بمفرده: ترفض نقطة دخول فك التشفير الآن أي نص مشفر لا يكون طوله مضاعفاً لحجم الكتلة، وكدعم احتياطي يتم مسح المخرجات باستخدام FillChar قبل الاستخدام بحيث يعيد أي مسار يفشل في كتابة منطقة أصفاراً بدلاً من بقايا الذاكرة المخصصة.
ما تتركك به المرحلة
العيوب الخمسة هي أخطاء مختلفة، لكنها تتناغم. عرض عدد صحيح يلتف في حاصل ضرب، ونوع حقل يثبت حارساً في حالة خطأ مستمرة، وفحص نطاق معطل حيث توقفت الفهارس عن كونها آمنة، وتكرار ليس له أرضية، وحاوية رفضت اللغة تصفيرها. في كل منها، فعل Delphi ما يحدده تماماً، لأن اللغة تمنحك عمليات حسابية تلتف، وتضييقاً صامتاً، وفحوصات نطاق يمكنك إيقاف تشغيلها، وتكراراً دون حد مدمج، وتخصيصاً لا يقوم بالتهيئة. هذا هو العقد، ويلبيه محلل Pascal بامتلاك أربعة أشياء يدوياً عند كل حد يتحكم فيه الملف: عرض العدد الصحيح، وفحص النطاق، وعمق التكرار، وتهيئة الحاوية.
تم إغلاق هذه العيوب في إصدارات PDFlibPas الحالية، المحرك لـ Delphi و C++Builder. إذا كان عملك يمتد أيضاً إلى كيفية ادعاء الملف بأنه محمي، فإن الملاحظات المصاحبة حول تدقيق التشفير والأذونات وحول ما قبل الرحلة لـ PDF/A و PDF/UA تغطي جانب التحليل لنفس المحلل، ويشحن كل ذلك داخل مكتبة PDFlibPas Delphi PDF جنباً إلى جنب مع واجهات برمجة التطبيقات للتحميل والرسم والتوقيع المغطاة في مكان آخر في هذه المدونة.