الصلاحية: ماذا يُسمح للعملية أن تفعل؟
أعمق الأبعاد أمنياً. نفكّك أخطر وهمٍ في عالم الحاويات: أن «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) تُجبرك
على منح كل الصلاحيات. مبدأ الامتياز الأدنى مستحيل.
فكّك صلاحيات root إلى ~٤٠ قدرة (capability) مستقلة، كلٌّ تحرس فئةً من العمليات. الآن تمنح ما يلزم فقط.
| capability | تمنح | لماذا تهمّ |
|---|---|---|
CAP_NET_BIND_SERVICE | الربط على منافذ < 1024 | خادم على :80 بلا root كامل |
CAP_NET_RAW | raw/packet sockets | ping؛ لكن تتيح انتحال/تنصّت الحزم |
CAP_SYS_ADMIN | mount، 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 الداخلي عن الخارجي.
العملية تملك كل القدرات داخل الـ 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 يحمّل ملفاً افتراضياً يسمح بـ ~٣٠٠ ويحجب ~٤٠ خطيرة.
يُفحص أولاً seccomp (هل النداء مسموحٌ أصلاً؟)، ثم داخل الـ kernel capability المطلوبة، ثم LSM. ثلاث بوّاباتٍ مستقلة على التسلسل؛ تخطّي واحدة لا يكفي.
ليش LSM (AppArmor / SELinux): التحكم الإلزامي
القدرات وseccomp تتحكّمان في نوع العملية. الـ LSM يضيف بُعداً
مختلفاً: تحكّمٌ إلزامي (MAC) في أي مورد تُطبَّق عليه — «اقرأ /etc/...
فقط، اكتب /var/log/... فقط». الفرق عن أذونات الملفات (DAC): DAC يملكها صاحب
الملف؛ MAC سياسةٌ مركزية لا يتجاوزها حتى root بسهولة.
- AppArmor (Ubuntu): سياساتٌ على المسارات؛
docker-defaultيمنع الكتابة في/proc/sysحتى لو ملكت القدرة. - SELinux (RHEL): وسومٌ وأنواع؛ يَسِم ملفات الكونتينر بنوعٍ (
container_t) يمنع الوصول لملفات المضيف. هذا يفسّر علامة:z/:Zعلى الـ volumes (تُعيد الوسم) — اشتقاق.
الكونتينر العادي محميٌّ بأربع بوّابات: قدراتٌ منزوعة، (اختيارياً) user ns، مرشّح
seccomp، سياسة LSM. --privileged يفتح الأربع دفعةً واحدة:
يعيد كل القدرات (CAP_MKNOD, CAP_SYS_ADMIN)، يلغي seccomp،
يخفّف LSM، يكشف الأجهزة. فصار mknod قرص المضيف ثم قراءته ممكناً. الهروب
لم يكن ثغرة؛ كان النتيجة المنطقية لإسقاط كل الدفاعات.
اللغز — حصّن كونتينرك اليدوي، ثم اكسر تحصينك
- القدرات: أسقط كل القدرات إلا ما يلزم بـ
capshأوprctl(PR_CAPBSET_DROP). أثبت أنmknodصار يفشل بعد أن كان ينجح. - user namespace: اجعل
idبداخله يقولuid=0بينما من المضيفuid=100000+. أثبت أن ملفاً يملكه root الحقيقي لا يمكن لكونتينرك الكتابة فيه. أصعب نقطة — اقرأman 7 user_namespacesبعناية (متطلبات/etc/subuidوترتيب الخرائط). - seccomp: اكتب C يركّب مرشّحاً يحجب نداءً واحداً (
chmodبـEPERM)، شغّل تحته شِلّاً، أثبت أنchmodيفشل والبقية تعمل. استعملlibseccompأو اكتب BPF خاماً بـprctl(PR_SET_SECCOMP, ...). - الكسر التعليمي: أزل دفاعاً واحداً كل مرة، أعد محاولة «جريمةٍ» مناسبة، ووثّق أي دفاعٍ كان يصدّ أي فعل. هذه الخريطة هي ما يجعلك واثقاً من كل
--security-opt.
man 7 capabilities، man 7 user_namespaces (أقسام «mappings» و«Capabilities»)، man 2 seccomp. ولفهم ملف Docker اقرأ moby/profiles/seccomp/default.json — انظر كيف يُبنى من defaultAction: SCMP_ACT_ERRNO ثم قائمة سماح.
الخلاصة — العزل صار له أنياب وحُرّاس
اكتمل النموذج الكامل للعزل: ماذا ترى (I) · كم تستهلك (II) · مِمّ تتكوّن (III) · ماذا تفعل (IV). كل علامات أمان Docker (--cap-drop, --security-opt, --userns-remap, --privileged, --read-only) صارت شفّافةً تماماً.
كل ما بنيناه فعلناه بأوامرٍ يدوية متفرّقة: unshare، mount،
كتابة cgroups، إسقاط قدرات، مرشّح seccomp. هشٌّ ومتعب. Docker يفعل هذا بأمرٍ واحد.
كيف يُنظَّم هذا الكمّ؟ ولماذا يحتاج Docker أربعة برامج daemon متراكبة لا
واحداً؟ أنت الآن تملك كل اللبنات التي يستعملها runc.