DOCKER::ROOT الإقليم IV · caps · seccomp · LSM · عمق الأمان kernel live
REGION IV البُعد الرابع the escape · /privileged

الصلاحية: ماذا يُسمح للعملية أن تفعل؟

أعمق الأبعاد أمنياً. نفكّك أخطر وهمٍ في عالم الحاويات: أن «root داخل الكونتينر» شيءٌ واحد. نفكّك كلمة root إلى ذرّاتها، نرى كيف تُبنى الثقة طبقةً فوق طبقة، ثم نهدمها بأنفسنا لنفهم لماذا بُنيت.

النبذة

namespaces + cgroups + overlay تجعل العملية معزولةً وملجومة. لكنها قد تكون root (UID 0)، والـ kernel واحدٌ مشترك بين المضيف وكل الكونتينرات — لا hypervisor يفصل. فإذا كان root في الكونتينر = root على الـ kernel، فالعزل وهمٌ تكسره أوّل ثغرة. السؤال المصيري: كيف نسمح بـ«root وظيفي» داخل الكونتينر دون أن يكون root حقيقياً على المضيف؟ الجواب أربع طبقات دفاعٍ مستقلة: capabilities · user namespaces · seccomp · LSM.

اللغز المستفزّ — كن root، ثم اكتشف كم أنت عاجز

الجزء أ — قِس جبروتك المزعوم:

bash alpine sh — أنت uid=0
# hostname newname # تغيير اسم المضيف — ينجح؟ # date -s "2020-01-01" # ضبط الساعة — ينجح؟ # mount -t tmpfs none /mnt # تركيب fs عشوائي # mknod /dev/sda1 b 8 1 # إنشاء عقدة قرص المضيف # modprobe somemodule # تحميل وحدة kernel

سجّل أيّها فشل بـ Operation not permitted. أنت root — فلماذا تُمنع؟ إن كان root يقدر على كل شيء، فمن الذي يقول «لا» لـ root؟

الجزء ب — الجريمة (في VM قابل للتدمير فقط):

bash
$ docker run --rm -it --privileged alpine sh

الآن اخرج إلى المضيف. اقرأ ملفاً من نظام ملفات المضيف الحقيقي. لا ثغرة kernel — فقط ما منحك إياه --privileged.

سؤالان يقودانك

ما الفرق بالضبط بين العادي و--privileged؟ (تلميح: --privileged لا يضيف علامةً سحرية، بل يُسقط الدفاعات الأربع دفعةً واحدة.) وإذا استطعت mknod لعقدة قرص المضيف وأنت root كامل... فما الذي يمنعك من قراءة القرص خاماً؟

حين تنجح في القراءة من قرص المضيف، ستفهم في عظامك لماذا كل دفاعٍ سنشرحه موجود. الهروب يعلّم أكثر من ألف تحذير.

الدرس — تفكيك root، وطبقات الدفاع الأربع

ليش وُجدت capabilities: تفكيك «root»

في يونكس التقليدي الصلاحية ثنائية: UID 0 يتخطّى كل الفحوص، وغيره خاضعٌ لها. المشكلة: أبسط مهمة تتطلّب صلاحيةً واحدة (ping يحتاج raw socket) تُجبرك على منح كل الصلاحيات. مبدأ الامتياز الأدنى مستحيل.

ليش — حلّ Linux

فكّك صلاحيات root إلى ~٤٠ قدرة (capability) مستقلة، كلٌّ تحرس فئةً من العمليات. الآن تمنح ما يلزم فقط.

capabilityتمنحلماذا تهمّ
CAP_NET_BIND_SERVICEالربط على منافذ < 1024خادم على :80 بلا root كامل
CAP_NET_RAWraw/packet socketsping؛ لكن تتيح انتحال/تنصّت الحزم
CAP_SYS_ADMINmount، setns، pivot_root...«root الجديد» — أخطرها، سلّة مهملات
CAP_SYS_MODULEتحميل وحدات kernel= سيطرةٌ كاملة على الـ kernel ⇒ المضيف
CAP_MKNODإنشاء عقد أجهزةبها تصنع قرص المضيف وتقرأه (جريمتك)
CAP_DAC_OVERRIDEتجاوز أذونات الملفاتقراءة/كتابة أي ملف

الآن المفارقة تنحلّ: حين شغّلت alpine عادياً كنت «root» لكن Docker أسقط معظم القدرات (يحتفظ بقائمةٍ بيضاء ~١٤). لهذا hostname و date -s وmknod فشلت — أنت root بلا أنياب. و--privileged خطير لأنه يعيد كل القدرات، ومنها CAP_MKNOD وCAP_SYS_ADMIN — وبهما تمّت جريمتك.

bash اكتشف بنفسك
$ cat /proc/$$/status | grep Cap # CapEff/CapPrm/CapInh/CapBnd (hex) $ capsh --decode=00000000a80425fb # فُكّ الخريطة، قارن مضيف vs كونتينر

ليش الـ user namespace هو الكيستون (الحصاد من الإقليم I)

القدرات تقلّل الضرر، لكن العملية ما زالت «UID 0 حقيقي» على المضيف. الحلّ الجذري: user namespace يفصل UID الداخلي عن الخارجي.

داخل الكونتينر: uid=0 (root كامل في رؤيته) على المضيف: uid=100000 (مستخدم عادي بلا ملكية حسّاسة) /proc/<pid>/uid_map: 0 100000 65536 # inside outside count → UID 0 الداخلي يساوي 100000 الخارجي
ليش — الأثر العميق

العملية تملك كل القدرات داخل الـ user namespace، لكنها لا تعمل إلا على موارد يملكها الـ namespace نفسه. حين تلمس موردَ المضيف يفشل الفحص لأن UID الخارجي 100000 لا يملكه. «root» صار سلطاناً كاملاً داخل مملكته الورقية، صفراً خارجها. ومنه تُشتقّ rootless containers كلها (هو الـ namespace الوحيد الذي ينشئه غير root).

تعقيدات rootless (ولماذا هو صعب): subuid/subgid (من أين تأتي الـ 65536 معرّفاً؟ من /etc/subuidالشبكة بلا root (تحتاج slirp4netns user-mode، أبطأ)، cgroups بلا root (تفويض عبر systemd delegation — التحم خيطا I و II هنا).

ليش seccomp: تضييق سطح الـ syscalls

سطح الهجوم الحقيقي على الـ kernel المشترك هو الـ syscalls (~٣٥٠). كثيرٌ منها لا يحتاجه تطبيقك (keyctl, ptrace...) لكنّ أيّها قد يحوي ثغرةً تكسر العزل. seccomp يركّب مرشّح BPF: قرارٌ لكل syscall (اسمح/ارفض/اقتل). Docker يحمّل ملفاً افتراضياً يسمح بـ ~٣٠٠ ويحجب ~٤٠ خطيرة.

ترتيب الدفاعات على كل syscall

يُفحص أولاً seccomp (هل النداء مسموحٌ أصلاً؟)، ثم داخل الـ kernel capability المطلوبة، ثم LSM. ثلاث بوّاباتٍ مستقلة على التسلسل؛ تخطّي واحدة لا يكفي.

ليش LSM (AppArmor / SELinux): التحكم الإلزامي

القدرات وseccomp تتحكّمان في نوع العملية. الـ LSM يضيف بُعداً مختلفاً: تحكّمٌ إلزامي (MAC) في أي مورد تُطبَّق عليه — «اقرأ /etc/... فقط، اكتب /var/log/... فقط». الفرق عن أذونات الملفات (DAC): DAC يملكها صاحب الملف؛ MAC سياسةٌ مركزية لا يتجاوزها حتى root بسهولة.

الصورة المجمّعة — لماذا كان الهروب ممكناً

الكونتينر العادي محميٌّ بأربع بوّابات: قدراتٌ منزوعة، (اختيارياً) user ns، مرشّح seccomp، سياسة LSM. --privileged يفتح الأربع دفعةً واحدة: يعيد كل القدرات (CAP_MKNOD, CAP_SYS_ADMIN)، يلغي seccomp، يخفّف LSM، يكشف الأجهزة. فصار mknod قرص المضيف ثم قراءته ممكناً. الهروب لم يكن ثغرة؛ كان النتيجة المنطقية لإسقاط كل الدفاعات.

اللغز — حصّن كونتينرك اليدوي، ثم اكسر تحصينك

  1. القدرات: أسقط كل القدرات إلا ما يلزم بـ capsh أو prctl(PR_CAPBSET_DROP). أثبت أن mknod صار يفشل بعد أن كان ينجح.
  2. user namespace: اجعل id بداخله يقول uid=0 بينما من المضيف uid=100000+. أثبت أن ملفاً يملكه root الحقيقي لا يمكن لكونتينرك الكتابة فيه. أصعب نقطة — اقرأ man 7 user_namespaces بعناية (متطلبات /etc/subuid وترتيب الخرائط).
  3. seccomp: اكتب C يركّب مرشّحاً يحجب نداءً واحداً (chmod بـ EPERM)، شغّل تحته شِلّاً، أثبت أن chmod يفشل والبقية تعمل. استعمل libseccomp أو اكتب BPF خاماً بـ prctl(PR_SET_SECCOMP, ...).
  4. الكسر التعليمي: أزل دفاعاً واحداً كل مرة، أعد محاولة «جريمةٍ» مناسبة، ووثّق أي دفاعٍ كان يصدّ أي فعل. هذه الخريطة هي ما يجعلك واثقاً من كل --security-opt.
المصادر الدقيقة

man 7 capabilities، man 7 user_namespaces (أقسام «mappings» و«Capabilities»)، man 2 seccomp. ولفهم ملف Docker اقرأ moby/profiles/seccomp/default.json — انظر كيف يُبنى من defaultAction: SCMP_ACT_ERRNO ثم قائمة سماح.

الخلاصة — العزل صار له أنياب وحُرّاس

capabilities — أي فئات عمليات root يُسمح بها أصلاً (تفكيك root) user ns — هل "root" حقيقي أم وهمي معزول (الكيستون) seccomp — أي syscalls يُسمح باستدعائها (تضييق السطح) LSM — على أي موارد بعينها تُطبَّق (تحكّم إلزامي)

اكتمل النموذج الكامل للعزل: ماذا ترى (I) · كم تستهلك (II) · مِمّ تتكوّن (III) · ماذا تفعل (IV). كل علامات أمان Docker (--cap-drop, --security-opt, --userns-remap, --privileged, --read-only) صارت شفّافةً تماماً.

بذرة الغموض

كل ما بنيناه فعلناه بأوامرٍ يدوية متفرّقة: unshare، mount، كتابة cgroups، إسقاط قدرات، مرشّح seccomp. هشٌّ ومتعب. Docker يفعل هذا بأمرٍ واحد. كيف يُنظَّم هذا الكمّ؟ ولماذا يحتاج Docker أربعة برامج daemon متراكبة لا واحداً؟ أنت الآن تملك كل اللبنات التي يستعملها runc.