Seccomp-bpf للوكلاء المستقلين
المرة الأولى التي حذف فيها أحد وكلائنا شيئاً لم يكن ينبغي له حذفه كانت يوم ثلاثاء عند الساعة 21:47. أتذكر التوقيت لأن تنبيه امتلاء القرص وصل تماماً وأنا أهمّ بإطفاء الشاشة. كان الوكيل في خضمّ سلسلة استدلال حول تحدّي CTF لرفع الامتيازات، وقد قرّر أن أفضل طريقة "لتنظيف الآثار المؤقتة" هي تنفيذ find / -name "*.tmp" -delete. لم يمسّ شيئاً حرجاً لأن الحاوية كانت معزولة، لكنه جعل صورة الأساس التي كنّا نستخدمها لأدوات الفَزّ غير صالحة. اضطررنا إلى إعادة بنائها صباح اليوم التالي.
في تلك الليلة كتبت في الدفتر جملة لا تزال ملتصقة على الشاشة: "لا يفهم نموذج لغوي كبير ما يعنيه أن يكون الأمر لا رجعة فيه". من هناك وُلد نموذج العزل (sandboxing) الذي يستخدمه اليوم Gwaihir CLI، محرّك التنفيذ لدينا. والقطعة التي كلّفنا ضبطها أكبر جهد كانت seccomp-bpf.
لماذا seccomp-bpf وليس شيئاً آخر
السؤال المعقول هو: إذا كانت Docker وgVisor وFirecracker وKata موجودة، فلماذا نتورّط في كتابة مرشّحات BPF يدوياً؟ الجواب القصير هو أن أيّاً منها لم يكن مناسباً لتدفّق عمل وكيل يطلق أدوات هجومية على دفعات.
يشارك Docker في وضع runc النواةَ مع المضيف، وملف تعريف seccomp الافتراضي الخاص به يسمح بمرور أكثر من 300 syscall. هذا مقبول لخدمة ويب، لكن ليس لشيء يقرّر في وقت التشغيل استدعاء ptrace أو kexec_load. أعطانا Firecracker (Agache et al., 2020) microVM لكل وكيل، لكن تكلفة إقلاع VM لكل استدعاء لـnmap -sS كسرت ميزانية الكمون لدينا: 125 مللي ثانية مقابل 4 مللي ثانية لـfork+exec مع تطبيق seccomp.
كان gVisor المرشّح الأقوى. يعترض Sentry خاصته نداءات النظام في فضاء المستخدم ويقلّل بشكل هائل من مساحة سطح النواة. لكن دراسة Young et al. في HotCloud 19 قاست ما أكدناه لاحقاً على مقاعد اختبارنا: فتح الملفات أبطأ بـ216 مرة، وnداءات النظام أبطأ بـ2.2 مرة. حين يطلق وكيل ffuf بـ200 خيط ضد نقطة نهاية، يظهر هذا الإهدار بوضوح.
أما seccomp-bpf فهو شبه مجاني. يُترجم المرشّح إلى BPF، ويعيش في واصف العملية، وتكلفة كل syscall في حدود عشرات النانوثوانٍ. والأهم: نحن نتحكّم به، قاعدةً قاعدة. وكما تقول وثائق النواة بحق، "هو ليس صندوقاً رملياً بحدّ ذاته، بل أداة ليستخدمها مطوّرو الصناديق الرملية"[1]. هذا بالضبط ما كنّا نحتاج إليه.
ملفات تعريف مصغّرة لكل أداة
النموذج لكل أداة، لا لكل وكيل. حين يقرّر Gwaihir تنفيذ nmap، فإنه لا يحمّل ملف تعريف sqlmap. كل ثنائي يمكن للوكيل استدعاؤه له ملفه الخاص .scprofile الذي يصف الحدّ الأدنى من نداءات النظام المرصودة في تتبّع بـstrace -c، موسَّعاً بعدة جولات من الاستخدام الحقيقي.
عملية البناء مملّة، ولهذا تنجح. نشغّل الأداة ضد هدف مختبري بينما يعمل perf trace، نجمع قائمة syscalls، نقارنها بالوثائق، ونتخلّص من كل ما هو خطر بشكل واضح حتى وإن ظهر (مثلاً، لا ينبغي لـnmap أبداً أن يحتاج إلى unshare أو mount). ما يبقى هو القاعدة المرجعية.
بالنسبة لـnmap، في فحص TCP SYN نموذجي، تتسع القائمة في صفحة واحدة. بالنسبة لـsqlmap فهي أطول لأن مفسّر Python يطلب أشياء أكثر. أما بالنسبة لـffuf المكتوب بـGo، فهناك مفاجآت: يستدعي وقت تشغيل Go نداءَي rt_sigaction وmmap بكميات صناعية، ونسيان أحدها يعلّق العملية دون تشخيص واضح.
بروتوكول التفاوض مع Sentinel
هنا تصبح الأمور مثيرة للاهتمام بالنسبة للوكلاء. ملف التعريف الستاتيكي لا يكفي. إذا قرّر النموذج اللغوي الكبير في منتصف التنفيذ أنه يحتاج إلى حلّ DNS لإثراء اكتشاف ما، فسيستدعي socket(AF_NETLINK, ...)، وهو غير موجود في ملف nmap. بدون آلية إضافية، تستقبل العملية SIGSYS فتموت.
ما فعلناه هو وضع قناة إشعار seccomp (SECCOMP_RET_USER_NOTIF، متاحة منذ النواة 5.0[2]) بين العملية الابن ومشرف نسمّيه Sentinel. حين يصادف المرشّح syscall موسوماً بأنه "قابل للتفاوض"، يوقف العملية ويرسل الطلب إلى Sentinel. يقيّم Sentinel ذلك بناءً على سياسة تصريحية، ويستشير النموذج اختيارياً بمحرّض قصير من نوع "هل هذا connect() إلى 10.10.0.0/8 مبرّر بالمهمة الحالية؟"، ويعيد المتابعة أو الإحباط.
إنه نمط شبيه بـAgentBound (Securing AI Agent Execution, 2025)، لكن على مستوى النواة بدلاً من MCP. الفارق المهم أن الوكيل لا يستطيع تجاوز Sentinel: يُحمَّل مرشّح BPF مع NO_NEW_PRIVS قبل execve، بحيث لا يستطيع حتى ثنائي خبيث التراجع عنه.
مرشّح حقيقي
هذا مقتطف مبسّط من الملف الذي نستخدمه لـnmap، مكتوب بـlibseccomp في Rust (يزن وقت تشغيل Gwaihir 13.2 ميغابايت، وهو مصنوع من خليط من Rust للمنطق وZig لـالمسارات الساخنة الخاصة بالتتبّع).
use libseccomp::*;
fn build_nmap_profile() -> Result {
// Acción por defecto: SIGSYS y traza al supervisor.
let mut ctx = ScmpFilterContext::new(ScmpAction::Trap)?;
// I/O básico
for sc in &["read", "write", "close", "fstat", "lseek",
"openat", "pread64", "pwrite64"] {
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
}
// Memoria
for sc in &["mmap", "munmap", "mprotect", "brk", "madvise"] {
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
}
// Red: solo lo que nmap necesita para SYN scan
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("socket")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("setsockopt")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sendto")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("recvfrom")?)?;
// Negociables: el agente puede pedirlas vía Sentinel.
ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("connect")?)?;
ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("execve")?)?;
// Prohibidas explícitamente.
for sc in &["ptrace", "kexec_load", "init_module",
"delete_module", "reboot", "mount", "unshare"] {
ctx.add_rule(ScmpAction::KillProcess, ScmpSyscall::from_name(sc)?)?;
}
ctx.set_filter_attr(ScmpFilterAttr::CtlNnp, 1)?;
Ok(ctx)
}
ثلاث تفاصيل مهمة. أولاً، الإجراء الافتراضي هو SCMP_ACT_TRAP، لا SCMP_ACT_KILL. السبب تشخيصي: نريد لمعالج SIGSYS أن يلتقط syscall ويسجّله قبل أن تموت العملية. ثانياً، نداءات النظام القابلة للتفاوض تستخدم SCMP_ACT_NOTIFY، وليس ALLOW؛ وهذا ما يفتح القناة مع Sentinel. ثالثاً، النداءات المحظورة تحمل KILL_PROCESS صريحاً حتى وإن كانت مغطّاة بالإجراء الافتراضي، لأننا نريد لذلك القرار أن يكون مقروءاً في تدقيق مستقبلي.
مقايضات تعلّمناها بالصدمات
الأولى هي قابلية النقل. تتغيّر أرقام syscalls بين المعماريات (x86_64، aarch64، riscv64)، وlibseccomp تجرّد ذلك بشكل جيد إلى حدّ بعيد، لكن هناك حالات حافّة. socketcall المتعدّد على 32 بت، arch_prctl الذي لا يوجد إلا على x86، وstatx الذي ظهر في نوى حديثة. نحتفظ بمصفوفة اختبارات حسب المعمارية.
الثانية هي التصحيح. حين يقتل ملف تعريف عملية بسبب syscall لم يكن في القائمة، الرسالة التي تراها هي Bad system call (core dumped). لا شيء أكثر. اضطررنا إلى كتابة مغلِّف يحلّل siginfo_t الخاص بـSIGSYS، يبلّغ عن رقم syscall، يقارنه بالجدول، ويعرض شيئاً مقروءاً. أول مرة رأينا فيها "statx blocked by profile nmap.scprofile, suggest adding to baseline" بدلاً من core dump، كدنا نبكي من الراحة.
الثالثة هي الإيجابيات الكاذبة. حين تتحدّث libc وتبدأ باستخدام clone3 بدلاً من clone، تنكسر جميع ملفات تعريفك دفعةً واحدة. تعلّمنا ذلك حين دخلت glibc 2.34 إحدى صور الأساس. لدينا الآن مهمة أسبوعية تعيد توليد القواعد المرجعية وتحذّرنا إذا كان الفارق كبيراً بشكل مريب.
الرابعة، والأكثر مفاجأة، هي حمل التشغيل للمرشّحات الكبيرة جداً. يُنفّذ BPF عند كل syscall، ومرشّح بمئات القواعد المتتالية يظهر أثره. قسنا 3-4% غرامة على أعباء عمل كثيفة بنداءات النظام مع ملف من 180 قاعدة، مقابل 0.8% مع ملف من 40. إعادة ترتيب القواعد بحيث تُقيَّم نداءات النظام المتكرّرة أولاً استرجعت معظم ذلك الوقت.
ما كان سيوفّر علينا وقتاً لو علمناه باكراً
ثلاثة أشياء. الأول: ابدؤوا بـSCMP_ACT_LOG بدلاً من TRAP أثناء تطوير الملف. تسجّلون كل شيء، تشغّلون الأداة على أعباء حقيقية يوماً كاملاً، ثم تبنون القائمة. نحن سلكنا الطريق العكسي وأضعنا أسابيع نلاحق الانهيارات.
الثاني: إشعار المستخدم (USER_NOTIF) هو ما يحوّل seccomp إلى شيء مفيد للوكلاء. بدونه، ملف التعريف الستاتيكي مشدّ. ومعه، تحصل على سياسة ديناميكية دون التضحية بفصل الامتيازات.
الثالث: قيسوا التكلفة الحقيقية من اليوم الأول. اختبارات الأداء المنشورة مفيدة، لكن أعباء عملكم غريبة. وكيل يستدعي أداة قصيرة 50 مرة في الثانية له ملف تكلفة مختلف تماماً عن خادم طويل العمر.
لا يحمي المرشّح من كل شيء. يحمي من الغباء. وقد تبيّن أن الغباء هو 90% مما يخطئ فيه نموذج لغوي كبير حين تطلقه بحرّيّة.
اليوم يشغّل Gwaihir ثغرات يقترحها النماذج دون أن يراقب أيٌّ منّا الشاشة منتظراً كارثة. ليس لأننا نثق بالنموذج، بل لأننا نثق بالمرشّح. وذلك، بعد ثلاثاء 21:47، أمر يغيّر الحياة.
References
- Linux Kernel Documentation (2024). Seccomp BPF (SECure COMPuting with filters).
- Drysdale, M. y Corbet, J. (2024). seccomp(2) - Linux manual page, man7.org.
- Young, E. et al. (2019). The True Cost of Containing: A gVisor Case Study. USENIX HotCloud 19.
- Agache, A. et al. (2020). Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI 20.
- seccomp/libseccomp (2024). libseccomp: Enhanced Linux seccomp library.