شرح تجاوز SafeStack: الخزنة حديد، بس الكراسة في الصالة
SafeStack بيقسم الـ stack لاتنين عشان أي buffer overflow ما يقدر يكسر غير الجزء اللي محدش مهمه. خطة محترمة، لحد ما تلاقي إن الـ pointer اللي بيحدد مكان الجزء القابل للكسر نفسه متخزن في TLS ملاصقة للـ overflow. دي حكاية layout واحد غلط إزاي وقّع shadow stack وcanary وCFI معمول في البيت، كلهم في سلسلة واحدة.
شرح تجاوز SafeStack: الخزنة حديد، بس الكراسة في الصالة
تخيل بنك واخد الأمان بجد. كل الفلوس جوه خزنة محترمة، وكل الشغل الورقي اللي فيه فوضى وشخبطة بقى على طاولة رخيصة في الصالة. منطقي جداً. بس التفصيلة اللي بتضيّع كل ده: في كراسة صغيرة محفوظة في دولاب ورا الطاولة بالظبط، ومكتوب فيها مكان الطاولة نفسها. ادفع كم ورقة زيادة على الطاولة، هتلاقي نفسك بتكتب جوه الكراسة. عدّل السطر اللي فيها، وبكرة الصبح الموظفين هيفرشوا «طاولة الصالة» جوه الخزنة فوق الفلوس وهما مبسوطين. محدش هيكسر الخزنة. الخزنة أصلاً عمرها ما هتتفتح.
ده، في صورة واحدة، تجاوز SafeStack. الـ stack overflow الكلاسيكي انت عارفه: تصب bytes في buffer أكتر من طاقته، وتفضل تصب لحد ما تكتب فوق الـ return address، والبرنامج يبقى بتاعك. الـ canaries اتولدت أصل عشان تكتشف اللعبة دي، والـ NX والـ ASLR رفعوا تمن اللي بييجي بعدها. SafeStack اختار الطريق الـ structural: لو الـ overflow حتمي، نطلّع الأشياء المهمة من مسار الطيران بتاعه، ونسيب الحاجات القابلة للكسر في مكان محدش مهمه. فكرة كويسة بجد، وظروف معينة بتخليها تشتغل. والمشكلة هي الظروف دي بالذات.
في المقال ده: إزاي SafeStack بيقسم الـ stack لاتنين، وإزاي التقسيمة دي بتتكل على memory layout محدش صدّقه، وسيناريو عملي وقع فيه shadow stack معمول في البيت وcanary وCFI يدوي كلهم ورا بعض، وفي الآخر الحل اللي بيشيل الحكاية من جذورها (تلميح: حل ممل، زي كل الحلول الصح).
ما هو SafeStack أصلاً؟
في البرنامج العادي على لينكس x86-64، كل thread عنده stack واحدة بتحمل كل حاجة مرة واحدة: الـ arrays المحلية، والـ saved registers، والـ pointers المؤقتة، والـ return addresses. والـ overflow مش محتاج يفهم أي حاجة من الكلام ده. هو بس بيكتب لفوق في الذاكرة لحد ما يقف على return address، ومن اللحظة دي اللي بيكتب هو اللي بيسوق.
الدفاعات الكلايكية كلها ردود فعل على الصورة دي. الـ canary بيحط قيمة حارسة على الـ stack وبيبص عليها وهو خارج من الدالة. الـ NX بيمنع الكود المحقون من التنفيذ. الـ ASLR بيخبّي الأهداف. التلاتة بيرفعوا تكلفة الهجوم، والتلاتة ليهم طرق تجاوز معروفة ومزحومة من زمان.
SafeStack، اللي طلع من عيلة أبحاث الـ Code-Pointer Integrity وبييجي مع LLVM/Clang تحت -fsanitize=safe-stack، بياخد ضربة structural بدل كل ده. وقت الـ compile، كل object على الـ stack بيتصنف:
- آمن: الـ return addresses والـ saved registers والـ values اللي الـ control flow بيعتمد عليها. دي بتعيش في الـ safe stack.
- غير آمن: الـ arrays والـ buffers وأي حاجة عنوانها بيتنقل بره الدالة. باختصار، أي حاجة الـ overflow يقدر يلطخها. دي بتنقل لمكان تاني خالص اسمه الـ unsafe stack.
الدوال المترجمة كده بتسيب بيانات التحكم على الـ safe stack، وبتتعامل مع بيانات الشغل عن طريق __safestack_unsafe_stack_ptr، وده thread-local variable. وهنا القرار اللي شايل الموضوع كله على ضهره: الـ pointer ده متخزن في الـ TLS، وهي بلوك بيانات لكل thread البرنامج بيوصلها من غير الـ fs segment register على x86-64.
المنطق كان سليم: الـ overflow على الـ unsafe stack مش المفروض يوصل لمنطقة تانية زي الـ TLS، لأن المناطق في الذاكرة بينها gaps، زي الأوض اللي بينها حيطان.
الـ threat model بيقول «منفصلة». والـ memory map، في الـ layout اللي هنشوفه تحت، بيقول «ملاصقة». والكلمتين دول هما المقال ده كله.
ليه الموضوع يهمك؟
- الحماية حقيقية ومستخدمة. flag واحدة في clang وبتكون شغالة، فالسيرفيسات المتقسّية بتستخدمها، وغالباً فوق PIE وFull RELRO وNX وcanary في نفس الوقت. وأول ما تقابلها بجد بتوجع فعلاً: الـ return address مش في مكان الـ overflow بتاعك خالص، والسلاسل المعتادة بتموت من أول لمسة.
- الفشل هنا فشل كامل. ده مش bypass نص نص ولا leak صغير. لو قدرت تكتب فوق الـ unsafe stack pointer، أي كتابة بعدها البرنامج بيعملها على الـ «unsafe» هتقع في المكان اللي انت حددته. الـ overflow «المحصور» بيتحول لـ primitive بتكتب بيها جوه مين ما تحب، ومن هناك المشوار لـ shell قصير.
- الـ vulnerability مش في الكود. التطبيق اللي هنمشي عليه مترجم صح والـ mitigation شغالة. مفيش CVE في السورس. الفجوة افتتحت بين منطقتين في الذاكرة كان مفروض عمرهم ما يلمسوا بعض.
- كل حماية ورثت السقوط. shadow stack؟ الجنبين بتوع المقارنة بقوا تحت إيدك. canary؟ نفس الحكاية. CFI يدوي؟ الـ base register بتاعه هو saved register انت اللي كتبه. أول ما افتراض العزل مات، كل اللي مبني عليه مات معاه.
السيناريو: daemon ملاحظات واثق في الـ compiler بتاعه
الهدف التخيلي اسمه notesvc. daemon صغير على لينكس، بيعرض menu نصي، وبيعمل تلات حاجات تستاهل الكلام: معاينة حالة بتطبع شوية من دفاتره الداخلية (ومنها pointer حي، وده كرم مضياف بيفضل الشغل من نظري لمجدول)، ومرور خام على مدخلات المستخدم، وحفظ ملاحظة. متترجم بـ clang -O2 -fsanitize=safe-stack، ومواطن صالح في كل حاجة تانية: PIE وFull RELRO وNX وcanary. والكاتب متحمس كمان، فضم حاجتين معمولتين في البيت: shadow stack (نسخة من كل return address في array في الـ BSS بتتقارن وقت الرجوع) وCFI check على الـ indirect calls.
// notesvc.c: الميزة اللي بتفرق
void save_note(void) {
char note[48]; // بتقع على الـ UNSAFE stack تحت SafeStack
puts("Provide some input for the buffer.");
gets(note); // قراءة من غير حد أقصى، وصاحب الكود عارف
}
48 byte buffer، وقراءة من غير حدود، ووعد من الـ compiler إن الضرر هيفضل محصور في النص اللي محدش مهمه.
الطريق للشق
التفكير، بالترتيب اللي بيتعمل بيه فعلاً:
- الجرد. الـ checksec بيوريك الحيطان المعتادة زيادة أثاثة غريبة: symbol اسمه
__safestack_unsafe_stack_ptr(يعني SafeStack مفتوح)، وarray في الـ BSS بيتكتب فيها عند مواضع الـ calls (shadow stack)، وسلسلة compare-and-trap صغيرة قبل الـ indirect calls (CFI يدوي). - الـ bug.
getsفي buffer 48 byte. في binary عادي هتوقف هنا وتبدأ تكتب ROP chain. تحت SafeStack، الـ buffer عايش على الـ unsafe stack، فالـ overflow بيلطخ data غير آمنة تانية وخلاص. الـ return address بتاعsave_noteفي مكان تاني خالص، والمحاولة الأولى المكبرة بتموت من غير ما توصل لأي return أصلاً. - سؤال الـ layout. الـ unsafe stack ده فين بالظبط، ومين اللي عايش جنبه؟ في الـ debugger: هي mmap من غير اسم. وفوقها مباشرة، من غير guard page بينهم: بلوك الـ TLS الثابت، ومعاه الـ thread pointer كله. والمسافة من الـ note buffer لحد
__safestack_unsafe_stack_ptrرقم ثابت بالبايت، ثابت من run للتاني، لأن الـ ASLR بيزحف المناطق كلها مع بعضه ومش بيعيد ترتيب الأوض من جوه. ومعاينة الحالة في الـ menu بتطبع pointer من الدفاتر دي، فبرقم واحد مطبوع، الـ program base وهندسة الـ stack وكل offset ثابت في ما بينهم بيقعدوا بالطرح. - الاستنتاج. قراءة من غير حدود، ومنطقة ملاصقة، ومفيش guard page، معناها إن كلمة «منفصلة» في الـ threat model بتاع SafeStack عمرها ما كانت حقيقية هنا. الـ overflow يوصل للـ pointer اللي بيعرف الـ unsafe stack نفسه.
المصيدة: الـ overflow في عملية عايشة عملية جراحة مش هدم
هنا أول payload ساذج بيفشل فشل مثير للاهتمام. املأ المكان لحد الـ TLS بـ junk وكمّل، والعملية بتموت في نص الـ gets نفسها، جوه glibc، قبل أي corruption عبقري يشتغل أصلاً.
والسبب ده تحفة هندسية: الـ gets بتنتهي لـ read، ومكنة glibc للـ syscalls القابلة للإلغاء بت عمل dereference للـ thread descriptor (الـ TCB اللي بتوصل له من fs:0x10) عشان تعمل حساباتها. الـ thread pointer اللي انت لطخته للتو بقى قيمة garbage من غير canonical address، وأول bookkeeping بعدها رحلة بلا عودة لـ SIGSEGV. (بدّل الـ junk بأصفار، هياخد crash مختلف على address قريب من الصفر. الشكل مختلف، الفاتحة واحدة.)
يعني الـ payload لازم يكون جرّاح، مش بلدوزر. بين الـ buffer والـ pointer اللي انت عايزه في أراضي الـ runtime ماشي فيها حالياً بيفتح وبيقفل:
- الـ thread self-pointer بيتصلّح ليشاور على stack عادي قابل للكتابة، عشان حسابات الإلغاء تفضل عايشة لباقي الـ read.
- الـ dtv pointer والـ slots بتاعة glibc جوه الـ TLS بتتعالج أو بترجع لقيمها. في الـ build ده قيمهم الحقيقية كانت offsets ثابتة من الـ program image، قابلة للحساب من الـ leak الواحد ده، بس الأداة البلدوزر بتصفيرهم اشتغلت برضه، لأن مفيش حاجة على المسار الساخن كانت محتاجاهم بعدها.
- slot الـ stack guard بياخد قيمة بنختارها بعمد، ودي هتفرق بعد شوية.
بعد ما الأثاث يترجع مكانه، الـ gets بتخلص، والـ prompt بيطبع، والبرنامج بيكمل تنفس، وكل خانة اتكسرت بقيت خانة احنا اخترناها. ودي بالظبط الفرق بين crash وexploit.
الـ pivot: نقل الطاولة جوه الخزنة
نجمة الـ overflow هي الكتابة فوق __safestack_unsafe_stack_ptr نفسه، اللي بيتوجه لـ stack الحقيقي، على مسافة عشرات البايتات تحت الـ frames العايشة. من اللحظة دي، أي وصول للـ unsafe stack من الـ thread ده بيقع على الـ stack الحقيقي بدلها. الموظفين اتقالهم إن طاولة الصالة الصبح مكانها جوه الخزنة، ومفيش عندهم أي سبب يجادلوا الكراسة.
واللي بيخلي ده مدمر هو اللي البرامج بتعمله بعدها: بتفضل تشتغل unsafe-stack work. الميزة اللي بعدها في الـ daemon بتقرا 40 byte خام للـ «معالجة»، محسوبة نسبة للـ unsafe stack pointer الجديد (المزيف)، فبتقع بالظبط فوق الـ saved registers بتاعة الدالة الحالية والـ frame بتاعة اللي ناداها. القراءة المعاد توجيهها الواحدة دي بتزوّر:
- saved register من الـ callee-saved، بتتحول بعدين لـ buffer pointer،
- مدخلات المقارنة بتاعة الـ canary، و
- الـ CFI base register اللي الـ check اليدوي بيستخدمه.
وبعدها ميزة الملاحظات بتنادي gets تاني، والمرة دي الـ buffer address جاي من الـ register المزوّر، فالكتابة بتيجي فوق slot الـ return address بتاع نفس الدالة. ROP بالخدمة الذاتية: الدالة بتكتب الـ hijack بتاعها فوق نفسها بنفسها.
أربع checks وأربع سقطات
كل حماية معمولة في البيت بتقع هنا لسبب خاص فيها، وكل سبب فيه درس لوحده:
- الـ shadow stack. الـ daemon قارن الـ return address الحي بنسخة كان حفظها بدري. بس النسخة اللي بيرجعلها كانت متوصلة من حالة الـ overflow بقى مالكها، فالمقارنة بقت بين قيمتك وقيمتك. الـ check اللي بيقارن كلامك بكلامك مش مراقب امتحان، ده زميلك اللي بيغش معاك وبيقولك «تمام كده».
- الـ canary. نفس شكل السقوط. الـ canary اللي اتحفظ كان منسوخ من memory احنا حضّرناها، والقيمة اللي اتقارن بيه اتسحبت من slot احنا مليناه. جنبين المقارنة الاتنين خرجوا من قلمنا. مفيش secret بيعيش لما مخزنه والمتحقق منه يكونوا في نفس الـ write set.
- الـ CFI اليدوي. قبل كل indirect call، الـdaemon بيقوم بحساب rotated delta بين الـ call target وbase register، وبيعمل trap إلا لو الـ delta صغيرة. والـ base register ده واحد من الـ saved registers اللي زوّرناها في القراءة المعاد توجيهها. خلي الـ base يساوي الـ target والـ delta تبقى صفر، والصفر بيعدي من أي threshold. الـ check ده عمره ما اختبر «هل الـ target ده مشروع». هو اختبر «هل الـ attacker مسيطر على الـ base كمان»، وبعد الـ pivot الإجابة بقت آه، للأبد.
// الـ forward-edge check اليدوي، بإعادة صياغة
uint64_t delta = ror64((uint64_t)target - cfi_base, 3);
if (delta > 1) __builtin_trap(); // cfi_base جاي من register بقى تحت سيطرة الـ attacker
- والـ check اللي وقع الأول: SafeStack نفسه. الـ metadata بتاعة الحماية نفسها، الـ pointer اللي بتعرّف بيه «الـ unsafe stack فين»، كانت متخزنة جوه مدى الـ overflow. صانت البيت، وكتبت عنوان البيت على بوابة بتضربها الرياح.
عبور خط النهاية
بعد ما الـ indirect call بقى أليف، السلسلة الأخيرة بتبقى checklist لكل حاجة صاحب البرنامج ضافها وهي بتشتغل ضده: الـ call بيروح لسلسلة قصيرة جوه الدالة بتنتهي بـ gets على address جاي من الـ register المزوّر بتاعنا، والكتابة بتيجي فوق slot الـ return بتاع الـ call نفسه، ومن هنا الـ ROP chain بياخد الباقي. chain واحدة بتسرّب libc address عن طريق puts وتحسب الـ libc base، وchain تانية بتتبعت بعد قراءة الـ leak بجد، بتنادي system("/bin/sh").
Shell. وجنبها PIE، وجنبها Full RELRO، وجنبها NX، وجنبها canary، وجنبها SafeStack، وجنبها shadow stack، وجنبها CFI. السبب إن محدش فيهم أثر مش إن كل واحد فيهم كان ضعيف لوحده. السبب إنهم كلهم كانوا واقفين على نفس الافتراض اللي محدش صدّقه.
الفروع والأقارب
- لو كان في guard page بين المنطقتين، المسار ده بالذات كان مات عند حدود الصفحة، وكنت هترجع تدور على استغلال تاني للـ unsafe stack، زي pointers متخزنة جوه structs قابلة للـ overflow (والـ documentation بتاعة SafeStack نفسها بتقول إن ده خارج نطاقها).
- الـ targets اللي فيها threads كتير عندها unsafe stack واحدة لكل thread، وكل واحدة معاها الـ TLS بتاعتها. نفس أسئلة الجوار، بس بعدد threads أكبر.
- الـ layout مش قانون طبيعي. إصدارات libc وloaders مختلفة بتقرر الأماكن دي بشكل مختلف، وعشان كده «اتأكد في الـ debugger» خطوة، مش اقتراح لطيف.
- الـ hardware shadow stacks (Intel CET) بتخزن الـ return addresses في ذاكرة محمية التقنية دي متقدرش تكتب عليها. السلسلة دي بالتحديد كانت هتقف عندهم. والـ overflow نفسه، بصراحة، كان هيفضل موجود برضه.
الإصلاح الفعلي
// بعد الإصلاح: الـ overflow مش موجود أصلاً
void save_note(void) {
char note[48];
puts("Provide some input for the buffer.");
if (!fgets(note, sizeof(note), stdin)) return; // bounded، ومتحقق منها
note[strcspn(note, "\n")] = '\0';
}
مفيش جراحة TLS، ومفيش تزوير registers، ومفيش لغز CFI. الإصلاح الممل بيمسح السلسلة اللي من أربع عشرة خطوة في دالة واحدة.
وقايمة الدفاع، بترتيب أهميتها الفعلية:
- صلّح القراءة نفسها. كل input محدود بوجهته.
fgets، أوreadمع فحص طول، أوstd::string، أي حاجة لغتك بتوفرها. «من غير حدود» هي الـ vulnerability، وكل اللي بعدها مفاوضات مع السارق بعد ما دخل وخلاص. - تعامل مع الـ mitigations كطبقات، مش حيطان. SafeStack فوق canary فوق RELRO فوق NX دي حيطة عالية. ولسه حيطة، والحيطة ليها أبواب لسه ما اكتشفتهاش.
- راجع الـ layout اللي الـ mitigations بتاعتك بتتفترضه. ربع ساعة في الـ debugger: اطبع منطقة الـ unsafe stack، واطبع بلوك الـ TLS، وشوف بيفصلهم إيه. لو حد الأمان بتاعك هو «المنطقتين دول مش بيلمسوا بعض»، روح اتفرج عليهم وهما مش بيلمسوا بعض.
- متشحنش CFI مكتوب بالإيد. rotated delta على base register جاي من register بيتحكم فيه الـ attacker مش دفاع، ده قفل كرتون: إحساس أمان عالي لحد ما أول مبتدئ يجرب يفتحه. الـ CFI الحقيقي بتاع الـ compiler (
-fsanitize=cfi) بيتحقق من الـ call targets ضد set محسوبة قبلها من الأهداف الشرعية، ومفيهوش أي input تحت سيطرة الـ attacker جوه المقارنة نفسها. - اعمل fuzz مع AddressSanitizer في الـ CI. الـ
getsاللي فتحت الحكاية دي كلها الـ ASan بيكتشفها من أول input. كل دفاع في المقال ده موجود عشان يعوّض عن bug الـ sanitizer كان هيلقطه من أول تجربة.
نقاط الفحص والمراجعة
- شغّل checksec واقرأ الخانات الغريبة بجد. SafeStack بيظهر كـ symbol اسمه
__safestack_unsafe_stack_ptr؛ كتابات في الـ BSS عند مواضع الـ calls تفشي shadow stack؛ سلاسل compare-and-trap قبل الـ indirect calls تفشي CFI يدوي. - اعمل grep في الـ disassembly على العيلة بتاعة «من غير حدود»:
getsوstrcpyوsprintfوscanf("%s"). «من غير حدود» + SafeStack = المقال ده بالظبط. - في الـ debugger:
info proc mappings، هات المنطقة الـ anonymous اللي الـ unsafe stack عايش فيها، وقارنها بالـ thread pointer ($fs_baseعلى x86-64). ملاصقين ومفيش فاصل؟ انت لقيت فرضية المقال دي في هدفك انت. - حط watchpoint على
__safestack_unsafe_stack_ptrوانت بتبعت input مكبر. لو الـ watchpoint ضرب قبل أي crash، افتراض «المناطق المنفصلة» خذلك فعلاً.
جدول الفحص
| الفئة | الإجراء | طريقة التحقق |
|---|---|---|
| التعامل مع المدخلات | تحديد كل قراءة بوجهتها | grep على gets/strcpy؛ fuzz مع ASan |
| دفاعات الـ stack | SafeStack + canary كطبقات، مش كخطة | checksec + مراجعة layout يدوية |
| مراجعة الـ layout | gap أو guard page بين unsafe stack والـ TLS | info proc mappings مقابل $fs_base في gdb |
| CFI للـ forward-edge | من الـ compiler، ومتجربش تكتبه بإيدك | -fsanitize=cfi؛ مراجعة أي traps مخصصة |
| الـ CI | sanitizer وfuzz على كل build | ASan صفر تقارير على الـ corpus |
أسئلة متكررة
هل ASLR بيوقف ده؟ لا. الـ ASLR بيعشوش أماكن المناطق، بس الـ unsafe stack والـ TLS بيتحركوا مع بعض، فالمسافة بين الـ buffer بتاعك والـ pointer اللي عايزه رقم ثابت. leak واحد من أي code أو libc pointer، وكل الـ addresses اللي محتاجها بتقع بالطرح.
يبقى SafeStack بقى بلا فايدة؟ لا، ودي مش الدرس. ضد smash عادي ده حيطة بجد: الـ return address مش في مكان الـ overflow، والسلاسل المعتادة بتموت ببساطة. فشل هنا لأن layout معين خالف افتراض معين. استخدمه، وصدّق افتراضاته بالتحقيق مش بالثقة، ومتخليه يعمللك غفلة.
هل canary أقوى كان هيحل المشكلة؟ الـ canary كان كويس. المخزن بتاعه والمتحقق منه كانوا الاتنين جوه الـ write set بتاع الـ attacker، ومفيش secret بيعيش في الوضع ده. الدرس في مكان تخزين بيانات الثقة، مش في entropy القيمة نفسها.
طب Intel CET والـ hardware shadow stacks؟ هما كانوا هيقفوا نص السلسلة بتاع الـ return addresses، لأن الـ hardware shadow stacks عايشين في ذاكرة الـ userspace stores متقدرش توصلها. الـ buffer overflow نفسه كان هيفضل موجود، وأي abuse بيانات-فقط كان هيفضل على الطاولة. الهاردوير بيرفع الأرضية، مش بيمسح البقعة.
الخواطر الأخيرة
كل دفاع في الحكاية دي كان معقول لوحده، والسلسلة اشتغلت برضه، لأنهم كلهم كانوا مصدقين نفس الحقيقة غير المتحقق فيها عن الـ memory layout. pointer الـ SafeStack كان قاعد في الـ TLS لأن الـ TLS «بعيدة عن الـ overflow». نسخة الـ shadow stack كانت حاكمة لأنها «read-only بالمزاج». الـ CFI check كان سليم لأن الـ base register بتاعه «internal state». واللحظة الافتراض اللي تحتهم اتشقق، دول مبقوش حيطان واقفة كل واحدة لوحدها، دول بقوا أوراق لعب متراكبة على نفس الطاولة.
اتأكد مكان الكراسة بتاعتك مكتوب فين، قبل ما حد تاني يكتب فيها.
الخزنة عمرها ما اتكسرت. الموظفين بس فضلوا يفرشوا على المكان اللي الكراسة قالته، وفي الآخر، الكراسة بقت بتاعتنا.
المراجع
- LLVM SafeStack documentation: https://clang.llvm.org/docs/SafeStack.html
- Kuznetsov et al., Code-Pointer Integrity, USENIX OSDI 2014: https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-kuznetsov.pdf
- OWASP, Buffer Overflow Attack: https://owasp.org/www-community/attacks/Buffer_overflow_attack
- CWE-121, Stack-based Buffer Overflow: https://cwe.mitre.org/data/definitions/121.html
- Ulrich Drepper, ELF Handling For Thread-Local Storage: https://www.akkadia.org/drepper/tls.pdf