الأدلة التقنية

ماذا يوجد داخل ملف ZIP، وما الذي يخفيه؟

كيف يرتب ملف ZIP محتوياته من الداخل، ولماذا يستطيع ملف مضغوط صغير أن يملأ قرصك أو يكتب خارج المجلد الذي فككته فيه.

ماذا يوجد داخل ملف ZIP، وما الذي يخفيه؟

تفتح ملف ZIP كل يوم دون أن تفكر فيه: مرفق في البريد، أو نسخة احتياطية، أو حزمة برنامج، بل إن ملفات Word وExcel وحزم Java من نوع JAR وتطبيقات Android كلها ملفات ZIP بامتداد مختلف. ولكن ماذا يوجد داخل هذا الملف فعلاً؟ ولماذا يكون ZIP أكبر من 7z لنفس المجلد بثلاث مرات أحياناً؟ ولماذا تستطيع أن ترى أسماء الملفات داخل ZIP محمي بكلمة مرور دون أن تعرفها؟ وكيف يستطيع ملف صغير مرفوع إلى تطبيقك أن يكتب خارج المجلد الذي فككته فيه، أو أن يملأ القرص؟ في هذا المقال نفتح الملف بايتاً ببايت، ثم نقارن الصيغ، ثم ننتقل إلى ما يهم من يكتب الكود الذي يفك ملفات المستخدمين.

وهذا المقال من مرحلة أساسيات علوم الحاسب Computer Science Fundamentals، ويكمل مقال كيف يعمل ضغط البيانات، حيث شرحنا DEFLATE وZstandard وLZMA من الداخل، فهنا لا نعيد شرح الضغط نفسه، وإنما الغلاف الذي يجمع الملفات. وسوف نناقش في هذا المقال ما يلي:

  • الفرق بين الأرشفة Archiving والضغط Compression، ولماذا يختلف tar مع gzip عن ZIP.
  • هيكل ملف ZIP بحسب مواصفة PKWARE: رأس كل ملف والبيانات والدليل المركزي Central Directory وسجل النهاية، وقيمة CRC-32 وZIP64، ثم فحص ملف حقيقي بـ zipinfo وxxd.
  • التشفير في ZIP: لماذا ZipCrypto ضعيف، وما الذي يحميه AES-256 وما الذي لا يحميه.
  • صيغة RAR وترخيصها، وصيغة 7z، وtar مع zstd للنسخ الاحتياطية، والأدوات المفتوحة وتراخيصها.
  • المخاطر الأمنية: القنابل المضغوطة Zip Bombs، وثغرة Zip Slip، والروابط الرمزية، والأرشيفات المشفرة التي تمر من الفحص.
  • تجربة عملية: نفس المجلد بأربع صيغ، وفحص البايتات، والأرشيف المتصل Solid، وسكربت يرفض الأسماء الخطرة والأحجام الكبيرة.

الأرشفة والضغط: عمليتان مختلفتان

الأرشفة Archiving هي جمع عدة ملفات ومجلدات في ملف واحد مع أسمائها وصلاحياتها وتواريخها، دون أن تصغر بالضرورة، والضغط Compression هو تقليل حجم سلسلة من البايتات، وقد تكون ملفاً واحداً. وفي Linux تفصل الأداتان تماماً: tar يجمع الملفات في ملف واحد متصل اسمه Tarball، ثم تضغط هذا الملف كاملاً بـ gzip أو xz أو zstd، ولذلك ترى امتدادات مثل .tar.gz و.tar.zst. أما ZIP فيفعل الأمرين في نفس الصيغة، ولكن بترتيب معاكس: يضغط كل ملف وحده أولاً، ثم يجمع الملفات المضغوطة ويضيف في آخرها قائمة بها. والصورة التالية تبين الطريقتين:

صفان: في الأول ثلاثة ملفات يجمعها tar في سلسلة واحدة ثم يضغطها zstd ككتلة واحدة، فالأصغر tar.zst بحجم 357 KB و7z المتصل 435 KB، ولكن قراءة ملف واحد تحتاج القراءة من البداية، وفي الثاني ZIP يضغط كل ملف وحده ثم يضيف دليلاً في النهاية، فيفتح أي ملف مباشرة ولكنه الأكبر بحجم 1,230 KB
ضغط السلسلة كاملة يستفيد من التشابه بين الملفات، وضغط كل ملف وحده يسمح بفتح أي ملف مباشرة

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

ملف ZIP من الداخل

صمم Phil Katz صيغة ZIP لبرنامج PKZIP من شركته PKWARE في نهاية الثمانينات، وما زالت الشركة تنشر مواصفتها في ملف APPNOTE.TXT، وآخر إصدار منه 6.3.10، ومنه أخذنا كل التفاصيل التالية. وملف ZIP مكون من أربعة أجزاء، كل جزء يبدأ بتوقيع Signature من أربعة بايتات أولها الحرفان PK من اسم Phil Katz:

  1. رأس الملف المحلي Local File Header وتوقيعه 50 4b 03 04: يأتي قبل كل ملف، وفيه اسمه وطريقة ضغطه وتاريخه وقيمة CRC-32 والحجم قبل الضغط وبعده.
  2. البيانات: محتوى الملف مضغوطاً، في الغالب بـ DEFLATE، أو مخزناً كما هو إذا لم يفد الضغط.
  3. الدليل المركزي Central Directory: مدخل لكل ملف توقيعه 50 4b 01 02، ويكرر الاسم والحجم وCRC-32، ويضيف الصلاحيات وموقع رأس الملف المحلي داخل الأرشيف، وهو المرجع الحقيقي لقائمة الملفات.
  4. سجل نهاية الدليل المركزي End of Central Directory واختصاراً EOCD، وتوقيعه 50 4b 05 06: آخر 22 بايتاً في الملف إذا لم يكن فيه تعليق، وفيه عدد الملفات وحجم الدليل المركزي وموقعه.

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

شريط بمقياس الملف demo.zip بحجم 232 بايت: رأس محلي لـ hello.txt عند البايت 0 ثم بياناته، ورأس محلي لـ notes.txt عند 49 ثم بياناته، ثم مدخلا الدليل المركزي عند 100 و155، ثم سجل النهاية عند 210، وأسهم توضح أن القارئ يبدأ من النهاية فيجد أن الدليل عند 100 بحجم 110، ثم يقفز إلى رأس الملف
القارئ يبدأ من آخر 22 بايتاً، ومنها يعرف مكان قائمة الملفات، ومنها مكان كل ملف

وهناك ثلاثة تفاصيل في المواصفة يجب أن تعرفها:

  • قيمة CRC-32: بصمة من 32 بت لمحتوى كل ملف قبل الضغط، يحسبها البرنامج بعد الفك ويقارنها بالمخزنة، وهي نفس فكرة القيمة في آخر ملف gzip التي رأينا كيف كشفت الفساد في تجربة تحقيق Dirty Pipe. ولاحظ أنها تكشف الفساد العرضي فقط، فمن يتعمد تعديل الملف يستطيع أن يحسبها من جديد بسهولة، فهي ليست توقيعاً ولا هاش آمناً كما شرحنا في مقال التشفير للمطورين.
  • ZIP64: حقول الحجم في الصيغة الأصلية 32 بت، وعدد الملفات 16 بت، أي أن الحد 4 GB للملف الواحد وللأرشيف و65,535 ملفاً، وعندما تتجاوزها تكتب الأداة القيمة 0xFFFFFFFF أو 0xFFFF في الحقل الأصلي وتضع القيمة الحقيقية في حقل إضافي من 64 بت وسجل نهاية إضافي، وهذا هو امتداد ZIP64.
  • الدليل المركزي هو المرجع: رأس الملف المحلي والدليل المركزي يكرران نفس المعلومات، والأدوات المختلفة قد تقرأ هذا أو ذاك، وهذا الاختلاف تحديداً هو ما تستغله بعض الهجمات، كما سوف نرى في القنابل المضغوطة.

التشفير في ZIP: ZipCrypto وAES-256

الطريقة البسيطة لحماية ملف ZIP هي الخيار -P أو -e في أداة zip، وهذا يستخدم التشفير الأصلي من PKWARE ويسمى ZipCrypto، والمواصفة نفسها في القسم 6.0 تقول إنه «يعتبر ضعيفاً بمعايير اليوم» ولا ينصح به إلا للتوافق مع الأدوات القديمة. والسبب أن Eli Biham وPaul Kocher نشرا في 1994 هجوماً بالنص المعروف Known-Plaintext Attack عليه، يكفيه 12 بايتاً معروفة من محتوى أحد الملفات حتى يستعيد مفاتيح التشفير دون معرفة كلمة المرور، وهناك أداة مفتوحة تطبقه هي bkcrack. والـ 12 بايتاً سهلة: رأس ملف PNG معروف، وملفات XML في مستندات Office تبدأ بنفس السطر.

والحل الأنسب هو تشفير AES الذي وضعته شركة WinZip، وتصفه في صفحة تشفير AES، وتشير إليه مواصفة PKWARE في الملحق E باسم AE-x، حيث يكون رقم طريقة الضغط 99، ويستخدم AES بمفتاح 128 أو 256 بت في وضع CTR مع HMAC-SHA1 للتحقق من السلامة. وتدعمه أدوات مثل 7-Zip بالخيار -mem=AES256، ولكن لاحظ أمرين: أداة zip وunzip من Info-ZIP لا تدعمانه، وأسماء الملفات وأحجامها تبقى مكشوفة حتى مع AES، لأن ZIP يشفر البيانات فقط ولا يشفر الدليل المركزي، فمن يرى الأرشيف يعرف أن داخله ملفاً اسمه salaries-2026.xlsx دون أن يفتحه. وإذا كانت الأسماء نفسها سرية فاستخدم 7z مع تشفير الرؤوس كما سوف نرى.

صيغة RAR: مغلقة المصدر، وفك الضغط فقط مفتوح

RAR صيغة من شركة RARLAB لبرنامج WinRAR، وصاحب حقوقها Alexander Roshal بحسب اتفاقية الترخيص، والبرنامج نفسه تجريبي لمدة 40 يوماً ثم يحتاج ترخيصاً مدفوعاً. أما الصيغة الحالية RAR 5.0 فموثقة في الملاحظة الفنية من RARLAB، ومنها نعرف ما تتميز به:

  • الأرشيف المتصل Solid: علم في رأس كل ملف يعني أن البرنامج يكمل بنفس قاموس الضغط من الملف السابق، أي نفس فكرة tar مع الضغط التي شرحناها.
  • سجل الاستعادة Recovery Record: بيانات إضافية اختيارية تسمح بإصلاح الأرشيف إذا فسد جزء منه، وهي ميزة لا توجد في ZIP ولا 7z.
  • البصمة BLAKE2sp: يستطيع الأرشيف أن يحفظ لكل ملف بصمة BLAKE2sp من 32 بايتاً بدل CRC-32 أو معها.
  • التشفير: AES-256 مع اشتقاق المفتاح من كلمة المرور بـ PBKDF2، ويستطيع أن يشفر رؤوس الملفات أيضاً فتختفي الأسماء.

والنقطة التي تهمك كمطور هي ترخيص فك الضغط، فالكود المصدري لأداة UnRAR متاح من صفحة الإضافات في RARLAB، ورخصته تسمح باستخدامه مجاناً في أي برنامج لفك أرشيفات RAR، ولكنها تمنع صراحة استخدامه «لتطوير أداة أرشفة متوافقة مع RAR أو لإعادة إنشاء خوارزمية ضغط RAR، فهي مملوكة». أي أنك تستطيع أن تفك RAR بأدوات مفتوحة، ولكنك لا تستطيع أن تنشئ ملف RAR إلا ببرنامج RARLAB. ولهذا السبب لا ننصح باستخدام RAR لأي شيء تنشئه أنت، فلا يوجد سبب لأن تقيد نسخك الاحتياطية بأداة مدفوعة واحدة، والأولى أن تستقبله فقط عندما يرسله لك غيرك.

صيغة 7z: مفتوحة وصغيرة

صيغة 7z من Igor Pavlov مؤلف 7-Zip، وضاغطها الافتراضي LZMA2، وتدعم الأرشيف المتصل Solid افتراضياً، وتشفير AES-256 مع اشتقاق المفتاح بعدد كبير من دورات SHA-256، وضغط الرؤوس وتشفيرها، فمع الخيار -mhe=on لا يستطيع أحد أن يرى حتى أسماء الملفات دون كلمة المرور. وكود 7-Zip مرخص بـ GNU LGPL مع أجزاء بترخيص BSD، وفيه أيضاً جزء بقيد UnRAR نفسه لفك ملفات RAR، كما في ملف الترخيص، ويمكن استخدامه في أي جهاز بما فيها أجهزة الشركات دون أي رسوم. ومنذ سنوات تنشر 7-Zip نسخة رسمية لـ Linux باسم 7zz من صفحة التنزيل، وفي Debian 13 تجدها في الحزمة 7zip باسم الأمر 7z، أما p7zip فهو النسخة القديمة التي نقلها متطوعون إلى Linux قبل ذلك، فاستخدم الرسمية إذا كانت متاحة لك.

tar مع zstd للنسخ الاحتياطية

إذا كنت تنسخ مجلدات على سيرفرات Linux فالخيار الأنسب غالباً هو tar مع zstd، والسبب أن tar يحفظ كل ما يحتاجه Linux: المالك والمجموعة والصلاحيات والروابط الرمزية، بينما ZIP لا يحفظ ذلك كله بنفس الدقة، وzstd يعطي ضغطاً سريعاً وفكاً سريعاً وقت الاستعادة كما رأينا في قياس أدوات الضغط. وأداة GNU tar تدعمه مباشرة بالخيار --zstd كما في توثيق GNU tar. ولكن تذكر أن الأرشيف الواحد ليس نظام نسخ احتياطي، فهو لا يحذف التكرار بين نسخة اليوم ونسخة الأمس ولا يتحقق من سلامة النسخ القديمة، ولذلك شرحنا الطريقة الكاملة في مقال قاعدة 3-2-1، والأدوات المخصصة في مقارنة restic وBorg وKopia.

الأدوات المفتوحة وتراخيصها

الأداةما تفعلهالترخيص
7-Zip (الأمر 7zz أو 7z)تنشئ وتفك 7z وZIP وtar وغيرها، وتفك RARGNU LGPL مع أجزاء BSD وقيد UnRAR
Info-ZIP (zip وunzip وzipinfo)الأدوات المعتادة لـ ZIP في Linux، دون AESInfo-ZIP License المبني على BSD
libarchive وbsdtarمكتبة تقرأ وتكتب tar وZIP و7z وcpio وغيرها، وتقرأ RARBSD
unar و lsarتفك صيغاً كثيرة منها RAR، من مكتبة XADMasterLGPL 2.1
PeaZipواجهة رسومية لـ Windows وLinux فوق 7-Zip وأدوات أخرىLGPLv3
RAR وUnRAR من RARLABالوحيدة التي تنشئ RARتجاري، وUnRAR مجاني بقيود

المخاطر الأمنية: عندما يكون الملف المضغوط هو الهجوم

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

القنابل المضغوطة Zip Bombs

القنبلة المضغوطة ملف صغير ينتج عند فكه حجماً هائلاً يملأ القرص أو الذاكرة، فيوقف السيرفر أو يعطل فاحص الفيروسات. والطريقة القديمة كانت أرشيفات داخل أرشيفات، أشهرها الملف 42.zip، ولكنها تحتاج برنامجاً يفك بشكل متكرر. وفي 2019 نشر الباحث David Fifield قنبلة غير متكررة Non-Recursive، وعرضها في مؤتمر USENIX WOOT 2019، والفكرة أن ملفات كثيرة في الدليل المركزي تتداخل Overlap وتشير كلها إلى نفس الكتلة المضغوطة، فيفك البرنامج نفس البيانات آلاف المرات، والصورة التالية تبين الفرق:

لوحتان: في الأولى أرشيف عادي كل مدخل فيه يشير إلى بياناته، وفي التجربة 200 مليون بايت من الأصفار في ملف ZIP بحجم 194,271 بايت أي نحو 1029 إلى 1 وهو حد DEFLATE، وفي الثانية قنبلة Fifield حيث ستة مدخلات وأكثر تشير إلى نفس الكتلة، والأرقام 42 KB تصبح 5.5 GB و10 MB تصبح 281 TB و46 MB تصبح 4.5 PB مع Zip64
الضغط العادي لا يتجاوز نحو 1000 إلى 1، والتداخل بين المدخلات هو ما يصنع الأرقام الهائلة

وبحسب صفحة الباحث نفسها فإن ملفاً حجمه 42 KB يصبح 5.5 GB، وملفاً حجمه 10 MB يصبح 281 TB، أي نسبة أكبر من 28 مليوناً إلى 1، وملفاً حجمه 46 MB يصبح 4.5 PB مع ZIP64. والحماية لا تكون بفحص الأحجام المكتوبة في الرؤوس فقط، والسبب أن من صنع الملف يكتب فيها ما يشاء، وإنما بأن تعد البايتات الحقيقية أثناء الكتابة وتتوقف عند حد معين، وأن تحدد عدد الملفات، وهذا ما يفعله السكربت في التجربة.

ثغرة Zip Slip وتجاوز المسار

اسم الملف داخل الأرشيف نص يكتبه من أنشأه، فإذا كان الاسم ../../etc/cron.d/job ودمجه تطبيقك مع مجلد الفك دون فحص، كتب الملف خارج المجلد، وإذا كان المكان مجلداً يقرأ منه النظام إعدادات أو مهام مجدولة فقد أصبح تنفيذاً للكود على سيرفرك. وهذا هو نفس تجاوز المسار Path Traversal الذي شرحناه في مقال البرمجة الآمنة، والصورة التالية تبين ما حدث لنفس الملف مع ثلاث طرق للفك في التجربة:

أرشيف evil.zip فيه ملفان، الثاني اسمه ../../tmp/zip-slip-proof.txt، وثلاث طرق: الكود البسيط الذي يدمج الاسم مع المجلد فيكتب في /tmp خارج المجلد، وunzip وPython zipfile اللذان يحذفان ../ فيكتبان داخل المجلد، وsafe_extract.py الذي يحسب المسار الحقيقي ويرفض قبل كتابة أي شيء
الأدوات الجاهزة تحمي نفسها، والخطر في الكود الذي تكتبه أنت لفك الأرشيف
📌
في 5 يونيو 2018 نشر فريق الأمن في Snyk بحث Zip Slip، وأعلن أنه وجد هذا النمط في آلاف المشاريع والمكتبات، منها مشاريع من HP وAmazon وApache وPivotal، وفي لغات Java وJavaScript وRuby و.NET وGo، وكان أكثر انتشاراً في Java لأنه لم تكن هناك مكتبة مركزية لفك الأرشيفات، فانتشر كود منسوخ من المنتديات يدمج اسم المدخل مع المجلد مباشرة. ويتابع الفريق حالة الإصلاح لكل مكتبة في مستودع على GitHub. والدرس أن الكود الصغير الذي تنسخه لفك ملف مضغوط هو كود أمني، فراجعه كما تراجع كود تسجيل الدخول.

صيغ tar وZIP تستطيع أن تحمل رابطاً رمزياً Symbolic Link بدل ملف، فإذا كان في الأرشيف رابط اسمه data يشير إلى /etc، ثم ملف اسمه data/passwd، فإن برنامجاً يفك الاثنين بالترتيب سوف يكتب الملف الثاني داخل /etc مع أن اسمه لا يحتوي على ../. ولذلك لا يكفي فحص الأسماء، وإنما ارفض الروابط الرمزية في الأرشيفات التي يرفعها المستخدمون، أو استخدم ما توفره اللغة، مثل مرشح data في الوحدة tarfile في Python الذي شرحناه في قسم الأرشيفات في مقال البرمجة الآمنة.

الأرشيفات المشفرة تمر من الفحص

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

التجربة العملية

نستخدم في هذه التجربة نفس ال Container الذي شغلناه في تجربة مقال الضغط، وإذا لم يكن عندك فشغله بنفس الأوامر:

docker run -d --name lab-compress-tools --tmpfs /bench:size=2g python:3.13.16-slim sleep infinity
docker exec lab-compress-tools bash -c "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
  gzip bzip2 xz-utils zstd brotli lz4 zip unzip 7zip libarchive-tools xxd openssl curl"
docker exec lab-compress-tools bash -c "zip -v | sed -n 2p; unzip -v | head -1; 7z | sed -n 2p; bsdtar --version; tar --version | head -1"

والمخرج سوف يكون كما يلي:

This is Zip 3.0 (July 5th 2008), by Info-ZIP.
UnZip 6.00 of 20 April 2009, by Debian. Original by Info-ZIP.
7-Zip 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03
bsdtar 3.7.4 - libarchive 3.7.4 zlib/1.3.1 liblzma/5.8.1 bz2lib/1.0.8 liblz4/1.10.0 libzstd/1.5.7
tar (GNU tar) 1.35

نفس المجلد بأربع صيغ

البرنامج التالي يولد مجلد مشروع فيه 1,500 ملف ترجمة JSON صغير ومتشابه، مثل ملفات اللغات في أي تطبيق، وملف سجلات واحد:

# genproj.py: a project folder with many small similar files and one log, generated (seeded)
import os, json, random
random.seed(7)
os.makedirs('project/locales', exist_ok=True); os.makedirs('project/logs', exist_ok=True)
keys = [f'screen.{s}.{k}' for s in ['login', 'orders', 'users', 'reports', 'settings'] for k in ['title', 'save', 'cancel', 'search', 'empty', 'error', 'help', 'back']]
for i in range(1500):
    d = {k: f'{k} text {random.randint(1, 30)}' for k in keys}
    d['_meta'] = {'locale': f'xx-{i:04d}', 'version': 3}
    with open(f'project/locales/{i:04d}.json', 'w') as f: json.dump(d, f, indent=2)
with open('project/logs/app.log', 'w') as f:
    for n in range(40000):
        f.write(f'2026-10-0{1 + n % 9}T10:{n % 60:02d}:00Z INFO order={random.randint(1, 99999)} status={random.choice(["paid", "shipped"])} ms={random.randint(3, 900)}\n')
with open('project/README.md', 'w') as f: f.write('# Demo project\nGenerated test data for the archive lab.\n')

نحفظه باسم genproj.py وننسخه إلى ال Container مع السكربتات الثلاثة التي سوف نستخدمها لاحقاً، ثم نشغله وننشئ نفس المجلد بكل صيغة:

docker exec lab-compress-tools mkdir -p /zlab
for f in genproj.py make_evil.py naive_extract.py safe_extract.py; do docker cp $f lab-compress-tools:/zlab/$f; done
docker exec -it -w /zlab lab-compress-tools bash

python3 genproj.py && du -sb project && find project -type f | wc -l
zip -q -r project.zip project
7z a -bso0 -bsp0 project.7z project
7z a -bso0 -bsp0 -ms=off project-nonsolid.7z project
tar -czf project.tar.gz project
tar --zstd -cf project.tar.zst project
tar -cf - project | zstd -q -19 -T1 > project-19.tar.zst
tar -cJf project.tar.xz project
ls -l project.zip project.7z project-nonsolid.7z project.tar.gz project.tar.zst project-19.tar.zst project.tar.xz | awk '{printf "%10s  %s\n", $5, $9}'
zipinfo project.zip | tail -1

والمخرج سوف يكون كما يلي:

5774754	project
1502
    357309  project-19.tar.zst
    909320  project-nonsolid.7z
    434616  project.7z
    579638  project.tar.gz
    362976  project.tar.xz
    767673  project.tar.zst
   1230054  project.zip
1505 files, 5774754 bytes uncompressed, 962244 bytes compressed:  83.3%

في المخرج أعلاه لاحظ التالي:

  • ZIP كان الأكبر، 1.23 MB، أي أكبر من tar.gz بأكثر من الضعف ومن tar.zst بالمستوى 19 بنحو ثلاث مرات ونصف، والسبب ما شرحناه: كل ملف JSON ضغط وحده، فلم يستفد من تشابهه مع الـ 1,499 ملفاً الأخرى.
  • السطر الأخير من zipinfo يقول إن البيانات المضغوطة نفسها 962,244 بايت، بينما الملف 1,230,054، أي أن 267,810 بايت، نحو 22% من الأرشيف، هي رؤوس الملفات والدليل المركزي، فكل اسم ملف مكتوب مرتين، مرة في الرأس المحلي ومرة في الدليل. والرقم 1505 يشمل المجلدات الثلاثة أيضاً.
  • tar.zst بالمستوى الافتراضي 3 كان أكبر من tar.gz في هذه البيانات، وبالمستوى 19 كان الأصغر بين الكل، فالمستوى يغير النتيجة أكثر من الأداة أحياناً.
  • 7z المتصل أصغر من 7z غير المتصل بأكثر من الضعف، 435 KB مقابل 909 KB، ونفس الضاغط LZMA2 في الحالتين.

الأرشيف المتصل Solid وغير المتصل

نرى ما يقوله 7-Zip عن الأرشيفين:

7z l -slt project.7z | grep -E "^(Solid|Blocks) ="
7z l -slt project-nonsolid.7z | grep -E "^(Solid|Blocks) ="
Solid = +
Blocks = 1
Solid = -
Blocks = 1502

الأرشيف المتصل كتلة واحدة ضغطت فيها كل الملفات كسلسلة، وغير المتصل 1,502 كتلة، كتلة لكل ملف، أي أن غير المتصل يتصرف مثل ZIP، ولكنه أصغر منه لأن LZMA2 أقوى من DEFLATE. والمقابل في المتصل أنك لكي تستخرج آخر ملف يجب أن يفك 7-Zip ما قبله في الكتلة، وأن ملفاً واحداً فاسداً في الكتلة قد يمنع استخراج ما بعده. ولذلك تقسم 7-Zip الأرشيفات الكبيرة إلى عدة كتل متصلة بحجم محدد، والخيار -ms يحدد حجمها.

ملف ZIP بايتاً ببايت

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

mkdir -p small && cd small
printf "hello zip\n" > hello.txt
printf "notes notes notes notes notes notes\n" > notes.txt
touch -d "2026-10-09 10:00:00" hello.txt notes.txt
TZ=UTC zip -q -X demo.zip hello.txt notes.txt
zipinfo demo.zip
xxd demo.zip
grep -obUaP "PK\x03\x04|PK\x01\x02|PK\x05\x06" demo.zip | cut -d: -f1 | paste -sd" "
python3 -c "import zlib; print(hex(zlib.crc32(b\"hello zip\n\")))"

والمخرج سوف يكون كما يلي:

Archive:  demo.zip
Zip file size: 232 bytes, number of entries: 2
-rw-r--r--  3.0 unx       10 t- stor 26-Oct-09 10:00 hello.txt
-rw-r--r--  3.0 unx       36 t- defN 26-Oct-09 10:00 notes.txt
2 files, 46 bytes uncompressed, 22 bytes compressed:  52.2%
00000000: 504b 0304 0a00 0000 0000 0050 495d 48c9  PK.........PI]H.
00000010: 1148 0a00 0000 0a00 0000 0900 0000 6865  .H............he
00000020: 6c6c 6f2e 7478 7468 656c 6c6f 207a 6970  llo.txthello zip
00000030: 0a50 4b03 0414 0000 0008 0000 5049 5d85  .PK.........PI].
00000040: b781 080c 0000 0024 0000 0009 0000 006e  .......$.......n
00000050: 6f74 6573 2e74 7874 cbcb 2f49 2d56 c8c3  otes.txt../I-V..
00000060: 4772 0100 504b 0102 1e03 0a00 0000 0000  Gr..PK..........
00000070: 0050 495d 48c9 1148 0a00 0000 0a00 0000  .PI]H..H........
00000080: 0900 0000 0000 0000 0100 0000 a481 0000  ................
00000090: 0000 6865 6c6c 6f2e 7478 7450 4b01 021e  ..hello.txtPK...
000000a0: 0314 0000 0008 0000 5049 5d85 b781 080c  ........PI].....
000000b0: 0000 0024 0000 0009 0000 0000 0000 0001  ...$............
000000c0: 0000 00a4 8131 0000 006e 6f74 6573 2e74  .....1...notes.t
000000d0: 7874 504b 0506 0000 0000 0200 0200 6e00  xtPK..........n.
000000e0: 0000 6400 0000 0000                      ..d.....
0 49 100 155 210
0x4811c948

في المخرج أعلاه لاحظ التالي:

  • التواقيع في المواقع 0 و49 و100 و155 و210 بالضبط، كما في الصورة السابقة: رأسان محليان، ثم مدخلان في الدليل المركزي، ثم سجل النهاية. وقد رأيت PK في أول البايتات من قبل إذا فتحت ملف docx أو jar في محرر Hex.
  • الملف hello.txt بحجم 10 بايتات خزن كما هو، stor، لأن DEFLATE لا يوفر شيئاً في نص بهذا القصر، ولذلك تظهر الكلمة hello zip مقروءة في البايتات من 0x27، بينما notes.txt ضغط من 36 إلى 12 بايتاً، defN، فلا ترى محتواه في البايتات.
  • قيمة CRC-32 في الرأس الأول هي البايتات 48c9 1148، وهي بترتيب Little-endian الرقم 0x4811c948 نفسه الذي حسبته Python، وقد شرحنا ترتيب البايتات في مقال الأنظمة العددية.
  • سجل النهاية يقول 0200 0200 أي ملفان، ثم 6e00 0000 أي حجم الدليل 110 بايتات، ثم 6400 0000 أي أنه يبدأ عند البايت 100، وهذا بالضبط ما يقرؤه أي برنامج أولاً.

التشفير: ما الذي يبقى مكشوفاً؟

zip -q -P lab-only-pass zc.zip hello.txt notes.txt
7z a -bso0 -bsp0 -tzip -mem=AES256 -plab-only-pass aes.zip hello.txt notes.txt
7z a -bso0 -bsp0 -mhe=on -plab-only-pass hidden.7z hello.txt notes.txt
7z l -slt -plab-only-pass zc.zip | grep -E "^Method =" | tail -2
7z l -slt -plab-only-pass aes.zip | grep -E "^Method =" | tail -2
unzip -l aes.zip | sed -n 4,5p
7z l -pwrong hidden.7z | grep ERROR

والمخرج سوف يكون كما يلي:

Method = ZipCrypto Store
Method = ZipCrypto Deflate
Method = AES-256 Store
Method = AES-256 Deflate
       10  2026-10-09 10:00   hello.txt
       36  2026-10-09 10:00   notes.txt
ERROR: hidden.7z : Cannot open encrypted archive. Wrong password?

في المخرج أعلاه لاحظ التالي:

  • أداة zip العادية استخدمت ZipCrypto، وهو التشفير الضعيف، ولا يوجد فيها خيار لـ AES، فإذا احتجت ZIP مشفراً فأنشئه بـ 7-Zip كما في السطر الثاني.
  • unzip -l عرض أسماء الملفات وأحجامها وتواريخها من أرشيف AES-256 دون أي كلمة مرور، فالتشفير يغطي المحتوى فقط.
  • أرشيف 7z مع -mhe=on رفض حتى أن يعرض القائمة بكلمة مرور خاطئة، لأن الرؤوس نفسها مشفرة.
  • كلمة المرور في سطر الأوامر تظهر في سجل الأوامر Shell History وفي قائمة العمليات، فهي هنا للتجربة فقط، وفي الاستخدام الحقيقي اترك الأداة تطلبها منك.

فحص Zip Slip بسكربت يرفض الأسماء الخطرة

نصنع أولاً أرشيفاً تجريبياً غير ضار، ثانيه ملف نصي اسمه يحاول الخروج إلى /tmp، والكتابة كلها داخل ال Container:

# make_evil.py: a harmless test zip whose second entry tries to climb out of the destination folder
import zipfile
with zipfile.ZipFile('evil.zip', 'w') as z:
    z.writestr('docs/readme.txt', 'a normal file\n')
    z.writestr('../../tmp/zip-slip-proof.txt', 'this file should never be written outside the folder\n')

ثم الكود البسيط الذي ينتشر في المنتديات، والذي يدمج الاسم مع المجلد دون أي فحص، ولا تستخدمه أبداً:

# naive_extract.py: the vulnerable pattern, joining the entry name to the folder without any check (do not use)
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z:
    for name in z.namelist():
        path = os.path.join(sys.argv[2], name)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, 'wb') as f:
            f.write(z.read(name))
        print('wrote', os.path.abspath(path))

والطريقة الصحيحة: نحسب المسار الحقيقي لكل اسم ونتأكد أنه داخل المجلد قبل كتابة أي ملف، ونرفض الروابط الرمزية، ونحدد عدد الملفات، ونعد البايتات الحقيقية أثناء الكتابة ونتوقف عند 50 MiB، فهذا يحمي من القنابل المضغوطة أيضاً:

# safe_extract.py: extract a zip only if every entry stays inside the folder and the total size stays under a limit
import shutil, sys, zipfile
from pathlib import Path

MAX_FILES = 10_000
MAX_TOTAL = 50 * 1024 * 1024          # 50 MiB of extracted data, counted while writing

def safe_extract(archive, dest):
    dest = Path(dest).resolve()
    with zipfile.ZipFile(archive) as z:
        entries = z.infolist()
        if len(entries) > MAX_FILES:
            raise ValueError(f'refused: {len(entries)} entries, the limit is {MAX_FILES}')
        for info in entries:                                   # 1) check every name before writing anything
            target = (dest / info.filename).resolve()
            if not target.is_relative_to(dest):                # "../" or an absolute path leaves the folder
                raise ValueError(f'refused: {info.filename!r} would be written to {target}')
            if (info.external_attr >> 16) & 0o170000 == 0o120000:
                raise ValueError(f'refused: {info.filename!r} is a symbolic link')
        written = 0
        for info in entries:                                   # 2) extract, counting the real bytes
            target = (dest / info.filename).resolve()
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True); continue
            target.parent.mkdir(parents=True, exist_ok=True)
            with z.open(info) as src, open(target, 'wb') as out:
                while chunk := src.read(1024 * 1024):
                    written += len(chunk)
                    if written > MAX_TOTAL:                    # the sizes in the headers can lie
                        raise ValueError(f'refused: more than {MAX_TOTAL:,} bytes after {info.filename!r}')
                    out.write(chunk)
    print(f'ok: {len(entries)} entries, {written:,} bytes extracted to {dest}')

try:
    safe_extract(sys.argv[1], sys.argv[2])
except ValueError as e:
    sys.exit(str(e))

والآن نشغل الثلاثة على نفس الأرشيف، ثم نجرب السكربت الآمن على أرشيف فيه 200 MB من الأصفار، وعلى أرشيف المشروع العادي:

cd /zlab
python3 make_evil.py
unzip -l evil.zip
echo "### naive"
mkdir -p out-naive && python3 naive_extract.py evil.zip out-naive && cat /tmp/zip-slip-proof.txt
echo "### unzip"
mkdir -p out-unzip && unzip -o evil.zip -d out-unzip; echo "exit code: $?"
echo "### safe"
mkdir -p out-safe && python3 safe_extract.py evil.zip out-safe; echo "exit code: $?"
echo "### big"
head -c 200000000 /dev/zero > zeros.bin && zip -q -9 big.zip zeros.bin && rm zeros.bin
ls -l big.zip | awk '{print $5}'; unzip -l big.zip | tail -1
mkdir -p out-big && python3 safe_extract.py big.zip out-big; echo "exit code: $?"
mkdir -p out-project && python3 safe_extract.py project.zip out-project; echo "exit code: $?"

والمخرج سوف يكون كما يلي:

Archive:  evil.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
       14  2026-10-09 14:31   docs/readme.txt
       53  2026-10-09 14:31   ../../tmp/zip-slip-proof.txt
---------                     -------
       67                     2 files
### naive
wrote /zlab/out-naive/docs/readme.txt
wrote /tmp/zip-slip-proof.txt
this file should never be written outside the folder
### unzip
Archive:  evil.zip
 extracting: out-unzip/docs/readme.txt
warning:  skipped "../" path component(s) in ../../tmp/zip-slip-proof.txt
 extracting: out-unzip/tmp/zip-slip-proof.txt
exit code: 1
### safe
refused: '../../tmp/zip-slip-proof.txt' would be written to /tmp/zip-slip-proof.txt
exit code: 1
### big
194271
200000000                     1 file
refused: more than 52,428,800 bytes after 'zeros.bin'
exit code: 1
ok: 1505 entries, 5,774,754 bytes extracted to /zlab/out-project
exit code: 0

في المخرج أعلاه لاحظ التالي:

  • unzip -l عرض الاسم الخطر كما هو، فأي برنامج يقرأ القائمة يرى ../../tmp/ قبل أن يفك أي شيء، ولهذا فالفحص قبل الكتابة ممكن دائماً.
  • الكود البسيط كتب الملف في /tmp/zip-slip-proof.txt خارج المجلد تماماً، ولو كان الاسم ../../etc/cron.d/job وكان البرنامج يعمل بصلاحيات root لكان هذا تنفيذاً للكود.
  • أداة unzip من Info-ZIP حذفت ../ وكتبت الملف داخل المجلد مع تحذير ورمز خروج 1، وكذلك تفعل extractall في الوحدة zipfile في Python، فالأدوات الجاهزة تحمي نفسها، ولكنها لا تخبر تطبيقك أن الملف كان مشبوهاً، ولذلك نفضل الرفض الصريح.
  • السكربت الآمن رفض قبل أن يكتب أي ملف، لأن الفحص كله في المرور الأول، ثم رفض أرشيف الأصفار بعد 50 MiB مع أن الملف نفسه 194 KB فقط، وقبل أرشيف المشروع العادي كاملاً.
  • عند رفض الأرشيف في منتصف الكتابة، مثل حالة الأصفار، يبقى في المجلد ما كتب قبل التوقف، فاجعل الفك في مجلد مؤقت تحذفه كاملاً عند أي رفض، ولا تفك مباشرة في مكان يقرأ منه التطبيق.
🛑
لا تفك أبداً أرشيفاً رفعه مستخدم ببرنامج يعمل بصلاحيات root أو بصلاحيات تسمح له بالكتابة خارج مجلد مخصص، ولا تعتمد على فحص الامتداد وحده. شغل الفك بمستخدم محدود الصلاحيات، في مجلد مؤقت، مع حد للحجم وعدد الملفات والوقت، ثم انقل النتيجة بعد التحقق منها.

مكتبة واحدة تقرأ كل الصيغ

أخيراً، إذا احتجت أن يقرأ تطبيقك صيغاً كثيرة فلا تكتب قارئاً لكل صيغة، فمكتبة libarchive التي يقوم عليها الأمر bsdtar تقرأ ZIP و7z وtar بكل أنواع الضغط من نفس الواجهة:

for f in small/demo.zip project.7z project.tar.zst; do printf "%-18s %s entries, first: %s\n" $f $(bsdtar -tf $f | wc -l) $(bsdtar -tf $f | head -1); done
small/demo.zip     2 entries, first: hello.txt
project.7z         1505 entries, first: project/
project.tar.zst    1505 entries, first: project/

حذف التجربة

exit
docker rm -f lab-compress-tools

أي صيغة تستخدم؟

الحالةالصيغةوالسبب
ملف ترسله لأي شخصZIPيفتح في Windows وmacOS وLinux دون أي برنامج إضافي
ملف سري ترسله لشخص7z مع AES-256 و-mhe=onيشفر الأسماء أيضاً، وأرسل كلمة المرور من قناة أخرى
نسخ مجلدات على سيرفرات Linuxtar مع zstdيحفظ الصلاحيات والمالك، وسريع في الاستعادة
أرشيف كبير تحفظه طويلاًtar.xz أو tar.zst -19 أو 7zأصغر حجم، والضغط يحدث مرة واحدة
آلاف الملفات الصغيرة المتشابهةأي صيغة متصلة SolidZIP كان أكبر بثلاث مرات ونصف في تجربتنا
RAR وصلك من غيركفكه بـ 7-Zip أو unar أو bsdtarلا تحتاج WinRAR للفك، ولا تنشئ RAR لنفسك

أخطاء شائعة

  1. الاعتماد على كلمة مرور ZIP العادية: ZipCrypto ضعيف، والأسماء مكشوفة حتى مع AES.
  2. دمج اسم المدخل مع المجلد مباشرة: هذا هو Zip Slip، فاحسب المسار الحقيقي وارفض ما يخرج.
  3. الوثوق بالأحجام في الرؤوس: عد البايتات أثناء الكتابة، فالرؤوس يكتبها من صنع الملف.
  4. نسيان الروابط الرمزية: اسم بلا ../ قد يكتب خارج المجلد عبر رابط سابق في نفس الأرشيف.
  5. ZIP لآلاف الملفات الصغيرة: صيغة متصلة أصغر بكثير.
  6. أرشيف متصل واحد ضخم لكل شيء: ملف فاسد واحد قد يضيع ما بعده، فقسمه إلى كتل أو استخدم أداة نسخ احتياطي حقيقية.
  7. إنشاء أرشيفاتك بـ RAR: تقيد نفسك بأداة مدفوعة واحدة دون سبب.

تدريب عملي

  • افتح ملف docx أو xlsx من جهازك بـ zipinfo، وابحث عن التوقيع PK 03 04 في أوله بـ xxd، ثم اقرأ سجل النهاية في آخره.
  • أضف إلى safe_extract.py حداً لنسبة الضغط لكل ملف، مثلاً 100 إلى 1، وجربه على big.zip.
  • كرر مقارنة الصيغ على مجلد حقيقي عندك، مثل مجلد node_modules أو مجلد صور، وقارن الفرق بين المتصل وغير المتصل.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • الأرشفة تجمع الملفات والضغط يصغرها، وtar مع gzip أو zstd يضغط السلسلة كاملة، بينما ZIP يضغط كل ملف وحده ويضع قائمة الملفات في آخره.
  • ملف ZIP يقرأ من آخره: سجل النهاية PK 05 06 يشير إلى الدليل المركزي PK 01 02، والدليل يشير إلى رأس كل ملف PK 03 04، وCRC-32 تكشف الفساد العرضي فقط، وZIP64 يرفع حد 4 GB و65,535 ملفاً.
  • ZipCrypto ضعيف بهجوم معروف منذ 1994، وAES-256 من WinZip يحمي المحتوى دون الأسماء، و7z مع -mhe=on يحمي الاثنين.
  • RAR صيغة مملوكة، وفكها مسموح بأدوات مفتوحة، أما إنشاؤها فبرنامج RARLAB فقط، و7z مفتوحة وتعطي أحجاماً صغيرة.
  • الأرشيف من مستخدم مدخل غير موثوق: افحص كل اسم، وارفض الروابط الرمزية، وحدد عدد الملفات، وعد البايتات أثناء الكتابة، وفك بصلاحيات محدودة في مجلد مؤقت.

وفي نفس المرحلة شرحنا ما قبل هذا المقال وما بجانبه:

دليلكيف يعمل ضغط البيانات Compression؟ من Huffman إلى Zstdلماذا تصغر ملفات السجلات إلى العشر ولا تصغر الصور أبداً، وكيف تختار أداة الضغط المناسبة بين السرعة والحجم.دليلصيغ الصور: كيف تختار الصيغة المناسبة لموقعككيف تحفظ كل صيغة صور البكسلات والألوان، ولماذا يصغر الملف نفسه إلى الثلث أو أقل عندما تختار الصيغة والمقاس المناسبين.دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Info-ZIP Zip 3.0 وUnZip 6.00 و7-Zip 25.01 وlibarchive 3.7.4 وGNU tar 1.35 وzstd 1.5.7 وPython 3.13.16 في Debian 13.7، ومواصفة PKWARE APPNOTE 6.3.10.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.