Technical Article

تحصين موقع PDF في Delphi ضد ملفات PKCS#12 الخبيثة

عندما تقوم بتوقيع مستند PDF، فإنك عادة ما تفكر في مفتاح التوقيع كشيء تتحكم فيه. فهو يعيش في ملف .pfx قمت بإنشائه، ومحمي بكلمة مرور اخترتها. يبدو الكود الذي يقرأ هذا الملف وكأنه سباكة برمجية، وليس حداً أمنياً. هذا الحدس خاطئ في اللحظة التي تتوقف فيها الشهادة عن كونها ملكاً لك. أداة سطح المكتب التي تتيح للمستخدم اختيار أي ملف .pfx، والخادم الذي يقبل بيانات اعتماد مرفوعة، وموقع الدفعات الذي يتم تغذيته بالشهادات عبر الشبكة، كلها تسلم بايتات متأثرة بالمهاجم إلى المحلل قبل إنتاج بايت توقيع واحد. إن قارئ PKCS#12 هو مساحة هجوم، بنفس المعنى الذي يكون فيه مفكك ترميز الصور أو محمل الخطوط.

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

أين تسافر البايتات

إن استيراد ملف .pfx لتوقيع مستند ليس عملية واحدة، بل هو خط أنابيب قصير، وتقوم كل مرحلة بتحليل شيء قد يكون المهاجم قد كتبه. الحاوية عبارة عن بنية PKCS#12 كما هي محددة في RFC 7292، وهي مجموعة من حقائب AuthenticatedSafe الملفوفة حول غلاف مشفر يحتوي على المفتاح الخاص. قراءته تعني المرور عبر ASN.1، واشتقاق مفتاح من كلمة المرور، وفك التشفير، ثم تسليم مفتاح RSA المسترد إلى الكود الذي يبني التوقيع.

في HotPDF، ترتبط هذه المراحل بوحدات متميزة. يعيش منطق حاوية PKCS#12 في HPDFPFX. يتم فك ترميز كل علامة وطول وقيمة يلمسها بواسطة قارئ ASN.1 في HPDFASN1. يقع اشتقاق المفتاح وفك تشفير PBES2 في HPDFCrypt جنباً إلى جنب مع PBKDF2HMACSHA256. عند استرداد المفتاح، يقوم HPDFRSA ومنشئ SignedData لـ CMS في HPDFCMS بتحويله إلى التوقيع المنفصل المدمج في ملف PDF. نقطة الدخول العامة التي تقود السلسلة بأكملها هي استدعاء واحد.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

يتدفق كل بايت من signer.pfx عبر HPDFASN1 و HPDFPFX قبل حدوث أي تشفير. إذا لم تكن هاتان الوحدتان حذرتين بشأن ما يدعيه الملف، فلن يحصل التشفير في المراحل اللاحقة على فرصة ليكون له أهمية.

العيب الأول: طول ASN.1 يتجاوز حدود الحماية

يقوم ترميز ASN.1 في DER و BER بتشفير كل عنصر كعلامة، وطول، وهذا العدد من بايتات المحتوى. الطول هو الحقل الذي يجب عليك الوثوق به والتحقق منه، لأنه يخبر المحلل بمدى القراءة، وقد كتبه من قام بإنتاج الملف. تحدد المواصفة X.690 §8.1.3 ترميزين. يحزم الشكل القصير طولاً من 0 إلى 127 في بايت واحد. ويستهلك الشكل الطويل، المستخدم لأي شيء أكبر، بايت رصاصي واحد تعطي بتاته السبعة المنخفضة عدد بايتات الطول التي تليها، ثم تحمل هذه البايتات ذات الترتيب الطرفي الكبير (big-endian) القيمة الفعلية. وبالتالي، يمكن لأربعة بايتات طول أن تعلن عن حجم محتوى يقترب من أربعة جيجابايت.

بعد فك ترميز مثل هذه القيمة، يتعين على المحلل التحقق من أن المحتوى يتناسب بالفعل مع الحاوية قبل أن يثق به. التحقق الطبيعي هو تأكيد أن الموضع الحالي بالإضافة إلى طول المحتوى لا يتجاوز نهاية البيانات. إذا تمت كتابة هذا الفحص بالطريقة البديهية، مع الاحتفاظ بالموضع وطول المحتوى والإجمالي في أعداد صحيحة موقعة 32 بت، فإن هذا الحارس يتعطل:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

المشكلة تكمن في الجمع، وليس المقارنة. عندما يكون ContentLen قريباً من MaxInt (2147483647)، فإن Pos + ContentLen يتجاوز نطاق 32 بت الموقع ويلتف إلى رقم سالب. والمجموع السالب لا يكون أبداً أكبر من Total، لذا يبلغ الحارس أن كل شيء على ما يرام ويسمح للمحلل بالاستمرار بطول محتوى يبلغ حوالي اثنين جيجابايت لا تحتويه الحاوية. ما يحدث بعد ذلك هو الضرر: يخصص القارئ حاوية للطول المزعوم ويسخ فيها، يتبع SetLength عملية Move تقرأ من المصدر. يحتوي المصدر على بضع مئات من البايتات المتبقية فقط، لذا فإن النسخ يقرأ خارج نطاق المدخلات بكثير، وهي قراءة خارج الحدود تؤدي في أفضل الأحوال إلى الانهيار وفي أسوأ الأحوال تسرب ذاكرة العملية المجاورة إلى التحليل.

الحارس الصحيح الوحيد يوسع المجموع الوسيط قبل المقارنة، بحيث لا يمكن لعملية الجمع تجاوز نوع البيانات الذي تحسب فيه. يقوم الإصلاح بترقية كلا المعاملين إلى Int64:

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

يحتفظ Int64 بمجموع قيمتين 32 بت دون خسارة، لذا ترى المقارنة الرقم الحقيقي وترفض الطول المزيف. ويغلق الفحص المنفصل غير السالب لـ ContentLen الحالة المطابقة التي تهبط فيها القيمة المفككة سالبة بمفردها. في HotPDF، يعيش هذا الحارس في HPDFASN1ParseNode، وهي الدالة التي تنتج العقدة التي تبنى عليها كل الوظائف المساعدة الأخرى. ونظراً لأن HPDFASN1Content يحدد حجم SetLength و Move مباشرة من طول محتوى العقدة، فإن العقدة التي تجتاز حارساً سيئاً كانت ستسمم كل قراءة مأخوذة منها. إن إصلاح الحدود عند نقطة فك الترميز هو ما يجعل الوظائف المساعدة فوقها آمنة.

العيب الثاني: استخدام عدد تكرار PBKDF2 كسلاح

العيب الثاني ليس خطأ في الذاكرة، بل هو إخبار الملف لوحدة المعالجة المركزية الخاصة بك بمدى صعوبة العمل. يحمي PKCS#12 مواد المفاتيح الخاصة به باستخدام PBES2، وهو المخطط القائم على كلمة المرور من PKCS#5، المحدد في RFC 8018. يقوم PBES2 بتشغيل دالة اشتقاق المفاتيح، وهنا PBKDF2 مع HMAC-SHA-256، ثم التشفير، وهنا AES-256-CBC. يأخذ PBKDF2 عدد تكرار، وهذا العدد هو معلمة محمولة في الملف. الغرض الكامل منه هو أن يكون بطيئاً: فالمزيد من التكرار يعني أن كل تخمين لكلمة المرور يكلف أكثر، وهو أمر جيد ضد مهاجم غير متصل بالإنترنت. يذكر RFC 8018 §4.2 صراحة أن العدد الأكبر هو الأفضل للأمان، ويتعمد عدم وضع سقف له.

هذا الانفتاح جيد عندما قمت أنت بإنشاء الملف. ولكنه سلاح عندما يقوم المهاجم بذلك. عدد التكرار هو عامل عمل يتحكم فيه المهاجم، وعامل العمل الذي يتحكم فيه المهاجم هو حجب خدمة ذو تعقيد خوارزمي. يمكن لملف .pfx مزيف أن يرمز عدداً من التكرارات بالمليارات؛ ويقوم المحلل بقراءته بامتثال ويستدعي PBKDF2 لهذا العدد من جولات HMAC-SHA-256، وتختفي العملية في حلقة لن تعود لدقائق أو ساعات في ملف واحد مقدم. في خادم التوقيع الذي يتعامل مع اعتماد واحد لكل طلب، تؤدي عملية رفع واحدة مصممة بعناية إلى تعطيل عامل التشغيل.

يجعل العدد الالتفاف أسوأ قبل أن يجعل وحدة المعالجة المركزية تدور. تعيش قيمة التكرار في الملف كـ ASN.1 INTEGER، والذي ليس له عرض ثابت، بينما الحقل الذي يستهلكه PBKDF2 في النهاية هو Integer 32 بت. فك ترميز INTEGER مباشرة في هذا الحقل يؤدي إلى اقتطاع القيمة الكبيرة، وتأتي القيمة المصممة للهبوط على بت الإشارة سالبة أو كعدد صغير غير ذي صلة، لذا حتى حجم العمل لم يعد كما يبدو أن الملف يطلبه. يقرأ الإصلاح القيمة بالعرض الكامل ويحدها قبل التضييق:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

القراءة في Int64 تعني أن القيمة المفككة هي القيمة الحقيقية، وليست شبحاً مقتطعاً منها. يرفض الحد الأدنى الأعداد الصفرية والسالبة، وهي غير منطقية لاشتقاق المفاتيح. والحد الأقصى, مائة مليون، يقع بكثير فوق أي ملف PKCS#12 مشروع، والذي يستخدم اليوم عشرات إلى مئات الآلاف المنخفضة من التكرارات، مع وضع حد أقصى لأسوأ الحالات بمقدار محدد ومحتمل من العمل. فقط بعد اجتياز القيمة لهذا النطاق يتم تضييقها إلى حقل 32 بت، وبالتالي لا يمكن للاقتطاع أن يفاجئ أحداً بعد الآن. في HotPDF، يعيش هذا التقييد في ParsePBES2Params، حيث يتم فك ترميز معلمات PBKDF2 في الطريق إلى PBKDF2HMACSHA256.

لماذا كلا الإصلاحين هما نفس الإصلاح

يبدو العيبان مختلفين، أحدهما تجاوز سعة الحاوية والآخر عملية معلقة، لكنهما يمثلان نفس الخطأ. في كل حالة، تم نقل رقم من ملف غير موثوق به إلى نوع ذي عرض ثابت خطوة واحدة مبكراً جداً، قبل التحقق منه مقابل الواقع. تمت إضافة الطول في 32 بت قبل اختبار الحدود؛ وتم تضييق عدد التكرار إلى 32 بت قبل اختبار النطاق. يخضع كلاهما لنفس الانضباط: فك الترميز في العرض الكامل، والتحقق مقابل الحد الحقيقي، ثم التضييق بعد ذلك فقط. ليس Int64 الوسيط اختياراً للأسلوب، بل هو العرض الوحيد الذي يمكن للحارس من خلاله رؤية القيمة التي كتبها المهاجم بالفعل. الحد الذي يفيض ليس حداً، والعدد الذي لا سقف له ليس معلمة، بل هو خانق عن بعد لوحدة المعالجة المركزية الخاصة بك.

توجيهات عملية لخط أنابيب التوقيع

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

الدرس الأوسع يمتد إلى ما هو أبعد من الشهادات. إن تحصين المحلل ليس تدقيقاً لمرة واحدة لوحدة واحدة، بل هو خاصية لكل مكان تقرأ فيه مكتبتك بايتات لم تكتبها هي. تحلل مكتبة PDF الكثير من مصادر غير موثوقة: الخطوط المدمجة في مستند، والصور في ستة أشكال ترميز مختلفة، ومرشحات التدفق، وفي مسار التوقيع، الشهادات. كل من هذه هي مساحة هجوم، وكل منها يستحق نفس الشك في كل طول وكل عدد. يبني HotPDF مسار الاستيراد والتوقيع على وحدات HPDFASN1 و HPDFPFX و HPDFCrypt و HPDFCMS المحصنة الموصوفة هنا، بحيث يتم تحليل بيانات الاعتماد التي تسلمها إليه دفاعياً قبل الوثوق بها على الإطلاق.

سير عمل التوقيع الذي تحميه هذه الفحوصات مغطى من البداية إلى النهاية في دليلنا الإرشادي لتوقيعات PAdES الرقمية في Delphi، ونفس الموقف الدفاعي المطبق على تشفير المستندات، بما في ذلك مسار مفتاح AES-256 الذي يشارك قاعدة الأكواد هذه، موصوف في المقال حول تشفير وأمان AES-256. كل هذا يتم شحنه كجزء من مكون HotPDF لـ Delphi و C++Builder جنباً إلى جنب مع واجهات برمجة التطبيقات للتحميل والتحرير والتشفير والتوقيع المغطاة في مكان آخر في هذه المدونة.