تعتبر عمليات البحث عن سلسلة فرعية مثل Pos('/Length', Dict) هي الأداة الخاطئة لقراءة قاموس PDF، لأن مفاتيح أسماء PDF تشترك في البادئات: /Length هي بادئة لـ /Length1، و/Encrypt هي بادئة لـ /EncryptMetadata. ويحدد معيار ISO 32000-1 الفقرة 7.3.5 الاسم كرمز ينتهي فقط عند علامة تحديد أو مسافة بيضاء، لذا يعتبر المفتاح موجوداً فقط عندما يكون البايت الذي يليه أحد تلك الأحرف. وقارئ القاموس الذي يتخطى هذا الفحص الفردي سيقرأ في النهاية قيمة خاطئة من ملف صالح تماماً
الخطأ الذي علمنا هذا لم يبدُ وكأنه مشكلة في المعالج اللغوي (lexer) على الإطلاق. حيث بدأ فحص المطابقة في الإبلاغ عن تلف دفق 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 في المقطورة مع /EncryptMetadata بداخله. والمفاتيح القصيرة أسوأ: البحث المبني كـ Pos('/' + Key, ...) مع كون Key = 'N' يهبط بسعادة على /Name أو /Nums. ولا تتطلب أي من هذه الاصطدامات ملفاً مشوهاً. فالكاتب الذي يرتب /Length1 قبل /Length متوافق تماماً، مما يعني أن خطأ السلسلة الفرعية ليس فجوة متانة ضد المدخلات المكسورة — بل هو فجوة صحة ضد المدخلات الصالحة
وضع الفشل هو أيضاً من النوع الهادئ. ولا يثير الـ /Length الخاطئ استثناءً؛ بل يسلمك شريحة بايت أقصر أو أطول مما يشغله الدفق بالفعل. وإذا كانت هذه الشريحة تغذي فحص مجموعة فرعية من الخطوط، أو تحليل CMap أو مسح البيانات الوصفية، فإن المستهلك يرى قمامة وعادة لا يبلغ عن أي شيء على الإطلاق، لأن نصف تدفق zlib يفشل ببساطة في الانتفاخ ويتحرك الكود للأمام. لقد قمنا بشحن هذه الفئة من العيوب بالضبط وأصلحناها في الإصدار v2.14.3 من القارئ المشترك لدينا، بعد مراجعة بنداً ببند لـ ISO 32000-1 الفقرات 7.2-7.3 حيث تم اعتبار كل بحث عن مفتاح بنمط Pos مشبوهاً
ما الذي يحدده معيار ISO 32000-1 الفقرة 7.3.5 للاسم بالفعل
القسم 7.3.5 قصير ودقيق: كائن الاسم هو solidus متبوعاً بسلسلة من الأحرف العادية، وينتهي الرمز بأول علامة تحديد أو حرف مسافة بيضاء. والمحددات هي أحرف الأقواس الثمانية بالإضافة إلى solidus وعلامة النسبة المئوية — ( ) < > [ ] { } / % — والمسافة البيضاء هي null، وtab، وتغذية السطر، وتغذية النموذج، وإرجاع الكاريكاتير، والمسافة (الفقرات 7.2.2-7.2.3). وقاعدة الإنهاء هذه هي القصة بأكملها. حيث إن /Length1 ليس "/Length متبوعاً بـ 1"؛ بل هو رمز واحد غير قابل للتجزئة، تماماً كما أن LengthOne وLength هما معرّفان مختلفان في باسكال. وأي قارئ يجد المفاتيح عن طريق البحث عن البايتات الخام يعيد تنفيذ المحلل اللغوي مع حذف قاعدة الإنهاء
إليك شكل العيب، مختصراً في أساسياته. يترجم هذا الإصدار ويمر بالفحوصات ضد الملفات التي يرتب كتابها /Length أولاً، ويفسد التدفقات للكتاب الذين لا يفعلون ذلك
// WRONG: matches /Length1, /Length2, /Length3 as well
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 خام بروتين مشترك واحد مبني على هذه القاعدة
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
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); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
هناك تفصيلان في تلك الحلقة يحملان وزناً. أولاً، يستمر في البحث بدلاً من إرجاع الفشل عند أول تصادم بادئة، لأن /Length1 120 /Length 4076 هو ترتيب قانوني والمفتاح الحقيقي لا يزال في الأمام. ثانياً، تحسب حالة نهاية المخزن المؤقت كمنهٍ، لأن جزء القاموس يمكن أن ينتهي قانونياً مباشرة بعد الاسم. ونقطة أكثر دقة تستحق التدقيق في كودك الخاص: تنطبق نفس القاعدة على الجانب الأيسر من المطابقة إذا كانت سلسلة البحث لا تتضمن solidus، وإلا فإن Pos('Length', ...) يمكن أن تهبط داخل /PieceLength. وتثبيت سلسلة البحث بـ / البادئة، كما هو موضح أعلاه، يتعامل مع الحافة اليسرى لأن / هو نفسه علامة تحديد تنهي الرمز السابق
كيف يمكن لملف PDF عدائي تحويل خطأ المحلل إلى تخصيص جيجابايت؟
يقوم الملف المشوه أو الضار بتصعيد هذه الأخطاء المعجمية إلى استنفاد الموارد، لأن الأعداد الصحيحة للقواميس تغذي بشكل متكرر أحجام التخصيص. ووجدت مراجعتنا سلسلة من هذا الشكل تماماً في توسيع دفق الكائنات. ويقول إدخال /N لقاموس ObjStm عدد الكائنات المضغوطة التي يحملها الدفق، واستدعى كود التوسيع SetLength على مصفوفة بحجمه. ومع ذلك، ترك محلل الأعداد الصحيحة المعلمة الخارجة دون تغيير عند الفشل مع استمرار إرجاعها — لذا فإن /N غير الرقمي سلم SetLength قيمة مكدس غير مهيأة. ويعني العدد الصحيح الموجب غير الصالح هناك طلب تخصيص بالجيجابايت، يتم تشغيله بواسطة بضع بايتات من المدخلات التالفة، أثناء مجرد مسح مستند لم توافق حتى على الثقة به بعد
كان للإصلاح جزأين مستقلين، وكلاهما يعمم. ويعيد المحلل الآن 0 صريحاً عند الفشل، وليس ذاكرة غير مهيأة أبداً. ولم يعد المستهلك يثق في حسابات /N الخالية: تخزن منطقة رأس ObjStm قبل /First زوجاً من الأعداد الصحيحة — رقم الكائن والإزاحة — لكل كائن مضغوط، ويشغل كل زوج أربعة بايتات على الأقل بما في ذلك الفواصل. وأي /N فوق FirstVal div 4 + 1 هو بالتالي مستحيل فيزيائياً لحجم الرأس المعلن ويتم رفضه قبل حدوث أي تخصيص. ويكلف الحد مقارنة واحدة وهو مشتق من البيانات الموجودة بالفعل، وهو النمط الذي يجب البحث عنه: سقف يثبته الملف نفسه، وليس ثابتاً عشوائياً
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
يكمل سقفان آخران المحيط الدفاعي في قارئنا، وكلاهما شُحن في الإصدار v2.12.0. حيث يرفض قارئ الدفق أي /Length أكبر من الملف بأكمله قبل تخصيص مخزن النتيجة المؤقت — لا يمكن أن يكون الدفق أكبر من الحاوية التي يعيش فيها، لذا فإن الفحص خالٍ من الإيجابيات الكاذبة. ويحد مسار النفخ المخرجات التي تم فك ضغطها عند 256 ميجابايت، مما يوقف قنبلة zlib الكلاسيكية حيث تتوسع بضع كيلوبايتات من المدخلات دون حدود؛ الحد سخي لأي تدفق PDF حقيقي مع الحفاظ على بقاء أسوأ الحالات على قيد الحياة. والموضوع عبر الثلاثة هو نفسه: كل حجم يعلنه الملف هو ادعاء، ويتحقق المحلل من كل ادعاء مقابل شيء يمكنه قياسه قبل تخصيص الذاكرة له. وينطبق موقف المراجعة نفسه مستوى واحداً لأسفل عند حدود الربط، وهو مغطى في تصلب واجهة تطبيق ثنائي لـ PDFium وأمان الذاكرة في دلفي
أين لا تكفي قاعدة الرمز بالكامل
حدود صادقة، حتى لا تثق بشكل مفرط في الروتين أعلاه. تعمل مطابقة الرمز بالكامل على إصلاح تعريف المفتاح، ولكن البحث عن البايتات المسطحة عبر مدى القاموس لا يمكنه تحديد ما إذا كانت المطابقة تقع داخل قاموس متداخل، أو سلسلة حرفية، أو تعليق — يمكن لـ FindDictKey على كائن صفحة أن تهبط على مفتاح داخل قاموسها الفرعي /Resources subdictionary إذا سلمتها مدى واسعاً جداً. ويقيد قارئنا المدى بجسم كائن واحد أولاً ويعامل سياقات السلسلة والتعليق كعنصر مراجعة منفصل ولا يزال مفتوحاً. وسلامة السلسلة الفرعية هي درجة واحدة من السلم، وليست السلم بأكمله: اتساق الإسناد الترافقي هو نظام خاص به، مغطى في التحقق من صحة الكائنات وتدفقات xref، وكتالوج التهديدات الأوسع للمستندات التي لم تؤلفها موجود في تدقيق مخاطر أمان PDF
إذا كنت تحتفظ بقارئ قاموس مكتوب يدوياً في دلفي أو لازاروس، فإن قائمة التحقق من هذا الحادث قصيرة. ابحث عن كل Pos('/ في قاعدة الكود ووجه الضربات من خلال مساعد رمز كامل واحد. وسرد عائلات البادئات التي تشارك فيها مفاتيحك — /Length، و/Encrypt، و/N، و/Type ضد /Type1 تظهر جميعها في ملفات حقيقية. ثم تصفح كل عدد صحيح يصل إلى SetLength، أو GetMem أو حلقة نسخ واسأل ما هي حدوده: حجم الملف، أو سقف مشتق من الرأس، أو لا شيء. وطبقة التحليل الموصوفة هنا هي الأساس تحت مكون PDFium Component لدينا، حيث يتقاطع القارئ على مستوى البايت ومحرك العرض في فحص بعضهما البعض في كل مستند يلمسانه