
CVE-2026-31413: ثغرة في صحة مدقق BPF - هروب من الحاوية
لقد وجدت خطأ في سلامة مدقق BPF في لينكس - حيث أن + 1 في استدعاء push_stack() يتسبب في تخطي المدقق لتعليمة ALU على مسار متفرع. بالنسبة لـ BPF_OR، هذا يعني أن المدقق يتتبع dst = 0 بينما تقوم وحدة المعالجة المركزية بحساب 0 | K = K. لقد كتبت هروبًا كاملاً من الحاوية: قراءة/كتابة خارج الحدود من خريطة BPF، اختطاف الجدول الافتراضي، الكتابة فوق modprobe_path، والحصول على صلاحيات الجذر على المضيف. ثم قمت بتأليف سلسلة من تصحيحين - إصلاح بحرف واحد في المدقق و90 سطرًا من اختبارات الذاتية - وتم دمجها في النواة الرئيسية.
📹 فيديو توضيحي للهروب من الحاوية
| CVE | CVE-2026-31413 |
| تصنيف الخطأ | Verifier soundness - register value divergence |
| السبب الجذري | push_stack(env, env->insn_idx + 1, ...) يتخطى تعليمة ALU على المسار المتفرع |
| مُدخَل في | bffacdb80b93 - Linux 7.0-rc1 (14 يناير 2026) |
| مُصلَح في | c845894ebd6f - Linux 7.0-rc5 (22 مارس 2026) |
| المتأثرة | 6.12.75+ (التراجع المستقر dea9989a3f) حتى 7.0-rc4 |
| التأثير | قراءة/كتابة عشوائية في النواة → هروب من الحاوية → جذر المضيف |
| المطلوب | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| الإصلاح | حرف واحد: insn_idx + 1 → insn_idx |
maybe_fork_scalars() تفرع حالة المدقق عندما ترى ARSH + AND/OR مع ثابت. المسار المدفوع يحصل على dst = 0 ويتخطى تعليمة ALU. بالنسبة لـ AND ذلك جيد: 0 & K = 0. بالنسبة لـ OR فهو خطأ: 0 | K = K وليس 0.
يعتقد المدقق أن السجل هو صفر. وحدة المعالجة المركزية لديها K. استخدمت ذلك لبناء قراءة/كتابة عشوائية خارج الحدود من قيمة خريطة BPF، وتسريب عنوان النواة للخريطة، وبناء جدول افتراضي bpf_map_ops مزيف، وإعادة توجيه map_push_elem عبر array_map_get_next_key لكتابة عشوائية، والكتابة فوق modprobe_path. يؤدي تشغيل تنسيق ثنائي غير معروف إلى تشغيل النواة لنصي البرمجي بصلاحيات الجذر. في حاوية، هروب كامل من المضيف.
سلسلة من تصحيحين: إصلاح بحرف واحد في المدقق بالإضافة إلى 90 سطرًا من اختبارات BPF الذاتية تغطي تفرع OR مقابل AND. تم دمجها بواسطة Alexei Starovoitov في 22 مارس. تم تعيين CVE-2026-31413 بواسطة Greg Kroah-Hartman في 12 أبريل.
يتيح لك eBPF تحميل برامج صغيرة إلى النواة - مرشحات الحزم، خطافات التتبع، سياسات الأمان - دون تجميع وحدة نواة. المشكلة هي أنك تحقن كودًا في ring 0. إذا كان لذلك الكود خطأ، فهو خطأ في النواة.
لذلك قبل تشغيل أي برنامج BPF، المدقق الخاص بالنواة يحاكي كل مسار تنفيذ ممكن. يتتبع ما يحمله كل سجل (مؤشر؟ سكالر؟ أي نطاق؟)، ويفحص كل وصول للذاكرة مقابل حدود الخريطة، ويرفض أي شيء يمكن أن يقرأ أو يكتب خارج الحدود. إذا قال المدقق أن البرنامج آمن، يقوم JIT بتجميعه إلى كود آلة أصلي وتشغيله بصلاحية كاملة للنواة. لا توجد فحوصات حدود وقت التشغيل بعد تلك النقطة. المدقق هو الحدود الأمنية.
لهذا السبب تختلف أخطاء سلامة المدقق عن الإفساد العادي للذاكرة. مع فيضان الكومة أو UAF، تحصل على بدائية إفساد واحدة ويجب أن تعمل من هناك - رش الكومة، تهيئة الكائنات، سباق نافذة. مع خطأ في المدقق، تجعل النواة تعتقد كذبة حول قيمة سجل. كل فحص حدود يعتمد على ذلك السجل يمر. توافق النواة على وصول OOB الخاص بك. تقوم بتشغيله دون سؤال. إذا تمكنت من محاذاة حالة السجل بشكل صحيح، تحصل على بدائية نظيفة وموثوقة منه.
كنت أتدقق في maybe_fork_scalars() - كود جديد، تمت إضافته في يناير 2026 في bffacdb80b93. تفرع الحالة مثير للاهتمام دائمًا لأنه حيث ينقسم المدقق إلى مسارات استكشاف متوازية، وإذا تتبع أي مسار قيمة غير صحيحة، فإن كل ما يتبع ذلك المسار يكون غير سليم.
تتفرع الدالة عندما ترى ARSH + AND/OR مع مصدر ثابت. المسار المدفوع يحصل على dst = 0 ويتخطى تعليمة ALU. كنت أقرأ السطر push_stack(env, env->insn_idx + 1, ...) وفهمت على الفور - أن + 1 يعني أن المسار المدفوع لا ينفذ أبدًا عملية ALU. بالنسبة لـ AND، 0 & K = 0، لذا التخطي جيد. بالنسبة لـ OR، 0 | K = K. يعتقد المسار المدفوع أن النتيجة هي 0 بينما هي في الواقع K.
كتبت برنامج BPF في تلك الأمسية. ARSH 63 للحصول على {0, -1}، OR مع ثابت، فرع شرطي لفصل مسارات المدقق، ثم إضافة السجل "صفر" إلى مؤشر الخريطة. وافق المدقق على map_value + 0. وصلت وحدة المعالجة المركزية إلى map_value + K. أكد KASAN الوصول خارج الحدود في الاختبار.
قراءة/كتابة OOB بحلول الصباح التالي. هروب من الحاوية بحلول الليلة التالية. استخدمت Claude (Opus 4.5) طوال الوقت - للعمل على منطق تفرع حالة المدقق، والعصف الذهني لبدائيات الاستغلال، وتحويل OOB إلى سلسلة هروب كاملة. جاء نهج اختطاف الجدول الافتراضي من تبادل حيث استعرض Claude مؤشرات دالة bpf_map_ops بحثًا عن أدوات قابلة للاستدعاء.
الالتزام bffacdb80b93 ("bpf: التعرف على الإزاحة الحسابية الخاصة في المدقق") تم إدراجه في 14 يناير 2026 في 7.0-rc1. بواسطة Alexei Starovoitov، وبمشاركة Puranjay Mohan. أضاف maybe_fork_scalars() للتعامل مع نمط LLVM DAGCombiner:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
يقوم LLVM بتخفيض `select_cc setlt X, 0, A, 0` إلى `sra + and`. بعد التحول الحسابي لليمين، يكون السجل إما `0` (إذا كانت المدخلات غير سالبة) أو `-1` (كلها أعداد واحد). يؤدي AND مع ثابت إلى `0` أو `K`.
لا يستطيع المُدقق تتبع `{0, K}` في `bpf_reg_state` واحد - نطاقه المُوقَّع `[0, K]` يُقرب بشكل زائد، وكان ذلك يتسبب في رفض برامج Cilium الصالحة. الإصلاح: تفرع حالة المُدقق. مسار واحد يستكشف `dst = 0`، والآخر `dst = -1`، كل منهما يتتبع القيمة الدقيقة.
التنفيذ:```c
static int maybe_fork_scalars(struct bpf_verifier_env *env,
struct bpf_insn *insn,
struct bpf_reg_state *dst_reg)
{
// ... condition check: dst range is [-1, 0], src is constant ...
branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
// ^^^^^^^^^^^^
// pushed path resumes AFTER the ALU insn
if (IS_ERR(branch))
return PTR_ERR(branch);
regs = branch->frame[branch->curframe]->regs;
__mark_reg_known(®s[insn->dst_reg], 0); // pushed: dst = 0
__mark_reg_known(dst_reg, -1ull); // current: dst = -1
return 0;
}
حدثان يحدثان في المسار المدفوع:
0insn_idx + 1 - التعليمات بعد عملية ALUبالنسبة لـ BPF_AND: dst = 0، تخطى عملية AND. وقت التشغيل: 0 & K = 0. متطابق. سليم.
بالنسبة لـ BPF_OR: dst = 0، تخطى عملية OR. وقت التشغيل: 0 | K = K. عدم تطابق.
يرى المُحقق 0. لدى الـ CPU K. غير سليم.
لا تتحقق الدالة من opcode. تمت كتابتها لـ AND - حيث تخطي التعليمات هو نفس تنفيذها مع dst = 0 - وتم تطبيقها على
OR أيضًا. بالنسبة لـ OR، هذا التكافؤ لا ينطبق.
نمط التحفيز هو خمس تعليمات:``` r6 = (u64)(map_value + 0) // load a positive value (guaranteed by map init) r6 s>>= 63 // arithmetic shift: r6 = 0 (positive input) r6 |= K // BUG: verifier forks, pushed path gets r6=0 if r6 s< 0 goto exit // steers verifier paths r9 += r6 // verifier: r9 += 0 (in-bounds) // runtime: r9 += K (OOB)
يتحقق المُتحقّق من مسارين:
**المسار الحالي** (`dst = -1`): يتم تنفيذ OR، `-1 | K` يبقى `-1`. يُتَّخذ الفرع `r6 s< 0`. يتابع المُتحقّق الخروج. هذا المسار آمن ويؤكّده المُتحقّق.
**المسار المُدفوع** (`dst = 0`، تم تخطّي OR): `r6 = 0`. لا يُتَّخذ الفرع `r6 s< 0`. يمر المُتحقّق إلى `r9 += r6`، ويرى `r9 += 0`، ويوافق على الوصول اللاحق للذاكرة باعتباره ضمن النطاق.
**وقت التشغيل** (`dst = 0`، يتم تنفيذ OR): قيمة الخريطة موجبة، لذا بعد ARSH، `r6 = 0`. يتم تنفيذ OR: `0 | K = K`. لا يُتَّخذ الفرع `K s< 0` (K موجبة). `r9 += K` — وصول خارج النطاق بمقدار `K` بايت، وافق عليه المُتحقّق باعتبار `r9 += 0`.
أنا أتحكّم في `K`. قراءة أو كتابة OOB بإزاحة اختيارية، نسبةً لأي قيمة خريطة BPF.
تخزّن نسخة القراءة البيانات المُسرَّبة في خريطة ثانية لاسترجاعها من فضاء المستخدم. تحمّل نسخة الكتابة قيمة من خريطة ثالثة وتكتبها في الإزاحة خارج النطاق. كلاهما يجتاز المُتحقّق.