Skip to content
• 28 دقيقة للقراءة

SHA-256/SHA-384 Type Confusion OOB Write Explained: حين يكذب struct على عمره يُصدِّقه الكيرنل

من 15 بايت خارج حدودها إلى root كامل وهروب من chroot jail: تشريح تقني لسلسلة استغلال Linux kernel تبدأ بالتباس أحجام بين SHA-256 وSHA-384 داخل وحدة cryptography مخصصة، وتمر عبر MSG_COPY وpipe_buffer وstruct page، وتنتهي بكتابة data-only داخل cred ثم مبادلة task->fs بـ init_fs.

#linux-kernel #exploit-development #heap-exploitation #type-confusion #privilege-escalation
SHA-256/SHA-384 Type Confusion OOB Write Explained: حين يكذب struct على عمره يُصدِّقه الكيرنل hero illustration
تسجيل صوتي للمقال
قراءة آلية
0:00 / --:--

تخيّل موظف أرشيف في مؤسسة عريقة، من النوع الذي يحترم الأنظمة حدّ الملل. صناديق الأرشيف كلها موحّدة الحجم، وفي كل صندوق بطاقة ببيانات، وفي أسفل كل بطاقة خانة صغيرة اسمها “عدد الأسطر”. مهمة موظفنا بسيطة إلى درجة الإهانة: انسخ بطاقة داخل بطاقة أخرى، وعدد الأسطر الذي ستنسخه هو الرقم المكتوب في خانة العدّ تلك. هو لا يفحص حجم البطاقة، ولا يسأل عن مصدرها، هو فقط يقرأ الرقم ويبدأ النسخ.

في يوم من الأيام وصلته بطاقة من قسم آخر، قسم يستعمل بطاقات أكبر، 48 سطراً بدل 32. الخانة التي يظنها “عدد الأسطر” تقع في بطاقته الصغيرة عند السطر 32 بالضبط، وهي في بطاقات القسم الآخر ليست خانة أصلاً، بل جزء من المحتوى نفسه. فالموظف يقرأ “عدد أسطر” هو في الحقيقة أرقام من بيانات البطاقة الكبيرة، أرقام لم يضعها أحد هناك لكي تكون عدّاداً، لكنها ستصير عدّاداً لمجرد أنه قرأها كذلك.

يبدأ النسخ، وكل شيء يبدو طبيعياً حتى السطر 32. هنا تحدث اللحظة التي تستحق قراءة المقال كله: القلم يكتب فوق خانة العدّ ذاتها. العدّاد لم يعد عدّاداً، صار قيمةً كتبها الموظف بيده هو. ومن السطر التالي فهو لا ينسخ في بطاقته أبداً، بل يقفز إلى البطاقة المجاورة في الصندوق التالي، ويكتب عند “العنوان” الذي حددته له بيانات البطاقة الأصلية.

ومن الذي اختار بيانات البطاقة الأصلية؟ أنت. لم تخترها مباشرة طبعاً، لكنك شاركت في يانصيب المحتوى آلاف المرات حتى خرجت الأرقام التي تريدها في المواضع التي تريدها. اليانصيب هنا رخيص ووفي، وسنرى بعد قليل أنه يدفع فوراً.

الآن الجزء الذي يحوّل موظفاً متعباً إلى حكاية أمنية. ماذا لو كانت البطاقة المجاورة هي بطاقة هويتك داخل المؤسسة، تلك التي تحدد من أنت وما الذي يحق لك؟ وماذا لو كانت المؤسسة قد حبستك في جناح واحد من المبنى، والخريطة التي في جيبك مرسومة بحيث لا تؤدي إلا إلى ذلك الجناح؟ بايتات قليلة مكتوبة في غير مكانها، وروح موظف مطيع، تكفيان ليتحول “موظف مؤقت” إلى “المدير العام”، ثم لتحويل الخريطة ذاتها إلى خريطة المبنى كله.

هذه ليست استعارة مفتعلة، هذه حرفياً ما حدث مع تغيير الأسماء فقط: الموظف اسمه copy_hash، والبطاقات هي structs تعيش في kernel space، وبطاقة الهوية اسمها cred، والخريطة اسمها fs_struct. والقصة كلها وقعت في بيئة اختبار افتراضية معزولة تحت سيطرتنا الكاملة، حول وحدة kernel مكتوبة بعناية، وهذا أسوأ ما فيها.

خريطة الرحلة

قبل الغوص، هذه هي السلسلة من أول بايت إلى آخر qword، لتعود إليها كلما شعرت أنك ضِلت:

  1. OOB write مضبوط: 15 بايت تُكتب خارج الـ buffer عند offset نختاره (نسميه B)، داخل object مجاور نختاره نحن أيضاً.
  2. Heap grooming بـ msg_msg: رسائل System V IPC موسومة تحتل الـ slab المجاور، وE2BIG oracle يخبرنا أين سقطت طلقتنا بالضبط.
  3. m_ts := 255 ثم MSG_COPY: قراءة بطول 255 بايت تمر من أمام HARDENED_USERCOPY كأنه موظف جمارك في مكالمة هاتفية.
  4. pipe_buffer spray: أنابيب مصغّرة تحل في الجوار، ومؤشر ops الذي تملؤه الـ kernel بنفسها يكسر KASLR.
  5. kread24: كتابة msg->next بايتاً بايتاً تمنح قراءة عشوائية، 24 بايت من أي عنوان في الـ kernel.
  6. سلسلة قراءات: __per_cpu_offset ثم current_task ثم cred، حتى نقف على عنوان هويتنا نحن.
  7. تزوير pipe_buffer كاملة: page وops وoffset/len وflags=CAN_MERGE، فيتحول write() واحد إلى كتابة فيزيائية عشوائية في أي صفحة RAM.
  8. الكتابة داخل cred: 72 بايت تجعل uid يساوي صفراً وتملأ كل الـ capabilities. بلا ROP ولا stack pivot ولا gadget واحد. هذا يسمى data-only، وهو أرستقراطية الاستغلال.
  9. ثمانية بايت أخيرة: task->fs := init_fs، فتنفتح أبواب نظام الملفات الحقيقي خارج الـ jail.

السيناريو: وحدة kernel تحمل كلمات المرور

وحدة kernel صغيرة، كتبها أحدهم ليدير بيانات مصادقة المستخدمين داخل kernel space بدلاً من userspace. الفكرة سمعناها من قبل في أماكن كثيرة: اجعل التحقق قريباً من “الحقيقة”، أي من الـ kernel نفسها، حتى لا يعبث به أحد. وما الذي يمكن أن يسوء عندما نضع هياكل بأحجام متغيرة في أعلى امتيازات النظام؟ سؤال بلاغي طبعاً، والإجابة عليه ستشغل بقية هذا المقال.

الوحدة تقدّم misc device نتعامل معه عبر ioctl، بواجهة معقولة الشكل من أربع عمليات: تسجيل مستخدم جديد، وحذفه، وتحديث كلمة المرور، واسترجاع الكلمة القديمة. لكل مستخدم نسختان من الـ hash: نسخة curr هي العاملة، ونسخة old احتياطية يُفترض أن تنقذ الموقف إذا فشل التحديث. وتدعم الوحدة خوارزميتين: SHA-256 وSHA-384.

وهنا بالضبط النقطة التي يتمنى أي reviewer صادق أن يراها قبل أن يراها attacker جائع: الخوارزميتان تُخزنان في نفس نوع الـ buffer، والتفريق بينهما يتم بحقل type يعيش في struct آخر بعيد، والنسخ بين النسخ يعتمد على دالة تقرأ “طول” المصدر من داخل بيانات المصدر نفسها. إذا لم ترتجف يدك الآن فمراجعة جدول أعمالك، فأنت لم تكتب C بما يكفي.

هياكل البيانات، بأسماء عامة كما هي في كل شيء في هذا المقال:

typedef struct {
    u8     digest[32];
    size_t len;                 /* عند offset 32 */
} sha256_hash_t;

typedef struct {
    u8     digest[48];
    size_t len;                 /* عند offset 48 */
} sha384_hash_t;

/* جوهر المشكلة: */
static void copy_sha256_hash(sha256_hash_t *dst, sha256_hash_t *src)
{
    size_t n = min_t(size_t, src->len, HASH_MAX);
    int i;

    memset(dst, 0, sizeof(*dst));
    for (i = 0; i < n; i++)
        dst->digest[dst->len++] = src->digest[i];   /* <== الخلل يسكن هنا */
}

الخلل: عندما يلتقي SHA-256 بـ SHA-384 في struct واحد

الدالة أعلاه تبدو بريئة إلى درجة تثير الشفقة: انسخ n بايت من مصدر إلى وجهة، بأسلوب “بايت بايت” من عصر ما قبل memcpy. لكن فيها مشكلتين متراكبتين، وكل واحدة منهما تكفي وحدها ليوم سيئ:

المشكلة الأولى أن src->len يُقرأ من offset 32 في buffer المصدر. إذا كان المصدر يحتوي hash من نوع SHA-384 (48 بايت) فإن البايتات 32 إلى 39 هي قلب الـ digest نفسه، فتُقرأ بيانات عشوائية وتُفسَّر كطول. الرقم الناتج هائل عموماً، لكن min_t(..., HASH_MAX) يقصّه إلى 48 دائماً. لاحظ السخرية هنا: آلية “التحقق” من الطول تقص أي قيمة إلى الحد الأقصى المسموح، أي أنها تضمن للـ attacker أن الـ loop سيمشي دوراته الثماني والأربعين كاملة مهما كان الرقم الأصلي. الحارس نفسه يفتح الباب على مصراعيه ويوقّع بالنيابة عنك.

المشكلة الثانية، وهي القاتلة، أن الفهرس والعدّاد في dst->digest[dst->len++] هما نفس الحقل. طالما بقيت len أقل من 32 فالدنيا بخير. لكن عند i=32 بالضبط تسقط الكتابة في العنوان dst+32، وهو عنوان حقل len ذاته. العدّاد الذي كان يقيس النسخ يصبح نتيجةً من نتائج النسخ، مثل ساعة تُضبط بقراءة رسالةٍ تصل متأخرة.

(ملاحظة جانبية لعشاق دقة المترجمات: الترتيب بين post-increment والتخزين حين يكتبان في العنوان نفسه مسألة code generation. في الـ binary المترجم أمامنا يسقط التخزين المسمّى أخيراً، فيصبح العدّاد قيمة البايت المكتوب. هذا ليس فوضى undefined behavior بمعناها الشعبي، إنه سلوك ثابت في binary ثابت، وكل ما سنبنيه بعدها مبني على هذه الثابتة.)

تابع الآن مسار الـ loop بعد أن أكل العدّاد نفسه، حيث D هو الـ digest الموجود فعلياً في المصدر:

الدورة iما الذي يحدث فعلاً
0 إلى 31نسخ عادي إلى digest[0..31]، والعدّاد يزحف حتى 32
32الكتابة تسقط فوق حقل len نفسه، فيصبح len = D[32]، ونسمّيه الرمز B
33 إلى 47الكتابة تسقط عند digest[B] حتى digest[B+14]، أي البايتات D[33..47]

والخلاصة العملية التي تستحق أن تُكتب بخط عريض على السبورة:

15 بايت (هي D[33..47])، عند offset نتحكم فيه (هو B = D[32])، داخل buffer طُلب بحجم 48 بايت، في slot من فئة kmalloc-64.

B بايت واحد، أي يستطيع أن يأخذ أي قيمة من 0 إلى 255. ولكي تخرج الكتابة من slot الـ hash وتصل إلى الجار في الـ slab نريد B قريباً من 64 أو أكبر. وهذا هو layout الحي الذي نهدفه:

kmalloc-64 slot (the hash)          kmalloc-64 slot (the neighbor)
+-----------------------------+     +------------------------------+
| digest[0..31]               |     | neighbor+0  .. +15           |
| digest[32..47] (size view!) |     | neighbor+16 .. +23           |
| (rest of the 64B slot)      |     | neighbor+24 .. +63           |
+-----------------------------+     +------------------------------+
hash+0                               hash+64
        shot at B=74 writes D[33..47] at hash+74 .. hash+88
        (that is neighbor+10 .. neighbor+24)

لاحظ ماذا تفعل طلقة B=74 على جارٍ من نوع رسالة IPC (سنرى بعد قليل لماذا رسائل تحديداً): نافذة hash+74 إلى hash+88 تلامس أجزاءً من حقلي m_list.prev وm_type بالكامل، ثم تنتهي بالضبط على البايت الصفري لحقل m_ts. آخر بايت في النافذة هو البايت الوحيد الذي نضبطه تماماً في الـ brute force، ووقوعه على m_ts بتحديد هو ما سيموّل أول leak لنا. الاختيار هنا ليس ذوقاً جمالياً، هو هندسة.

من يوقظ الخلل؟ سيرة ملصقٍ كاذب

حتى الآن الحديث عن “لو فُسّر buffer فيه SHA-384 على أنه SHA-256”. السؤال العملي: كيف نجعل الوحدة تفعل ذلك بيد رغيفٍ واحد؟ الجواب في دورة التحديث الفاشلة، واقرأها ببطء لأنها أجمل جزء في الثغرة:

  1. نسجّل مستخدماً جديداً من نوع SHA-256 بكلمة مرور P1. الآن curr يحتوي SHA-256 نظيفاً، وold نسخة مطابقة، والملصقان صادقان.
  2. نطلب update ناجحة نحو SHA-384 بكلمة مرور P2. الوحدة تصادق، تنسخ curr إلى old (والنسخ هنا سليم لأن النوع حينها متطابق)، تحسب hash الجديد في curr، ثم تُحدّث الملصقات لأن كل شيء نجح.
  3. نطلب update ثانية بكلمة المرور الحالية الصحيحة، لكن بكلمة مرور جديدة فارغة. المصادقة تنجح، فتُنسخ محتوى curr (وهو SHA-384 كامل) إلى old. ثم يفشل حساب الـ hash الجديد لأن كلمة المرور فارغة مرفوضة، فتستعيد الوحدة curr من old وتعيد الخطأ. والملصقات؟ لا تُلمس أبداً، فتحديثها كان مشروطاً بنجاح الحساب.

النتيجة بعد الخطوة الثالثة: old يحتوي 48 بايت من محتوى SHA-384، بينما ملصقه ما زال يقول “أنا SHA-256” من الخطوة الثانية. محتوى ينتمي إلى عالم، وملصق ينتمي إلى عالم آخر.

  1. نستدعي restore لاسترجاع الكلمة القديمة. الوحدة تنسخ old إلى curr “بناء على نوع old”، أي تُدخل buffer من 48 بايت في دالة copy_sha256_hash التي ترى العالم بطول 32. الطلقة تنطلق.

أجمل ما في القصة أن العملية الفاشلة هي التي تفتح الباب. التحديث الناجح يُصلح الملصقات بعناية، والفاشل يترك الحقلين في عالمين مختلفين ثم يعتذر. أخطاء المعالجة (error paths) هي دائماً المكان الذي يرتاح فيه المبرمجون ويتسكع فيه الـ attackers.

طبخ كلمات المرور: brute force بلا مقابل

كل ما نحتاجه من كلمة المرور P2 أن يحقق sha384(P2) قيدين صغيرين على بايتات محددة: d[32] = B (عنوان القفزة) وd[47] = البايت المطلوب إسقاطه في نهاية النافذة. قيدان من ثمانية bits لكل منهما، أي 16 bits، أي حوالي 65,536 محاولة متوقعة قبل أن تسقط الطلقة المطلوبة. مع implementation يشتغل بمعدل مليونين إلى ثلاثة ملايين hash في الثانية على core واحد، هذا زمن أقصر من احتياجك لإعادة تشغيل المتصفح.

وهنا اعتراف صغير يستحق مكاناً في المقال، لأنه درس مستقل بذاته: أول implementation لـ SHA-384 حملناه معنا كان يحمل خمسة ثوابت round تالفة، منسوخة خطأً في زمنٍ غابر. الدالة كانت تعمل بلا خطأ واحد، بصمتٍ تام، وبنتائج خاطئة تماماً. وهذا أسوأ ما يستطيع hash أن يفعله في الحياة: أن يكذب عليك بثقة. أعدنا اشتقاق الثوابت الثمانين من التعريف الأول (floor(frac(cbrt(prime)) * 2^64)) وأعدنا الاختبار ضد متجهات مرجعية، وعاد اليانصيب يدفع. الدرس: عندما يفشل brute force معك رغم أن الحساب “صحيح”، راجع الثوابت قبل أن تراجع منطقك.

النسخة المصححة

الإصلاح أصغر من أن يستحق كل هذا الدم، وهذا ما يجعله مهيناً:

static void copy_hash(void *dst, const void *src, u32 type)
{
    size_t sz;

    switch (type) {
    case HASH_SHA256:  sz = SHA256_DIGEST_SIZE; break;   /* 32 */
    case HASH_SHA384:  sz = SHA384_DIGEST_SIZE; break;   /* 48 */
    default:           return;
    }

    memcpy(dst, src, sz);   /* الطول من النوع، لا من البيانات */
}

ثلاث قواعد تصرخ من داخل هذه الأسطر القليلة. الطول يجب أن يأتي من النوع المعلن، لا من حقل يعيش داخل البيانات نفسها، لأن البيانات التي تحمل عدّادها الخاص ليست بيانات بل مفخخة. والملصق والمحتوى يجب أن يتزامنا ذرياً في كل مسار، بما في ذلك مسارات الفشل تحديداً، لأن “أنا SHA-256” جملة يجب أن تكون صادقة دائماً أو لا تُقال أصلاً. والقاعدة الثالثة، وهي الأجمل مرارةً: البديل الصحيح كان memcpy منذ اليوم الأول، دالة موجودة منذ قبل أن يولد كاتب الوحدة، تؤدي المهمة بلا عدّاد يقرأ نفسه. الأبسط كان دائماً هو الأصح.

ترويض الـ heap: رسائل مبلّلة بـ 64 بايت

OOB write بلا سياق لا يساوي شيئاً: 15 بايت ستفني نفسها في أول object يصادفها، وستنام بلا أثر أو توقظ الـ kernel ضدها. نحن نريد أن تصطدم بجارٍ نعرفه بالاسم. والحل المعتاد في عالم kernel exploitation: اجعل الحي الذي تسكنه الوحدة حياً على طريقتك أنت.

المفتاح أن الجميع يسكن نفس العمارة. الـ hash الخاص بالوحدة يُطلب بـ kzalloc(48)، أي أنه يسكن في kmalloc-64. ورسالة System V IPC موضوعها 16 بايت من البيانات لها header اسمه msg_msg بحجم 48 بايت (m_list ثم m_type ثم m_ts ثم next ثم security)، فمجموعها 64 بايت بالضبط، أي أنها جارة في نفس الـ slab cache، kmalloc-64. وحتى pipe_buffer مفرد (سنحتاجه بعد قليل) حجمه 40 بايت، وهو أيضاً من فئة kmalloc-64. الحي متجانس، وأنت من يرتب ساكنيه.

الخطة: نرشّ مئات الرسائل الموسومة عبر طوابير IPC، كل رسالة تحمل علامة تعريف في m_type وفي بياناتها، حتى نعرف لاحقاً من أصبنا بالضبط. ثم نطلق دورات register وunregister متتالية: كل دورة تحرر الـ hash وتعيد اقتطاعه من الـ freelist، وSLUB يوزّع بأسلوب LIFO صارم (آخر slot تحرر هو أول slot يُمنح)، فمع كل دورة يهبط الـ hash في مقعد جديد. نكرر حتى يجلس بجوار إحدى رسائلنا، في مساحة slab مكتظة برسائلنا، احتمال ذلك ليس حظاً بل إحصاءً مملّاً يعمل لصالحنا.

ولكن كيف نعرف أن الطلقة سقطت بجانب رسالة أصلاً، وأي رسالة، وفي أي طابور؟ هنا يدخل أصغر أداة في المقال وأكثرها أناقة، الـ E2BIG oracle:

/* مسح غير متلف لكل الرسائل: مع MSG_COPY يتحول msgtyp إلى
 * رقم تسلسلي داخل الطابور، فنمر على الرسائل واحدة واحدة بلا استقبالها. */
errno = 0;
ssize_t r = msgrcv(q, &small, 16, idx, MSG_COPY | IPC_NOWAIT);

/* r == 16:  رسالة سليمة، نسخ عادي.
 * errno == E2BIG: m_ts أكبر من bufsz، أي وجدنا ضحيتنا. */

المنطق من طبقة الأولى: msgrcv مع buffer أصغر من حجم الرسالة ومن دون MSG_NOERROR يرد بـ E2BIG، والفحص يسبق فكّ الرسالة من القائمة، فتبقى الرسالة في مكانها لم تُمس. رسائلنا كلها بحجم 16، فأي رسالة “تعتذر بحجمها” هي حتماً من قابل الطلقة. أنت هنا لا تهاجم شيئاً، أنت تطرق الأبواب واحدة واحدة وتنتظر من يفتح وعيناه دامعتان.

الـ leak النظيف: سحر MSG_COPY

الآن الضحية معروفة، وm_ts فيها صار 255 بعد أن كان 16. الخطوة البديهية: استقبلها بـ buffer كبير واستمتع بقراءة 255 بايت (منها 239 خارج حدود الـ object). والخطوة البديهية هذه بالذات ستقتل الـ VM فوراً.

السبب أن الاستقبال العادي ينسخ من الـ slab مباشرة إلى userspace، وHARDENED_USERCOPY يقف على هذا الحرف بالذات: كل نسخة إلى userspace يجب أن تبقى داخل حدود slab object موثّق. 255 بايت من object حجمه 64؟ panic، oops، شاشة نصية حمراء في dmesg، وإعادة تشغيل. (نعم، جرّبناها مرة، وكان dmesg قاسياً بلا داعٍ.)

MSG_COPY تغيّر هندسة العملية كلها من جذورها. هذه الـ flag، الموجودة أساساً لخدمة checkpoint/restore، تجعل msgrcv لا يستلم الرسالة بل ينسخها: تُخصَّص رسالة جديدة بحجم bufsz (هنا 255)، ثم copy_msg تنسخ من الأصل إلى النسخة بـ memcpy من kernel إلى kernel (بلا أي فحص usercopy لأنه لا يوجد usercopy أصلاً)، ثم store_msg تنسخ النسخة السليمة الحجم إلى userspace. الفحص الجمركي يرى صندوقاً سليم الحجم، بينما البضاعة المهربة كانت قد وُضعت في الصندوق داخل المخزن نفسه قبل لحظة. بعبارة أدق وأقل مجازاً: HARDENED_USERCOPY يفحص المصدر الأخير للنسخة، وMSG_COPY جعلت المصدر الأخير object سليماً بحكم التصميم، والقراءة خارج الحدود حدثت في مرحلة kernel داخلية لا يراقبها أحد.

الشرط الوحيد أن m_ts الجديد (255) لا يتجاوز سعة النسخة، لأن copy_msg ترفض (EINVAL) أن يكون المصدر أكبر من الوجهة. وهنا تظهر حكمة B=74 مرة أخرى: طلقة عند B=80 مثلاً كانت ستكتب m_ts كاملاً بقيمة عشوائية ضخمة تتجاوز الحدود القصوى، فتتحول الرسالة إلى قنبلة غير قابلة للقراءة أبداً. أما B=74 فتمس البايت الصفري فقط، فتصبح m_ts = 0xFF، أي 255، قيمة أكبر من 16 بما يكفي لقراءة 239 بايت من الجوار، وأصغر من حدود النسخ بما يكفي لمرور الفحص. بايت واحد مضبوط، في الموضع المضبوط، ولأسباب مضبوطة.

وماذا يوجد في الجوار؟ هنا تكتمل الصورة: بعد أن عرفنا ضحيتنا نفرّغ الطوابير الأخرى بعناية جراحية (بلا لمس ضحيتنا ولا الرسالة التي تسبقها في القائمة، لسبب سنشرحه بعد فقرة)، فتتحرر slots، ثم نرشّ أنابيب مصغّرة: كل pipe() مع F_SETPIPE_SZ(4096) يقتطع ring بحجم slot واحد، أي pipe_buffer واحد من 40 بايت، أي kmalloc-64 آخر يحل في الفجوات. ثم نعيد قراءة الضحية بنفس MSG_COPY.

في نافذة الـ 255 بايت الممدّدة نرى الآن: بيانات رسائلنا، ومؤشرات m_list لرسائل حية (عناوين heap مباشرة)، وبين الحقول، أثمن ما في الصورة كلها: حقول pipe_buffer التي ملأتها الـ kernel بيدها، وفيها page (مؤشر إلى struct page في منطقة vmemmap) وops (مؤشر إلى جدول عمليات الأنابيب، كائن ثابت في .data نعرف إزاحته الدقيقة من الـ image أمامنا). ops ناقص الإزاحة المعروفة يساوي kbase. KASLR سقط رسمياً، ولم نطلق رصاصة usercopy واحدة في حربه.

وعد صغير وفّيناه: لماذا لا نلمس الرسالة التي تسبق الضحية في القائمة؟ لأن طلقة B=74 شوّشت m_list.prev للضحية (هذا ثمنها الجانبي)، وCONFIG_DEBUG_LIST يتحقق من سلامة روابط القائمة عند الفك، فإذا فككت الجار بأي استقبال عادي سيرفض التحقق الحذف، وستبقى عقدة موصولة ومحرّرة في آن واحد (free-while-linked)، ثم يستقبلها مسكين لاحق فيتبع مؤشر freelist مشفّراً (SLAB_FREELIST_HARDENED) كأنه next سليم، فيقع oops على بعد خطوات. شخّصنا هذا الكراش بالكامل داخل GDB قبل أن نفهمه، وهو من النوع الذي يعلّمك احترام القوائم المرتبطة إلى الأبد. MSG_COPY مرة أخرى هي الحل: هي لا تفكّ الرسالة من القائمة أبداً، لا في النجاح ولا في الفشل. رسالتنا الضحية تبقى في طابورها كقطعة أثاث، تُقرأ ولا تُلمس.

kread24: حين يصبح msg->next تذكرتك إلى أي عنوان

الـ leak أعطانا kbase وعناوين heap، لكنه leak سلبي: نقرأ ما يقع في الجوار لا ما نريد. الترقية المطلوبة هي arbitrary read: قراءة 24 بايت من أي عنوان kernel نختاره. والمفتاح هذه المرة ليس m_ts بل الحقل الذي بعده، msg->next.

الفكرة أن رسائل IPC الكبيرة (أكبر من 4048 بايت، وهي DATALEN_MSG، أي صفحة ناقص header) تُخزَّن كسلسلة segments: الرسالة الأولى تحمل 4048 بايت، والباقي يذهب إلى msg_msgseg جديدة يشير إليها الحقل next، وكل segment بياناته تبدأ بعد مؤشرها مباشرة. عندما تنسخ copy_msg رسالة بهذا الشكل فإنها تمشي على السلسلة وتنسخ من كل segment بقدر نصيبه، وكل ذلك بـ memcpy من kernel إلى kernel.

الآن رتّب القطع: نكتب في الضحية m_ts := 4072 (أي 4048 زائد 24) وnext := X حيث X عنوان نختاره. عند MSG_COPY بـ buffer كبير ستنسخ copy_msg الأربعين والأربعين والثمانية الأولى من بيانات الضحية (خارج الحدود، لكن داخلياً وبأمان)، ثم تمشي إلى “الـ segment” عند X وتنسخ 24 بايت من X+8. الـ segment مزيّف، لا يوجد أحد عنده، لكن الدالة لا تعرف ولا يهمها: هي تنسخ ما يقوله لها m_ts وnext. الشرط الوحيد أن أول qword عند X نفسه يجب أن يكون صفراً، لأن الدالة ستقرأه بوصفه ->next للـ segment المزيّف وينبغي أن تقف عنده بدل أن تمشي إلى عنوان عشوائي. لذلك قاعدتنا الدائمة: X يساوي دائماً “العنوان المطلوب ناقص 8”، ونحن نعرف مسبقاً (من الـ image أو من قراءة سابقة) أن الفجوة التي قبله صفر.

24 بايت ليست رقماً اعتباطياً: هي ثلاثة مؤشرات بالضبط، وهذا يكفي لقراءة ثلاثة أسطر من خريطة الـ kernel في طلقة واحدة. والطلقة الأولى تُطلَق على عنوان نعرفه من الـ image قبل أي leak إضافي، فتجمع في ضربة واحدة: vmemmap_base وvmalloc_base وpage_offset_base. المنطقتان الأولى والثالثة هما القلب: الـ kernel الحديث مع KASLR لا يخبّئ قاعدة الـ text فقط، بل يخبّئ قاعدة الـ direct map (حيث تسكن الذاكرة الفيزيائية بعناوينها الافتراضية) وقاعدة vmemmap (حيث تسكن صفحات وصف struct page لكل صفحة RAM في النظام). بدون هاتين القيمتين لا نستطيع تحويل أي عنوان افتراضي إلى صفحة فيزيائية لاحقاً، وكل مشروع الكتابة النهائية يتعطل عند هذه الجملة تحديداً.

ثم سلسلة الصيد، ثلاث قراءات متتابعة، كل واحدة تأكل رغيف الـ 24 بايت:

kread24(kbase + OFF_PER_CPU_OFFSET - 8)  -->  __per_cpu_offset[0]
kread24(per_cpu_off + 0x1ed00 - 8)       -->  current_task (نحن بالذات)
kread24(current_task + 0x698 - 8)        -->  cred, real_cred

القراءة الأولى تنزل من ثابت في .data إلى مصفوفة الـ per-CPU. الثانية تذهب إلى خانة current_task داخل منطقة الـ CPU المثبّت عليه (نعم، ثبّتنا العملية على CPU واحد منذ البداية بـ sched_setaffinity، لأن كل هذه الهندسة، من freelist إلى per-CPU، تتفكك إذا رقصت العملية بين المعالجات). الثالثة تفتح task_struct الخاص بنا وتخرج منه مؤشري cred وreal_cred، ونتحقق أنهما متطابقان (استقرار داخلي للموثوقية: إذا اختلف الاثنان فأنت تقرأ شيئاً آخر، لا task سليمة). الإزاحات 0x698 و0x6e0 وما جاورها خاصة بهذا البناء واشتققناها من التفكيك، والرقم الذي يهمك أنت: كل قراءة منها كانت 16 طلقة OOB متتالية، نعم، قراءة kernel واحدة كلفت ست عشرة كتابة خارج الحدود، وهذا حسابها:

الكتابة بايتاً بايتاً: حساب الـ accumulation

الـ OOB write يعطينا في كل طلقة بايتاً واحداً مضبوطاً (آخر بايت في النافذة، d[47]) والـ 14 بايت التي قبله هي collateral عشوائي يسقط قبله مباشرة. إذن لكتابة qword كامل (8 بايت) عند موضع معين نحتاج 8 طلقات، نكتب فيها البايتات من الأعلى إلى الأدنى، بحيث الـ collateral الدخيل لكل طلقة يسقط على البايتات التي سبق أن ضبطناها أو التي سنضبطها لاحقاً، فيُصلح نفسه بنفسه:

/* كتابة qword كامل عند hash+QOFF: ثماني طلقات، من b7 إلى b0 */
for (int b = 7; b >= 0; b--) {
    uint32_t B = QOFF - 14 + b;              /* d[47] يسقط على hash+QOFF+b */
    uint8_t  v = (val >> (8 * b)) & 0xff;
    fire_oob_shot(B, v);   /* brute: sha384 بكلمة سر حيث d[32]==B وd[47]==v */
}

لكن هناك مشكلة أعمق من البايتات، مشكلة الكرسي. كل طلقة OOB تمر بدورة حياة كاملة: unregister يحرر الـ hash الحالي، وregister جديد يقتطع hash جديداً من الـ freelist. ومن يضمن أن الـ hash الجديد سيعود إلى نفس الـ slot، أي نفس الكرسي المجاور لضحيتنا؟ لو تحرّك الكرسي طلقة واحدة لكانت الطلقة التالية كتابة على object لا نعرفه، ولدفعنا الثمن panic أو صمتاً أبدياً.

الحل أناقة بغيضة تُدعى دوران المستهلك: نرتب التحريرات بحيث يهبط slot الضحية-الجار على رأس الـ freelist في اللحظة المضبوطة، ويُقتطع جزء منه بتخصيص وسيط (رسالة “مستهلك” نرسلها ونستقبلها بدورة خاصة) يبتلع الـ slot الذي لا نريده، بينما يعود الـ hash إلى كرسيه المطلوب في كل دورة. ومعها صيانة مستمرة: pool من الرسائل نحرر منها واحدة كل دورة كي لا يفرغ الـ freelist أبداً (الفراغ يعني انتقال SLUB إلى slab جديدة، والعندئذ تتحرر أشياء في أماكن لا تصل إليها دورتنا، وينزلق الكرسي بصمت). وكل هذا مثبّت تحت المجهر: تتبعنا عنوان الـ hash عبر مئة ومتجاوزة استدعاء restore متتالية فوجدناه ثابتاً في مكانه كأنه موظف حكومي قبل ساعة الدوام. النظرية مملة والنتيجة حاسمة: هذه الانضباطية هي الفرق بين 18 كتابة متتالية على نفس الـ object و18 كتابة موزعة عشوائياً، أي الفرق بين exploit يعمل وkernel panic تُحرَّر في قاعة المشاهدة.

لماذا رمينا الـ ROP في البحر

القارئ المتمرس يتوقع الآن الفصل المعروف: hijack لمؤشر ops، جدول عمليات مزوّر، stack pivot، ثم ROP chain من commit_creds(&init_cred) إلى KPTI trampoline إلى iretq نعود منها إلى userspace بعمامة root. توقّع صحيح، وهذا ما خططنا له فعلاً، أسبوعاً كاملاً من العمل الجاد. ثم ماتت الخطة ثلاث مرات متتالية، وكل مرة لسبب مختلف، وهذا أفضل جزء في المقال كله، فاقرأه مرتين:

الموت الأول جاء من STATIC_USERMODEHELPER. تذكّر أسلحة الجدّات: اكتب مسار برنامجك في modprobe_path أو core_pattern، وستجد الـ kernel نفسها تستدعي برنامجك بامتيازات كاملة عند أول حدث غريب. هذه الأبواب كانت كلها مسمّرة في هذا البناء: الثابت static_usermodehelper_path مضبوط على نص فارغ، وcall_usermodehelper يهمل المسار المطلوب أصلاً ويقرأ الثابت ثم يرفض التنفيذ بـ EINVAL. الحيلة التي أطعمت أجيالاً من الـ exploits صارت هنا مجرد سطر في مذكرات الاعتزال.

الموت الثاني جاء من جدول العمليات المزوّر نفسه. فكرة أن تزرع fake ops table في .data قابلة للكتابة تُقتَل ببساطة لأن STRICT_KERNEL_RWX يجعل .data غير قابلة للتنفيذ. حسناً، لا مشكلة كبيرة، الـ ROP لا ينفّذ .data أصلاً، هو ينفّذ gadgets من .text عبر مؤشرات. لكن للوصول إلى أول gadget تحتاج stack pivot، وهنا أعدنا مسح .text مسحاً يشبه التنقيب في الرمال: epilogues بكل أذواقها، xchg، push/pop، indirect jumps بكل الـ base registers، وكل عائلات الخلاصة الشهيرة. لا يوجد pivot قابل للحياة. القبر الثاني.

الموت الثالث كان الأدقّ والأجمل علمياً، وجاء من فحص لا يتجاوز سطراً واحداً في pipe_write. عندما تحاول kernel دمج بيانات جديدة في buffer قديم، تتحقق أن offset + len + chars لا يتجاوز PAGE_SIZE. الحقلان offset وlen هما unsigned int في الـ struct، لكن الكود المترجم في هذا البناء يعمل sign-extension ثم مقارنة unsigned. أي عنوان من عناوين kernel (وهي تبدأ كلها بـ 0xffff…) موضوع في أحد الحقلين يتحول بعد sign-extension إلى رقم كوني، فتسقط المقارنة فوراً، ولا يصل التنفيذ إلى confirm أصلاً. بمعنى آخر: مجرد أن تضع gadget في الحقلين يمنعك من الوصول إلى الـ gadget. خنق ذاتي بمهارة قلّ نظيرها.

ثلاث طرق مسدودة، وكل واحدة كانت باباً كاملاً في عالم آخر. وهنا، بدل كتابة “ثم اكتشفنا الحل الذكي” كذباً أدبياً، سنقول الحقيقة: لم نكتشف شيئاً عبقرياً، فقط نظرنا إلى الأداة التي بين أيدينا منذ البداية وقرأناها من جديد. الكتابة التي كانت ستزرع الـ payload لـ ROP هي نفسها كتابة عشوائية في أي عنوان فيزيائي. من قال أصلاً إننا نحتاج تنفيذ كود؟

الهدف الأسمى: كتابة فيزيائية مباشرة داخل cred

هذه هي اللحظة التي يتحول فيها البحث كله من معركة إلى محاسبة. لا نريد hijack أي مؤشر، ولا نريد تنفيذ أي تعليمة، ولا نحتاج gadget واحداً. نريد فقط أن نكتب 72 بايتاً في مكان واحد صحيح: داخل cred الخاص بعمليتنا نحن. هذا يسمى data-only privilege escalation، وهو أرقى أنواع الاستغلال لأنه لا يترك الـ kernel تنفّذ شيئاً من اختيارنا إطلاقاً.

الآلة المستخدمة هي pipe_buffer نفسها التي سلبت بها KASLR، لكن هذه المرة من الجهة الأخرى: نحن من يكتب حقولها. تذكّر الـ struct:

struct pipe_buffer {          /* 40 bytes, kmalloc-64 */
    struct page *page;        /* +0   : صفحة RAM التي يشير إليها الـ buffer */
    unsigned int offset, len; /* +8   : موقع البيانات داخل الصفحة وطولها   */
    const struct pipe_buf_operations *ops;  /* +16 */
    unsigned int flags;       /* +24  : وهنا يسكن CAN_MERGE                */
    unsigned long private;    /* +32 */
};

وعند write() على أنبوب، إذا كان آخر buffer في الـ ring يحمل flag اسمه CAN_MERGE، فإن pipe_write يدمج البيانات الجديدة في نهاية بيانات الـ buffer القائمة، أي عند page + offset + len، مباشرة داخل الصفحة. هذا هو نسل Dirty Pipe الشهير (CVE-2022-0847): هناك كانت الـ flag تُفعَّل خطأً على صفحة page cache فيُكتب في ملفات للقراءة فقط. هنا الفكرة نفسها، لكن بعد أن نختار نحن الصفحة: نزوّر page لتشير إلى struct page لأي صفحة RAM نريدها، ونضبط offset وlen لتقع الكتابة في الموضع المطلوب داخلها، ونضع ops على الجدول القياسي للأنابيب (الذي لا يحتوي confirm، فلا اعتراض في المنتصف)، وflags على 0x58 حيث يقطن CAN_MERGE. ثم نكتب على الأنبوب write() عادية، متواضعة، بريئة المظهر. البيانات تسقط في الصفحة الفيزيائية التي اخترناها، ويمرّ كل هذا دون أن تُنفَّذ تعليمة واحدة من صنعنا.

الترتيب الكامل للمسرح، مختصراً بلا خيانة للجوهر:

أولاً، نجهّز “السلاح”: أنبوب بمخزن أحادي slot، نعمل له splice لبايت واحد من ملف وهمي من صنعنا (ملف في /tmp، لا يضر أحداً). الـ splice يمنح الـ pipe_buffer حقولاً حية تملأها الـ kernel بنفسها، وهذا يطمئن كل مسارات الكود لاحقاً. ثم نمثّل فرق البالية على الـ freelist: نحرر الجار المجاور لمقعد الـ hash المستقبلي، ونحرر حشوة، وunregister يفرغ مقعدي الـ hashين، ورسالة المستهلك تلتقط أحدهما، وregister يعيد الـ hash إلى الكرسي، فتقتطع مصفوفة الـ pipe_buffer المفردة (40 بايت، أي kmalloc-64) آخر slot متاح: الذي يقع بالضبط عند hash+64. من الآن فصاعداً، كل طلقة OOB من كرسي الـ hash هذه تتحرر لتكون جملة في دستور الـ pipe_buffer المجاور. (كلمتان تلخصان فلسفة SLUB: LIFO ومواعيد. لو فاتهما القارئ فليعد فقرة دوران المستهلك.)

ثانياً، الطلقة الأولى هي flags: طلقة واحدة عند B=88 تُسقط البايت 0x58 في حقل flags. وقبل أن نكمل نعمل pre-verify: نكتب بايتاً واحداً على الأنبوب. إذا كان التجنيد قد نجح، فالـ CAN_MERGE فاعل، والبايت يندمج في صفحة الملف الوهمي نفسها (بالغ البلاهة، يكتب في ملفنا نحن، لا يضر أحداً)، ويعيد write() القيمة 1. إذا فشل الـ pre-verify ننسحب فوراً، وخسارتنا طلقة واحدة من الـ collateral بدل عشرات الطلقات التي كانت في النسخ السابقة تحفر مقبرة في الـ heap ثم تنهار المحاولات فوقها. هذه القاعدة تحديداً (اختبر ببايت قبل أن تصب بالطن) تستحق أن تُعلّق فوق كل مكتب exploitation في العالم.

ثالثاً، الـ accumulation النهائي بترتيب مقصود: ops إلى جدول الأنابيب القياسي (8 طلقات)، ثم offset وlen بحيث يكون موضع الدمج هو بالضبط موضع الهدف داخل الصفحة (8 طلقات)، ثم page نفسها إلى struct page الخاصة بصفحة الهدف (8 طلقات). والترتيب تنازلي بالعناوين عن قصد، لأن الـ collateral العشوائي لكل qword يسقط في الأربعة عشر بايتاً التي قبله، فنرتب بحيث يسقط دائماً على حقول سنكتبها لاحقاً أو على ذيل الـ hash نفسه حيث لا يهمنا. وخلال هذا كله، قاعدة صارمة: لا read على الأنبوب (يستهلك الـ buffer ويدمّر التمويه)، ولا write قبل الـ payload (يزيح موضع الدمج). الـ payload write يجب أن يكون أول عملية حقيقية على السلاح منذ الـ splice.

وحساب العناوين، وهو أقصر مما تتوقع لأننا دفعنا ثمنه كاملاً في مرحلة kread24:

phys   = cred_addr - page_offset_base     /* العنوان الفعلي في الذاكرة الفيزيائية */
pfn    = phys >> 12                      /* رقم الصفحة الفيزيائية               */
inpage = phys & 0xfff                    /* موقع الهدف داخل الصفحة             */
page   = vmemmap_base + pfn * 64         /* sizeof(struct page) == 64 هنا      */

/* القيم التي نزرعها في pipe_buffer (بالـ OOB shots نفسها، لا من userspace): */
page   = vmemmap_base + pfn * 64
offset = inpage - 1 ;  len = 1           /* الدمج يسقط عند offset+len = inpage */
ops    = &anon_pipe_buf_ops
flags  = 0x58                            /* CAN_MERGE                         */

ثم الـ payload نفسه، 72 بايتاً تبدأ عند cred+0x14:

الحقول عند cred+القيمة الجديدة
0x14 إلى 0x30: uid وgid وسائر الأقارب الثمانيةأصفار، كلهم، بلا استثناء ولا وداع
0x34: securebitsصفر
0x38: cap_inheritableصفر
0x40: cap_permitted0x000001ffffffffff، مجموعة كاملة
0x48: cap_effective0x000001ffffffffff
0x50: cap_bset0x000001ffffffffff
0x58: cap_ambient (الجزء الذي يشمله الـ payload)صفر

لماذا نبدأ عند 0x14 تحديداً ولا نبدأ من أول الـ struct؟ لأن هذا البناء يفعّل CONFIG_DEBUG_CREDENTIALS، وعند 0x10 يعيش حقل magic تفحصه الـ kernel للتأكد من سلامة الـ cred. كتابة هذا الحقل كنا سنكسر الفحص الذي وُضع أصلاً لصيد أمثالنا. فبدأنا الكتابة بعده بأربعة بايتات، وبقيت الـ magic سليمة شاهدةً على أن كل شيء طبيعي، تماماً كما يترك اللص الباب المقفول وراءه. ودرس جانبي من نفس الحيّز: في نسخة أولى من الـ payload كانت الحقول تُكتب بمحاذاة qword افتراضية، فسقطت مجموعات الـ capabilities متأخرة أربعة بايتات عن مواضعها، وخرج CapEff ناقصاً، وتحديداً بلا CAP_MKNOD، فرفض mknod بأدبٍ قاتل (EPERM). أربعة بايتات. في kernel space، أربعة بايتات تفصل بين امتلاك العالم والوقوف أمام شباك لا يفتح.

ثم نكتب: write(weapon_fd, payload, 72). والـ kernel، بكل صدقها المشهود، تدمج الـ 72 بايتاً في الصفحة الفيزيائية التي وصفنا لها. لا festivity، لا crash، لا شيء. فقط جملة getuid() بعدها تعود بصفر، وهذه هي هيئة root في هذه اللغة. والحكم الوحيد المقبول هو getuid() نفسه، لأن write() قد “ينجح” حتى لو دمج في أنبوب عادي لم نلاحظ انزلاقه مكان السلاح، أما uid فهو لا يجامل أحداً.

وقبل أن ننتقل، كلمة للـ elephant في الغرفة: البيئة التي تجري فيها القصة كلها ترفع no_new_privs (NNP) وتشغّل seccomp. NNP يغلق باب الترقية عبر execve بأكمله: لا su، ولا SUID، ولا file capabilities، وهذا عمداً حتى تكون ثغرة منطقية واحدة في وحدة واحدة هي الطريق الوحيد. وseccomp يمنع syscall مثل mknod من الأساس. التصميم منطقي من زاوية صاحب البيئة: هو يحرس الأبواب المعروفة كلها. لكن الكتابة داخل kernel memory ليست باباً أصلاً، هي الجدار الذي صُنعت منه الأبواب كلها. في طريقنا الطويل إلى uid 0 لم نستخدم execve ولا مرة واحدة، ولم نطلب من النظام أن يرقّينا، نحن كتبنا الترقية بأيدينا في سجل النظام نفسه.

الهروب من السجن: ثمانية بايتات تغيّر خريطة العالم

الآن أنت root. الدم يهمس لك أنك ملك، وأن القصة انتهت. ثم تفتح عينيك على الحقيقة المزعجة الثانية: أنت root داخل زنزانة. العملية كلها تعيش في chroot jail محكم: جذر نظام الملفات من وجهة نظرها مجلد فرعي في مكان ما، وكل مسار ستحاول الوصول إليه ينحل داخل حدود ذلك المجلد، والخروج “الرسمي” من الجلسة يطفئ الآلة كلها عن قصد. uid 0 أجاب عن سؤال “من أنت؟” بامتياز، لكن يبقى سؤال لم يجب عنه أحد: “أين أنت؟”، وهذا السؤال يملكه حقل آخر تماماً.

كل task_struct تحمل مؤشراً اسمه fs يشير إلى fs_struct، وهذا الـ struct يحمل بدوره root وpwd، أي إجابة النظام الدائمة عن سؤال “من أين يبدأ العالم؟” في كل عملية مسار. الـ chroot لا يبني جدراناً حول عمليتك، هو ببساطة يعدّل نسختك أنت من هذه الإجابة. والـ kernel نفسها تحتفظ بنسخة أصيلة معلّقة عند الثوابت: init_fs، الـ fs_struct الخاص بالمهمة الأولى منذ ولادة النظام، وجذره هو الجذر الحقيقي لنظام الملفات بأكمله، الذي تُرى منه كل طبقات الحبس مجرد مجلدات عادية.

والخلاصة التي دفعنا ثمنها المقال كله: هذا المؤشر بيانات. ثمانية بايتات عند task+0x6e0. ونحن نملك آلة تكتب أي بايتات في أي عنوان فيزيائي، وقد أثبتنا للتو أنها تكتب بدقة أربعة بايتات (لم ننس درس الـ capabilities المؤلم). فأعدنا نفس المسرح من أوله: نفس الترويض، نفس الكرسي، نفس السلاح، نفس الانضباط في الطلقات، لكن الـ payload هذه المرة قيمة واحدة، عنوان init_fs (وهو kbase زائد إزاحة ثابتة نعرفها منذ كسر KASLR، أي بلا قراءة إضافية واحدة، لأن كل قراءة إضافية مخاطرة إضافية، والكتابة الأخيرة لم تكن تحتاج قراءة أصلاً). write() واحد بثمانية بايتات، عند task+0x6e0:

task->fs = init_fs

ثم يحدث أهدأ انقلاب في التاريخ: من أول استدعاء مسار بعده، تبدأ open وstat وreaddir تحل من الجذر الحقيقي. جهاز block القرص الرئيسي، الذي كان مستحيل الرؤية، يظهر كعقدة موجودة أصلاً في /dev الخارجية، وفتحها بـ open مسموح (seccomp كان يمنع mknod، أما open لعقدة قائمة فلم يخطر ببال أحد أن يمنعه، ولهذا بالضبط سمّينا الـ capabilities كاملة: نحتاج أن نقرأ من الجهاز لا أن ننشئه). قرأنا ما أردنا من ملفات العالم الخارجي مباشرة، ثم، من باب التوثيق النرجسي، مسحنا القرص الخام نفسه كتلاً كتلاً (ميغابايت في المرة مع منطقة تداخل من 256 بايت بين الكتل حتى لا تنكسر أي بصمة نصية عند حدود القراءة) وتأكدنا أن كل ما رأيناه عبر نظام الملفات نجده أيضاً في الحديد العاري، وكل نتيجة قوبلت من مسارين مستقلين قبل تصديقها.

ولاحظ ما لم نفعله: لم نرقص رقصة الهروب الكلاسيكية من chroot (إنشاء مجلد، chroot إليه، ثم تسلق ../../../.. حتى تتجاوز الجذر) ولا أي من أبناء عمومتها. تلك الحيل تفترض أنك تطلب من النظام أن يسمح لك بالخروج، وأن السجن حبس جسدك. نحن لم نطلب إذناً ولم نلمس الجدران: غيّرنا الإجابة على سؤال “أين أنا”، فتوقف تعريف السجن عن الانطباق علينا أصلاً. الفرق بين الاثنين هو الفرق بين من يهرب من السجن ومن يشتري العقار الذي بُني عليه السجن.

لو كنت في الجهة الأخرى من الطاولة

أعد قراءة الرحلة الآن بعين مدافع، واسأل: أين كان يجب أن تتوقف؟ الجواب مرتبك، وهذا أهم ما فيه:

التحصينما فعله بنا فعلياً
HARDENED_USERCOPYمنع الاستقبال العادي للرسالة المتضخمة، فاضطررنا لغسل القراءة عبر MSG_COPY
SLAB_FREELIST_HARDENEDشفّر مؤشرات الـ freelist فقتل أي hijack تقليدي للـ freelist، ودفعنا نحو data-only
KASLR (مع عشوائية الـ direct map)فرض مرحلة leak كاملة: مؤشر ops للـ text، وقراءة للـ bases الثلاثة
CONFIG_DEBUG_LISTكشف الـ free-while-linked مبكراً وحوّل كراشاً غامضاً إلى تشخيص (لكن لم يمنع شيئاً)
STATIC_USERMODEHELPERقتل أسلحة modprobe_path وcore_pattern بجملة واحدة
STRICT_KERNEL_RWXجعل .data غير قابلة للتنفيذ فأفقد الجدول المزوّر معناه
فحص الدمج المُشوَّه بالإشارةخنق فكرة وضع gadgets في offset/len في المهد
NNP مع seccompأغلق su وSUID وmknod، ولم يغلق شيئاً مما فعلناه فعلاً

لاحظ النمط المقلق عبر الجدول كله: لا شيء في القائمة أوقف الهجوم. كل ما فعلته هذه التحصينات، مجتمعةً وبمحبة، هو أنها أجبرتنا على شكل أغلى وأبطأ وأذكى: من “انسخ الجواب” إلى “اغسل الـ leak عبر رسائل لا تُستقبل”، من “hijack مؤشر وارقص ROP” إلى “اكتب في البيانات مباشرة وانسَ الرقص”. التحصينات لم تكن جداراً في أي لحظة، كانت تعريجات في طريق ظل سالكاً. وهذا هو الدرس المركزي في kernel exploitation منذ سنوات، يعيد نفسه في كل بحث جدي: الذاكرة القابلة للكتابة هي الامتياز. cred بيانات، وtask->fs بيانات، والبيانات لا تحتاج gadgets لتُفسَّر، فقط من يكتبها.

والمسؤولية الحقيقية عن بداية القصة كلها تقع عند الباب الأول، عند الوحدة نفسها، وعندها ثلاث وصايا من هذه الرحلة: انسخ بـ memcpy بطولٍ من النوع لا من البيانات، واجعل الملصق والمحتوى يتزامنان حتى في مسارات الفشل، ولا تضع أبداً هياكل متفاوتة الأحجام في buffer واحد تفصل بينه بملصقٍ في struct بعيد. ثم وصية رابعة أعمق وأقل اتفاقاً: فكّر طويلاً قبل أن تضع منطق كلمات المرور في kernel space أصلاً، فأنت لا تكسب بها أمناً بقدر ما تشتري امتيازاً كاملاً لأصغر خطأ كتابي تكتبه.

الخلاصة

بدأنا بدالة نسخ تقرأ طولها من داخل النص المنسوخ نفسه، وانتهينا بملكية kernel كاملة وخريطة للعالم خارج السجن، من دون أن ننفّذ تعليمة واحدة من اختيارنا في kernel space على الإطلاق. لا ROP، لا stack pivot، لا gadget واحد، لا hijack لمؤشر واحد: فقط قراءات وكتابات. بين البداية والنهاية وقفت يانصيب hashes يدفع فوراً، ورسائل IPC تُقرأ ولا تُستقبل أبداً، وأنابيب تمنحنا خريطة الذاكرة ثم تصبح سلاح كتابة، وصفحة فيزيائية واحدة نكتب عليها بـ write() العادية المتواضعة.

والمسك الختامي الذي يستحق أن تحمله معك إذا نسيت كل ما فوقه: القصة كلها بدأت لأن الـ kernel صدّقت struct يقول “عمري 32” بينما يحمل في داخله 48. والـ kernel، المسكين، ليس غبياً حين يصدّقه، بل بالعكس تماماً: هو يصدّقه لأنه لم يكذب عليه قط. كل البيانات في ذاكرته كانت دائماً صادقة، عمرها كذا وكانت كذا، حتى جاء أناس يطبخون كلمات مرور في userspace ليجعلوا الـ hash يقفز إلى حيث يشاؤون. فإذا كتبت يوماً كوداً يميز بين الأنواع بملصقٍ ينفصل عن المحتوى، فاعلم أن أحدهم في مكان ما يشتري تذكرته الآن إلى slot الجار. والجار، في عالم الـ kernel، هو دائماً شيء يستحق القراءة.

المراجع

⌘
Suggested Searches