مقال تقني

التحقق من سجل EOCD في ZIP لملفات XLSX غير الموثوقة في Delphi

ملف xlsx هو أرشيف ZIP، وZIP ليس له جدول محتويات موثوق واحد. تعامل مكتبة HotXLS Excel Library لـ Delphi وC++Builder ذلك الغموض كسطح هجوم: مُحلِّل نهاية الفهرس المركزي فيها يقبل سجلًا مرشَّحًا فقط بعد اتفاق أربعة فحوصات متقاطعة مستقلة، بحيث لا يفوز فهرس مُزوَّر مخفي في تعليق ZIP أبدًا

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

أين تعيش الحقيقة عن أرشيف ZIP فعليًا؟

تعيش في النهاية بالضبط، في بنية بـ22 بايت تُسمّى سجل نهاية الفهرس المركزي. لا يُقرأ ملف ZIP من البداية إلى النهاية: كل عضو يحمل ترويسة ملف محلية مباشرةً قبل بياناته المضغوطة، لكن الفهرس الموثوق هو الفهرس المركزي، سلسلة من السجلات قرب النهاية تُسمّي كل مدخل وتُعطي إزاحة ترويسته المحلية. للعثور على الفهرس المركزي يجب أن تعثر أولًا على EOCD، لأن EOCD هو ما يقول أين يبدأ الفهرس وكم سجلًا يحمل. يُنمذجه HotXLS كـ TEndOfCentralDirectoryRecord، الذي تُطابق حقوله واحدًا لواحد التخطيط على القرص: FDiskNumber عند الإزاحة 4، وFStartDisk عند 6، وFThisDiskEntries عند 8، وFTotalEntries عند 10، وFSizeOfCD عند 12، وFOffsetOfStartCD عند 16، وFCommentLen عند 20. ذلك الإجمالي هو FMinSize، المحسوب في المُنشئ كـ 4*3 + 5*2. بعده يأتي تعليق الأرشيف، حتى 65535 بايت من محتوى عشوائي، مما يجعل FMaxSize يساوي 65557 ويعني أن السجل ليس في موضع ثابت. عليك الذهاب للبحث عنه

لماذا لا يكفي المسح للخلف بحثًا عن توقيع EOCD؟

لأن البايتات الأربعة التي تمسح بحثًا عنها، PK\005\006، يمكن أن تظهر شرعيًا داخل تعليق الأرشيف، أو داخل بيانات مضغوطة، أو داخل EOCD ثانٍ أضافه مهاجم عمدًا. مُحلِّل يتوقف عند أول توقيع يواجهه أثناء المشي للخلف قابل للتوجيه بسهولة: ضع EOCD خادعًا قرب النهاية ويتبعه المُحلِّل الساذج، بينما مُحلِّل يمسح بترتيب مختلف، أو يعامل آخر توقيع في الملف كأنه المرجعي، يتبع الحقيقي. هذه عائلة هجمات غموض ZIP، وعائدها هو بالضبط الانقسام الموصوف أعلاه، حيث يرى محرك المسح والتطبيق المستهلِك مجموعتَي مدخلات مختلفتين من ملف واحد

تمسح TEndOfCentralDirectoryRecord.Parse للخلف فعلًا. تُعيّن startscan إلى آخر بايت، وتُقيّد endscan إلى lsize - FMaxSize أو صفر، وتمشي في النافذة بمخازن مؤقتة من 256 بايت تتداخل بثلاثة بايتات بحيث لا يُفوَّت أبدًا توقيع يمتد عبر حدود مخزن مؤقت. الفرق هو ما يحدث عند إصابة. العثور على التوقيع يُنتج فقط إزاحة Candidate. يقرأ HotXLS بعدها الـ22 بايت عند تلك الإزاحة، ويُحلّلها بـ ReadEOCD، ويشترط أن تكون الحقول الناتجة متسقة داخليًا مع الملف الذي تدّعي وصفه قبل تعيين FOffsetEOCD إطلاقًا

Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
  SetLength(RecordBuf, FMinSize);
  inputstream.Position := Candidate;
  if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
  begin
    ReadEOCD(RecordBuf[0], 0);
    if (Candidate + FMinSize + FCommentLen = lsize) and
       (FDiskNumber = 0) and (FStartDisk = 0) and
       (FThisDiskEntries = FTotalEntries) and
       (Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
    begin
      FOffsetEOCD := Candidate;
      Result := FOffsetEOCD;
      Exit;
    end;
  end;
end;

اقرأ المُسنَد كأربعة ادعاءات منفصلة يجب على التزوير الوفاء بها في آن واحد. Candidate + FMinSize + FCommentLen = lsize يشترط أن يصل طول التعليق المُعلَن بالضبط إلى نهاية الملف، وهذا ما يقتل حيلة الخداع داخل التعليق: لا يستطيع EOCD مزيَّف مدفون داخل تعليق حقيقي أيضًا تفسير كل بايت بعد نفسه. FDiskNumber = 0 وFStartDisk = 0 يرفضان حقول الامتداد متعدد الأقراص التي لم يستخدمها أي ملف xlsx شرعيًا قط والموجودة في أرشيفات مُصنَّعة فقط للإرباك. FThisDiskEntries = FTotalEntries يرفض حيلة عدّ الانقسام حيث يُحدد مُحلِّل واحد حجم حلقته من حقل ومُحلِّل آخر من الآخر. وInt64(FOffsetOfStartCD) + FSizeOfCD = Candidate يشترط أن ينتهي الفهرس المركزي بالضبط حيث يبدأ EOCD، بحيث لا يمكن توجيه الفهرس إلى كتلة غير ذات صلة في مكان آخر من الملف. تحويل Int64 على الأخير مهم: كلا المعاملين 32 بت، ودون توسيع، يمكن لزوج مُصنَّع أن يلتف وأن يستوفي الاختبار حسابيًا بينما يشير إلى لا مكان عاقل

يجب أن تتفق الترويسات المحلية مع الفهرس المركزي

فحوصات EOCD تُحدد أي فهرس هو الموثوق؛ لكنها لا تضمن بعد أن الفهرس يقول الحقيقة عن الأعضاء الفرديين. كل مدخل يُوصف مرتين في ملف ZIP، مرة مركزيًا ومرة في ترويسته المحلية، ولا شيء في التنسيق يُجبر الوصفين على التطابق، لذا قارئ يثق بالفهرس المركزي وقارئ يثق بالترويسات المحلية يمكن أن يستخرجا محتوى مختلفًا من أرشيف واحد. تُغلق TZipEntry.ParseLocalHeader تلك الفجوة بتحليل الترويسة المحلية عند FCdFile.LocalFileHeaderOffset ومقارنة النسختين حقلًا بحقل، مُعيدةً رمزًا سالبًا مميزًا لكل نوع خلاف: اسم المدخل المُقنَّن، وطريقة الضغط، وأعلام البتات العامة الغرض، وعندما تكون علامة واصف البيانات صافية، CRC32 وكلا الحجمين. مع تلك العلامة مُعيَّنة يمكن أن تكون النسخ المحلية صفرًا، لأن القيم الحقيقية تعيش في واصف لاحق، لكن أي قيمة محلية غير صفرية يجب أن تطابق مع ذلك. فحص أخير يرفض المدخلات التي تمتد بياناتها بعد نهاية الملف، مقارنًا Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) بـ inputstream.Size. أي فشل يخرج من TCentralDirectory.Parse كنتيجة غير 1 وتُحوّله TZipArchive.OpenArchive إلى Can't open zip archive، بدلًا من تسليمك كائن أرشيف موثوقًا جزئيًا. عندما تحتاج فقط معرفة أي الأوراق يحويها ملف، تشغيل ذلك التحقق قبل تحليل كامل رخيص، ويُعطيك مسار فحص الأوراق الخفيف ذلك بالضبط دون تجسيد بيانات الخلايا

ماذا يحدث عندما تكذب البايتات نفسها؟

الاتفاق البنيوي لا يزال لا يقول شيئًا عن الحمولة، لذا يُغلّف HotXLS كل دفق مدخل في TZipVerifiedStream، الذي يفرض الحجم المُعلَن وCRC32 أثناء قراءة المستدعي. هذا ليس فحصًا لاحقًا عمدًا: قنبلة فك ضغط حجمها غير المضغوط المُعلَن 4 كيلوبايت لكنها تتضخم إلى غيغابايتات تُوقَف عند علامة الـ4 كيلوبايت، لا بعد وقوع الضرر. المُغلِّف يُقيّد كل قراءة إلى البايتات المُعلَنة المتبقية، ويُثير ZIP entry ended before its declared size إذا نفد المصدر مبكرًا، ويفحص بايتًا إضافيًا واحدًا عند الاكتمال ويُثير ZIP entry exceeds its declared size إذا بقي أي شيء، وأخيرًا يقارن CRC32 الجاري في VerifyComplete، مُثيرًا ZIP entry uncompressed size mismatch أو ZIP entry CRC32 mismatch

if Count > 0 then
begin
  Result := FSource.Read(Buffer, Count);
  if Result <= 0 then
    raise Exception.Create('ZIP entry ended before its declared size');
  FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
  Inc(FPosition, Result);
end
else
  Result := 0;

if FPosition = FExpectedSize then
begin
  if FSource.Read(Probe, 1) <> 0 then
    raise Exception.Create('ZIP entry exceeds its declared size');
  VerifyComplete;
end;

نتيجة واحدة تستحق التخطيط لها. الدفق أمامي الاتجاه فقط بالتصميم؛ أي Seek إلى أي مكان غير الموضع الحالي يُثير ZIP entry stream is forward-only، مع تنازل واحد لـ soEnd بإزاحة صفر بحيث لا تزال استعلامات الحجم تعمل. تلك هي المفاضلة الصحيحة للمدخل غير الموثوق، لأن دفقًا يمكنك إرجاعه هو دفق يمكنك إبطال محاسبة CRC الخاصة به، لكنه يعني أن كود المستهلك الذي يتوقع دفقًا قابلًا للبحث يحتاج مخزنه المؤقت الخاص. نفس الانضباط أمامي الاتجاه فقط يدعم القارئ المباشر المتدفق، وهو الواجهة البرمجية التي تلجأ إليها عندما يكون المصنّف المرفوع كبيرًا بما يكفي بحيث لا تريده مقيمًا في الذاكرة إطلاقًا

حدود الموارد قبل التخصيص، لا بعده

ثلاثة ثوابت في lxZipArchive تُحدّد ما يمكن أن يطلبه أرشيف واحد من العملية أن تفعله، وتُطبِّقها TZipEntries.Add بينما لا يزال الفهرس المركزي يُقرأ، قبل أن يُمسّ بايت واحد من بيانات المدخل. ZipMaxEntryUncompressedSize يُحدّد عضوًا واحدًا بغيغابايت واحد، وZipMaxTotalUncompressedSize يُحدّد الأرشيف بـ4 غيغابايت، وZipMaxCompressionRatio البالغ 10000 يرفض أي مدخل مُضغوط بـdeflate تتجاوز توسعته المُعلَنة عشرة آلاف ضعف، جنبًا إلى جنب مع الحالة المتدنية لحجم غير مضغوط غير صفري مقترن بحجم مضغوط صفري. أسماء المدخلات تمر عبر CanonicalZipEntryName في نفس الاستدعاء، التي ترفض أحرف NUL المضمَّنة، والنقطتين، وأي مقطع مسار .. بـ Invalid ZIP entry name، وتُحوِّل المقاطع إلى أحرف صغيرة وتُطبِّعها بحيث يتصادم عضوان يختلفان فقط في حالة الأحرف أو في فواصل زائدة كـ Duplicate ZIP entry name بدلًا من تظليل بعضهما البعض بصمت

دفاع متعمق فوق طبقة ZIP

طبقة ZIP واحدة من عدة طبقات، والنمط يتكرر حيثما يُحلّل HotXLS بنية يتحكم فيها المهاجم. أوضح مثال يقع في مُحلِّل صيغ BIFF: تتعاود TXLSFormula.GetTranslated عبر رموز tMemFunc، لذا يمكن لدفق رموز rgce مُصنَّع في ملف .xls قديم أن يتداخل بعمق عشوائي وينفد المكدس. البوابة ثابت، MaxTranslateDepth = 256، مُختار مقابل حقيقة معروفة من المنبع لا مُخمَّن. يُقيّد Excel تداخل الصيغ عند 64، لذا يترك 256 هامشًا رباعي الأضعاف ولا يمكن أبدًا أن يرفض صيغة أنتجها جدول بيانات حقيقي، بينما لا يزال ينهي دفقًا خبيثًا قبل نفاد المكدس بوقت كافٍ

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

لاحظ أن البوابة تُعيد nil بدلًا من إثارة استثناء. صيغة عميقة جدًا بحيث لا يمكن أن تكون حقيقية تُنتج شجرة نحوية فارغة، ويستمر التحليل المحيط، ويظل المصنّف يُحمَّل. عدم التماثل ذلك متعمَّد ويستحق النسخ في حدودك الخاصة: حد موجود لإيقاف استنزاف الموارد يجب أن يُدهور أصغر وحدة يستطيعها، لا إجهاض المستند. نفس المنطق ينطبق عندما تُوسِّع طبقة الحساب، لذا إذا سجّلت مُعالِجاتك الخاصة عبر واجهة دوال محرك الصيغ المخصصة، أعطها حدود وسيطات وعودية خاصة بها بدلًا من افتراض أن المستدعي تحقق بالفعل

ما لا تشتريه لك هذه الفحوصات

كن دقيقًا بشأن الحد. فحوصات EOCD المتقاطعة الأربعة تجعل فهرس الأرشيف غير غامض، بحيث يحل HotXLS وأي قارئ مطابق آخر نفس الملف إلى نفس مجموعة المدخلات؛ لا تقول شيئًا عمّا إذا كانت مجموعة المدخلات تلك غير ضارة. اتفاق الترويسة المحلية يوقف حيلة العرضين، لا حمولة خبيثة موصوفة باتساق. الدفق المُتحقَّق يوقف التقطيع، والفيض، والتلف، لا جزء XML مُشكَّل جيدًا تمامًا يُرمِّز شيئًا لم تتوقعه. ولا شيء من هذا يمسّ الماكرو: مشروع VBA داخل مصنّف بنيويًا مثالي لا يزال مشروع VBA، والقرار بالاحتفاظ به أو تجريده أو رفضه ينتمي إلى طبقة سياستك، لا إلى قارئ ZIP

ما تحصل عليه في المقابل هو حد فشل نظيف. ملف xlsx غير موثوق إما يُفتح كأرشيف واحد غير غامض تتطابق أعضاؤه مع أحجامها ومجاميع تحققها المُعلَنة، أو يُثير استثناءً برسالة تُسمّي الثابت المحدد الذي خالفه، وتستطيع خدمتك عزل الملف بناءً على الاستثناء بدلًا من التخمين. قارئ ZIP وطبقات المُحلِّل فوقه تُشحن كجزء من مكوّن HotXLS Excel لـ Delphi وC++Builder، الذي لا يحتاج Excel ولا أتمتة OLE على الجهاز الذي يُنفِّذ التحليل، وذلك الغياب بحد ذاته تقليل مهم لما يستطيع ملف مرفوع الوصول إليه