آليات الـ Stack واصطلاحات الـ Calling Conventions
الـ Stack هو أكثر بنية بيانات جرى استغلالها في تاريخ الحوسبة. في هذا الدرس نُشرّح الـ Stack Frame عظمةً عظمة، ونتتبّع كل Calling Convention حتى تقرأ أي Stack Dump وتعيد بناء سلسلة الاستدعاء كاملة.
مقدمة
كل Buffer Overflow كلاسيكي، وكل سلسلة Return-Oriented Programming (ROP)، وكل عملية Stack Pivot — تعتمد جميعها على فهمٍ دقيق لكيفية تنظيم الـ Stack، وكيف تتلاعب به استدعاءات الدوال، وكيف ترتّب الـ Calling Conventions المختلفة الـ Arguments والـ Return Address والـ Registers المحفوظة.
فكّر في الـ Stack على أنه رصّةٌ من الصحون: آخر صحن تضعه هو أوّل صحن تسحبه (LIFO). كل استدعاء دالة يضيف «صحنًا» جديدًا اسمه الـ Stack Frame، وكل عودة تسحبه. مهمّتنا كمستغِلّين أن نفهم بنية هذا الصحن بدقّة تسمح لنا بالتنبّؤ — بل بالتحكّم — بما يحدث حين يفيض.
عند نهاية هذا الدرس ستكون قادرًا على قراءة Stack Dump خام وإعادة بناء سلسلة الاستدعاء بالكامل، وتحديد كل حقلٍ في كل Frame، والتنبّؤ بالضبط كيف سيُفسِد الـ Buffer Overflow الـ Stack ويصل إلى الـ Return Address.
push يُنقِص الـ Stack Pointer ESP/RSP.تشريح الـ Stack Frame
الـ Stack Frame هو المنطقة المخصّصة لاستدعاءٍ واحد لدالة. يحتوي على الـ Local Variables، وقيم الـ Registers المحفوظة، والـ Return Address، و(في بعض الـ Conventions) الـ Arguments نفسها.
المخطط التفاعلي أدناه يمثّل Stack Frame نموذجيًّا على x86 وفق cdecl مع Frame Pointer. مرّر المؤشّر أو انقر على أي شريحة لتفهم دورها وعلاقتها بالاستغلال.
مستكشِف الـ Stack Frame (x86 · cdecl)
انقر على أي شريحة لعرض تفصيلها ودلالتها الأمنية. العناوين محسوبة نسبةً إلى EBP.
1.1 الحقول الرئيسية
Return Address
عنوان التعليمة التالية للـ CALL التي استدعت هذه الدالة. تدفعه CALL تلقائيًّا، وتقرأه RET لتستأنف تنفيذ الـ Caller. هذا هو الهدف الأساسي لأي Stack Buffer Overflow؛ فمن يتحكّم به يتحكّم بمسار التنفيذ.
Saved EBP (الـ Frame Pointer)
الـ Frame Pointer الخاص بالـ Caller، تدفعه الـ Prologue القياسية. عند العودة يُستعاد EBP إلى هذه القيمة. إفساد الـ Saved EBP يفتح باب هجوم Frame Pointer Overwrite الذي قد يحرف التنفيذ عند العودة التالية.
Local Variables
تُخصَّص بطرح قيمة من ESP في الـ Prologue (sub esp, N)، ويُوصَل إليها عبر إزاحات سالبة من EBP مثل [ebp-4]. الـ Buffer القابل للفيضان يعيش هنا غالبًا.
Arguments
يدفعها الـ Caller على الـ Stack قبل CALL، ويُوصَل إليها عبر إزاحات موجبة مثل [ebp+8]. الإزاحة +8 (لا +4) تُراعي وجود الـ Saved EBP والـ Return Address بينهما.
cdecl Calling Convention
cdecl (اختصار C declaration) هو الـ Convention الافتراضي لدوال لغة C على أنظمة x86 بنظام 32-بت في لينكس، وشائع أيضًا على ويندوز 32-بت.
2.1 القواعد
| الجانب | القاعدة |
|---|---|
| الـ Arguments | تُدفَع على الـ Stack من اليمين إلى اليسار |
| تنظيف الـ Stack | الـ Caller يزيل الـ Arguments بعد العودة |
| القيمة المُعادة | في EAX (وEDX للقيم 64-بت) |
| Callee-saved | EBX, ESI, EDI, EBP, ESP |
| Caller-saved | EAX, ECX, EDX |
لنتتبّع الاستدعاء add(1, 2, 3) خطوةً بخطوة. المخطط الحي أدناه هو قلب هذا الدرس: اضغط «التالي» لتشاهد الـ Stack يتغيّر مع كل تعليمة، من دفع الـ Arguments حتى تنفيذ ret.
تتبّع استدعاء دالة خطوةً بخطوة
add(1,2,3) بأسلوب cdecl. راقب الـ Stack Pointer ESP والـ Frame Pointer EBP وهما يتحرّكان.
cdecl · 32-bit; الـ Caller (main):
push 3 ; دفع الـ argument الأيمن أولًا
push 2 ; الـ argument الأوسط
push 1 ; الأيسر أخيرًا = الأقرب لـ ESP
call add ; دفع الـ return address والقفز إلى add
add esp, 12 ; الـ Caller ينظّف 3 arguments (3×4=12 بايت)
; الـ Callee (add):
add:
push ebp ; حفظ الـ frame pointer الخاص بالـ Caller
mov ebp, esp ; تأسيس الـ frame الجديد
mov eax, [ebp+8] ; a
add eax, [ebp+12] ; a + b
add eax, [ebp+16] ; a + b + c
pop ebp ; استعادة الـ frame pointer
ret ; سحب الـ return address إلى EIP
2.2 لماذا يهمّ تنظيف الـ Caller؟
لأن الـ Caller هو من ينظّف الـ Stack، يدعم cdecl الدوال متغيّرة الـ Arguments (مثل printf). فالـ Callee لا يعرف عدد الـ Arguments الممرَّرة، لذا لا يستطيع تنظيفها؛ أمّا الـ Caller — الذي دفعها — فيعرف عدد البايتات المطلوب إزالتها بدقّة.
stdcall Calling Convention
stdcall هو الـ Convention القياسي لدوال Win32 API على ويندوز. وهو مطابق لـ cdecl تمامًا باستثناء أن الـ Callee هو من ينظّف الـ Arguments.
| الجانب | القاعدة |
|---|---|
| الـ Arguments | تُدفَع من اليمين إلى اليسار |
| تنظيف الـ Stack | الـ Callee يزيلها عبر RET n |
| القيمة المُعادة | في EAX |
تعليمة RET n
RET n تسحب الـ Return Address إلى EIP، ثم تضيف n إلى ESP. هكذا يزيل الـ Callee الـ Arguments.
stdcall، إن تحكّمت بالـ Return Address فعليك مراعاة تعديل RET n على ESP عند بناء الـ ROP Chain، وإلا انزاح الـ Stack بمقدار n بايت غير متوقّع فتنكسر السلسلة.fastcall Calling Convention
يمرّر fastcall أوّل Argumentين (أو أكثر) في الـ Registers لتحسين الأداء، بدلًا من دفعهما على الـ Stack.
Microsoft fastcall
| الجانب | القاعدة |
|---|---|
| أوّل argumentين | ECX, EDX (يسار إلى يمين) |
| باقي الـ Arguments | تُدفَع على الـ Stack يمينًا إلى يسار |
| تنظيف الـ Stack | الـ Callee (مثل stdcall) |
| القيمة المُعادة | EAX |
__fastcall; compute(1,2,3,4) — الـ Caller:
push 4 ; d (الرابع، على الـ stack)
push 3 ; c (الثالث، على الـ stack)
mov edx, 2 ; b (الثاني، في EDX)
mov ecx, 1 ; a (الأول، في ECX)
call compute
; داخل الدالة: ret 8 لتنظيف c و d
thiscall واستدعاءات دوال ++C
يُستخدَم thiscall لدوال الأعضاء غير الساكنة في ++C. الفرق الجوهري: يجب تمرير الـ this Pointer إلى الكائن.
على مترجم MSVC
يُمرَّر الـ this Pointer في الـ Register ECX، وتُدفَع الـ Arguments يمينًا إلى يسار، والـ Callee ينظّف (مثل stdcall).
على مترجم GCC (لينكس)
يُمرَّر this كأوّل Argument على الـ Stack بأسلوب cdecl، والـ Caller ينظّف.
Virtual Call — هدفٌ استغلالي كلاسيكي
يمرّ الـ Virtual Call بثلاث خطوات: تحميل الـ vtable Pointer من الكائن، ثم الفهرسة داخله للحصول على الـ Function Pointer، ثم الاستدعاء عبره.
virtual call · MSVC 32-bitmov ecx, [eax] ; ECX = vtable pointer من الكائن عند EAX
mov edx, [ecx+8] ; EDX = مؤشّر الدالة الافتراضية الثالثة
call edx ; استدعاء الـ virtual function
EAX، تحكّم بالـ vtable Pointer ومن ثمّ بهدف الاستدعاء بالكامل. إفساد الـ vtable Pointer أو الجدول نفسه (عبر Use-After-Free مثلًا) يحرف الـ Virtual Call إلى شيفرة يسيطر عليها المهاجم.System V AMD64 ABI
هو الـ Convention القياسي على لينكس وmacOS وFreeBSD ومعظم أنظمة يونكس بنظام 64-بت. التحوّل الأهم عن x86: أوّل ستة Arguments صحيحة تُمرَّر في Registers، لا على الـ Stack.
| الجانب | القاعدة |
|---|---|
| Integer Arguments | RDI, RSI, RDX, RCX, R8, R9 (أول 6) |
| Float Arguments | XMM0–XMM7 (أول 8) |
| باقي الـ Arguments | تُدفَع على الـ Stack يمينًا إلى يسار |
| تنظيف الـ Stack | الـ Caller |
| القيمة المُعادة | RAX (صحيح) / XMM0 (عشري) |
| Red Zone | 128 بايت أسفل RSP لا تمسّها الـ Signals |
| Stack Alignment | 16 بايت قبل CALL |
pop rdi ; ret لتحميل الـ Arguments في الـ Registers قبل استدعاء دالة مثل system("/bin/sh"). حفظ ترتيب RDI→RSI→RDX→RCX→R8→R9 ضروري لبناء أي Chain تستدعي دوالّ بـ Arguments.Microsoft x64 Convention
يختلف الـ Convention الخاص بمايكروسوفت على 64-بت اختلافًا جوهريًّا عن System V، وهذا الاختلاف يجعل الـ Shellcode غير قابل للنقل بين المنصّتين دون تعديل.
| الجانب | القاعدة |
|---|---|
| Integer Arguments | RCX, RDX, R8, R9 (أول 4 فقط!) |
| Shadow Space | 32 بايت يحجزها الـ Caller فوق الـ Return Address |
| Red Zone | لا توجد (بخلاف System V) |
| تنظيف الـ Stack | الـ Caller |
المخطط أدناه يقارن بصريًّا أين تذهب الـ Arguments في كل Convention. بدّل بين الـ Conventions لترى الفرق بين تمرير الـ Registers والـ Stack، وموقع الـ Shadow Space.
مقارنة الـ Calling Conventions
أين يذهب كل argument؟ اختر Convention لرؤية توزيع الـ Arguments بين الـ Registers والـ Stack.
Shadow Space
يُلزِم الـ Convention الخاص بمايكروسوفت الـ Caller بحجز 32 بايت من الـ Shadow Space على الـ Stack، حتى لو كان للدالة أقلّ من أربعة Arguments. يستطيع الـ Callee استخدام هذه المساحة لسكب الـ Register Arguments فيها.
RCX,RDX,R8,R9 مقابل RDI,RSI,RDX,RCX) وغياب الـ Red Zone — ثلاثة فروق تجعل الـ Payload خاصّة بكل منصّة.Prologue وEpilogue
الـ Prologue والـ Epilogue هما الطقسان اللذان يفتحان الـ Stack Frame ويغلقانه. فهمهما هو ما يفسّر لماذا يعمل الـ Buffer Overflow، ولماذا تُعدّ تعليمة leave أداة ROP قوية.
Standard x86 Prologue
push ebp ; حفظ الـ frame pointer الخاص بالـ Caller
mov ebp, esp ; تأسيس الـ frame pointer الحالي
sub esp, N ; حجز N بايت للـ local variables
Standard x86 Epilogue
leave ; يكافئ: mov esp, ebp ; pop ebp
ret ; سحب الـ return address إلى EIP
Frame Pointer Omission
مع الخيار -fomit-frame-pointer لا يحفظ المترجم RBP، بل يحجز الـ Locals مباشرةً ويصل إليها عبر [rsp+offset]. هذا أكفأ (Register إضافي متاح وتعليمات أقل) لكنه يصعّب الـ Debugging والـ Stack Unwinding. يعتمده GCC افتراضيًّا على x64 مع الـ Optimization.
leave قيمة RSP لتساوي RBP ثم تسحب RBP. هذا يتيح Stack Pivot: وجّه RBP إلى Buffer تتحكّم به، ثم نفّذ leave ; ret لتنقل الـ Stack إلى منطقتك. أمّا مع الـ Frame Pointer Omission، فلا يوجد Saved EBP بين الـ Locals والـ Return Address، ما قد يُقصّر مسافة الـ Overflow المطلوبة.لنجعل هذا ملموسًا: المخطط أدناه يحاكي Buffer بحجم 16 بايت. حرّك المنزلق لتكتب المزيد من البايتات وتشاهد متى «يفيض» الـ Overflow فوق الـ Saved EBP ثم يصل إلى الـ Return Address — لحظة السيطرة.
محاكاة الـ Buffer Overflow
Buffer = 16 بايت، ثم 4 بايت لـ Saved EBP، ثم 4 بايت للـ Return Address.
عملي: تتبّع الاستدعاءات في الـ Debugger
النظرية تكتمل عند لوحة المفاتيح. هذه جلسة GDB مختصرة على x86 لفحص Call Chain من ثلاث دوال متداخلة، وقراءة سلسلة الـ Frames يدويًّا.
GDB · x86# بناء وتشغيل
gcc -m32 -fno-omit-frame-pointer -g -o demo stack_demo.c
gdb ./demo
(gdb) break func_c
(gdb) run
# تتبّع الـ call chain بالكامل
(gdb) backtrace
#0 func_c (x=3) at demo.c:5
#1 0x565561bc in func_b (y=2) at demo.c:12
#2 0x565561e3 in func_a (z=1) at demo.c:19
#3 0x56556204 in main () at demo.c:24
# فحص ذاكرة الـ Stack الخام
(gdb) x/32xw $esp
# سلسلة الـ Saved EBP:
(gdb) x/2xw $ebp
# الكلمة الأولى = Saved EBP (يشير لإطار الـ Caller)
# الكلمة الثانية = Return Address
# تتبّع السلسلة يدويًّا خطوة للأعلى
(gdb) x/2xw *(int*)$ebp
k يعرض الـ Call Stack، وkv يضيف تفاصيل الـ Frame Pointer، وdps rsp rsp+100 يُفرّغ ذاكرة الـ Stack مع فكّ رموز العناوين. الأمر .frame 1 ثم dv يعرضان الـ Local Variables لإطارٍ محدّد.★ الخلاصات الأساسية
- الـ Return Address هو الهدف الأساسي لأي Stack Overflow؛ يقع على إزاحة ثابتة من الـ Buffer في دالة ذات Frame Pointer، وتضعه
CALLتلقائيًّا. - cdecl (تنظيف الـ Caller) يدعم الدوال متغيّرة الـ Arguments، بينما stdcall (تنظيف الـ Callee عبر
RET n) تستخدمه واجهة ويندوز. طريقة التنظيف تؤثّر على كيفية بناء الـ ROP Chain. - System V AMD64 يمرّر 6 Arguments في Registers (
RDI,RSI,RDX,RCX,R8,R9)، بينما Microsoft x64 يمرّر 4 فقط (RCX,RDX,R8,R9) ويطلب 32 بايت Shadow Space — لذا الـ Shellcode على x64 خاصّ بكل منصّة. - تعليمة leave (
mov rsp,rbp ; pop rbp) أداة ROP قوية للـ Stack Pivot؛ فهم الـ Prologue والـ Epilogue يوضّح السبب. - Frame Pointer Omission يغيّر تخطيط الـ Stack بإزالة الـ Saved EBP/RBP، ما قد يؤثّر على مسافات الـ Overflow ويصعّب التحليل اليدوي.
- thiscall يمرّر الـ
thisفيECX(MSVC) أو كأوّل Argument على الـ Stack (GCC)؛ والاستدعاءات عبر الـ vtable Pointers هدفٌ استغلالي شائع. - Stack Alignment بـ 16 بايت مطلوب على x64؛ اختلاله قد يُعطِّل تعليمات SSE فيتعطّل الـ Exploit.