بسم الله الرحمن الرحيم.
في هذا المقال سنتحدث عن تصميم الذاكرة الخاص بالـ Stack والـ Heap، ونوضح نقاط الاختلاف بينهما.
ملاحظة: هذا المقال لا يغطي كل شيء حرفياً، لكنه سيوصلنا لفهم كامل عن الأمور التي سنتحدث فيها.
تخطيط الذاكرة (Memory Layout)
كما ذكرنا في مقال المؤشرات، ذاكرة البرامج في الأنظمة الحديثة x86/x64 تنقسم إلى 5 أقسام رئيسية (والبعض يقول 4 بدمج Data مع BSS):
- Text Segment — التعليمات التنفيذية (للقراءة فقط).
- Data Segment — المتغيرات العامة المهيأة.
- BSS Segment — المتغيرات العامة غير المهيأة.
- Heap — التخصيص الديناميكي.
- Stack — المتغيرات المحلية وإطارات الدوال.
+-----------------+ عناوين عالية
| Stack | ينمو للأسفل
| | |
| v |
+-----------------+
| (فجوة) |
+-----------------+
| ^ |
| | |
| Heap | ينمو للأعلى
+-----------------+
| BSS |
| Data |
| Text |
+-----------------+ عناوين منخفضة
نتعامل هنا مع الـ User Space فقط — الـ Kernel Space موضوع آخر.
السبب في اتجاهي النمو المتعاكسين: حتى ينمو الـ Stack والـ Heap دون تصادم — كل منهما يتوسع في اتجاه يبتعد عن الآخر.
لماذا الـ Stack أسرع؟ السر في الـ Registers
الـ Stack يُنظم تلقائياً بواسطة المعالج، وأداؤه سريع. السر في ذلك أن علاقة الـ Stack بالـ Registers مباشرة — على عكس الـ Heap الذي يتعامل معها بطريقة غير مباشرة.
أهم السجلات (Registers)
السجلات وحدات تخزينية صغيرة داخل الـ Processor تستخدم في العمليات الحسابية التي تتطلب سرعة عالية:
| السجل | الاسم الكامل | الوظيفة |
|---|---|---|
| EAX | Accumulator | الاحتفاظ بقيمة الإرجاع (return value) للدوال، والعمليات الحسابية |
| EBP | Base Pointer | عنوان قاعدة إطار الـ Stack الحالي |
| ESP | Stack Pointer | عنوان قمة الـ Stack الحالي |
| EIP | Instruction Pointer | عنوان التعليمة القادمة التي سينفذها المعالج |
| ECX | Counter | عدّاد في العمليات المتكررة (مثل الـ loop) |
| EDX | Data | قرين EAX في الضرب والقسمة، والاحتفاظ بالبيانات |
| EBX | Base | لا وظيفة محددة — يحفظ البيانات في سياقات متعددة |
| ESI/EDI | Source/Destination | نقل الـ strings: ESI = مصدر، EDI = وجهة |
هذه أسماء نظام x86 (32-bit). في x64 تكون (RAX, RBP, RSP, RIP…) بعرض 64 بت. سنتعامل في أمثلتنا مع الـ x86.
ما هو الـ Stack؟
جزء من الذاكرة (منفصل منطقياً وليس فيزيائياً) يخزن المتغيرات المحلية وبيانات الـ Stack Frame. يُحجز تلقائياً عبر push/pop عند دخول الدوال ويُلغى عند انتهائها. يتبع نمط LIFO — المتغيرات التي تنشأ أولاً تُزال آخراً.
الـ Stack Frame (Activation Record)
بنية بيانات تُنشأ في الـ Stack عند استدعاء دالة، وتحذف عند انتهائها. هدفها عزل كل دالة عن الأخرى — كل استدعاء ينشئ إطاراً خاصاً. مكوناتها:
- الباراميترات (Parameters) — القيم المدخلة للدالة.
- عنوان العودة (Return Address) — أين يعود البرنامج بعد انتهاء الدالة.
- مؤشر الإطار (Frame Pointer) — قاعدة إطار الـ Stack الحالي.
- المتغيرات المحلية (Local Variables) — متغيرات الدالة الحالية.
- السجلات المحفوظة (Saved Registers) — قيم سجلات الدالة المستدعية قبل استدعاء دالة جديدة.
تخطيط الـ Stack Frame في x86
[esp] ← قمة الستاك (ESP)
[ebp-4] → أول متغير محلي
[ebp] → EBP المحفوظ (caller's frame)
[ebp+4] → عنوان العودة (return address)
[ebp+8] → المعامل الأول
[ebp+12] → المعامل الثاني
لماذا نطرح عند الإضافة للستاك؟ لأن الـ Stack ينمو لأسفل: إذا كان
ebp = 1000، فأول متغير محلي يكون عند996، والثاني عند992وهكذا (في نظام 32-bit).
كيف ينشأ الـ Stack Frame؟ (Function Prologue)
#include <stdio.h>
int add(int a, int b){
int result = a + b;
return result;
}
int main(){
int x = 5, y = 3;
add(x, y);
return 0;
}
بالتفصيل:
- أولاً تنشأ دالة
main()بإنشاء إطار يحتوي متغيريهاx, y. - عند استدعاء
add()يتوقف تنفيذmain()مؤقتاً، ويُنشأ إطار لـadd()فوق إطارmain()يحتوي: الباراميترات (b=3, a=5)، المتغير المحليresult، وعنوان الإرجاع. - بعد انتهاء
add()يُحذف إطارها، ويعود التنفيذ لـmain()من عنوان الإرجاع.
بالـ Assembly:
; 1. تمرير الباراميترات (من اليمين لليسار)
push 3 ; b = 3
push 5 ; a = 5
; 2. استدعاء الدالة (يضع عنوان الإرجاع تلقائياً)
call add ; push EIP + jmp to add
; 3. Prologue
add:
push ebp ; حفظ إطار المتصل (caller's ebp)
mov ebp, esp ; تأسيس إطار جديد
sub esp, 4 ; تخصيص مساحة للمتغير المحلي result
كيف يُحذف الـ Stack Frame؟ (Function Epilogue)
; 1. تخزين النتيجة في السجل eax
mov eax, [ebp-4] ; eax = result
; 2. خاتمة الدالة (Epilogue)
mov esp, ebp ; إعادة esp إلى موضع ebp (يمسح المتغيرات المحلية)
pop ebp ; استعادة إطار المتصل
ret ; إخراج عنوان الإرجاع من الستاك والقفز إليه
اتفاقيات الاستدعاء (Calling Conventions)
مجموعة قواعد متفق عليها بين المترجم والمجمع تحدد:
- كيفية تمرير الباراميترات للدوال.
- كيفية إرجاع القيم (Return Values).
- مسؤولية تنظيف الستاك بعد الاستدعاء.
- أي السجلات يجب حفظها.
لماذا توجد أنماط مختلفة؟ لتحسين الأداء، وللتكامل مع أنظمة ولغات أخرى، ولدعم حالات خاصة (مثل الدوال ذات الباراميترات المتغيرة).
سجلات الحفظ (مهم)
| النوع | السجلات | من يحفظها |
|---|---|---|
| Caller-saved | EAX, ECX, EDX | المتصل إن احتاجها بعد الاستدعاء |
| Callee-saved | EBX, ESI, EDI, EBP, ESP | المنادى ملزم بحفظها واستعادتها |
1. cdecl (C Declaration)
| النقطة | القاعدة |
|---|---|
| اتجاه الباراميترات | من اليمين إلى اليسار |
| القيمة المرجعة | في EAX |
| تنظيف الستاك | الـ caller |
| السجلات | caller يحفظ caller-saved، والمنادى يحفظ callee-saved |
int __attribute__((cdecl)) add(int a, int b){
return a + b;
}
int result = add(5, 3);
; ينفذ المتصل (Caller)
push 3 ; b = 3
push 5 ; a = 5
call _add
add esp, 8 ; تنظيف الستاك — المتصل هو المسؤول
2. stdcall (Standard Call)
| النقطة | القاعدة |
|---|---|
| اتجاه الباراميترات | من اليمين إلى اليسار |
| القيمة المرجعة | في EAX |
| تنظيف الستاك | الـ callee |
| السجلات | المنادى يحفظ EBP, EBX, EDI, ESI, ESP |
int __attribute__((stdcall)) add(int a, int b){
return a + b;
}
; المتصل
push 3 ; b = 3
push 5 ; a = 5
call _add@8 ; الاسم يشفر عدد البايتات (@8)
; داخل الدالة (المنادى)
ret 8 ; تنظيف الستاك والعودة
3. fastcall (Fast Call)
| النقطة | القاعدة |
|---|---|
| الباراميترات | أول معاملين في ECX, EDX، والباقي على الستاك (يمين→يسار) |
| القيمة المرجعة | في EAX |
| تنظيف الستاك | الـ callee |
| السجلات | المنادى يحفظ السجلات المستخدمة |
int __attribute__((fastcall)) add(int a, int b, int c){
return a + b + c;
}
int result = add(3, 5, 2); // a=3, b=5, c=2
; المتصل
mov ecx, 3 ; a = 3 في ECX
mov edx, 5 ; b = 5 في EDX
push 2 ; c = 2 على الستاك
call @add@12
; داخل الدالة (المنادى)
ret 4 ; تنظيف الستاك (المعامل c فقط)
4. اتفاقية x64 (المعمارية الحديثة)
مهم جداً لمدونتك (ويندوز): في 64-bit لا توجد cdecl/stdcall/fastcall. الاتفاقية الوحيدة:
- الباراميترات الأربعة الأولى في: RCX, RDX, R8, R9، والباقي على الستاك.
- يجب حجز Shadow Space (32 بايت) على الستاك.
- الـ caller ينظف الستاك (باراميترات الـ stack فقط).
- القيمة المرجعة في RAX. عند تحليل binaries حديثة، هذا ما ستستخدمه غالباً.
كيف تختار النمط المناسب؟
- cdecl: للدوال ذات الباراميترات المتغيرة (مثل
printf). - stdcall: لدوال الـ Windows API.
- fastcall: عند الحاجة لأداء سريع (يستخدم السجلات ECX/EDX).
الكومة (Heap)
ليست منطقة ذاكرة منفصلة بل جزء من مساحة عملية الذاكرة (Process Address Space). على عكس الـ Stack (الذي يدار تلقائياً)، الـ Heap تُدار يدوياً من المبرمج، وتستخدم للتخصيص في وقت التشغيل (Runtime).
خصائص الـ Heap
- حجمها متغير (تنمو وتتقلص حسب الحاجة).
- التخصيص يبقى حتى تحريره يدوياً بـ
free(). - حجمها أكبر بكثير من الـ Stack.
الوحدة الأساسية: الكتلة (Chunk)
كل ذاكرة مخصصة في الـ Heap (عبر malloc وغيرها) تُخزن في وحدة تسمى Chunk — حتى لو طلبت مساحة صغيرة (malloc(1))، ستُخصص كتلة كاملة.
هيكل الكتلة
+-------------------+
| mchunk_size | حجم الكتلة الكلي (يشمل الهيدر)
| + flags | 3 بتات معلومات
+-------------------+
| User data | المساحة الفعلية التي يصلها المستخدم
+-------------------+
| padding | حشو لمضاعفات الحجم
+-------------------+
الهيدر (16 بايت في 64-bit):
- mchunk_size: الحجم الكلي للكتلة.
- flags — 3 بتات:
- البت 0 (PREV_INUSE):
1= الكتلة السابقة محجوزة،0= حرة. - البت 1 (IS_MMAPPED):
1= خُصصت عبر mmap،0= عبر sbrk. - البت 2 (NON_MAIN_ARENA):
1= من arena ثانوية (تعدد خيوط)،0= main arena.
- البت 0 (PREV_INUSE):
إذا كانت الكتلة حرة تُخزن معلومات إضافية في الهيدر:
- fd: مؤشر للكتلة الحرة التالية.
- bk: مؤشر للكتلة الحرة السابقة.
آليتا نمو الـ Heap: sbrk و mmap
| sbrk() | mmap() | |
|---|---|---|
| الآلية | يحرك مؤشر Program Break للأعلى | ينشئ منطقة ذاكرة منفصلة عن الـ Heap الرئيسي |
| التحرير | تقلص من الأعلى فقط | عبر munmap(ptr, size) |
| القيود | لا يمكن تحرير أجزاء من الوسط، غير مناسب للخيوط المتعددة | مناسبة للكتل الكبيرة |
| متى | الحجم الافتراضي | للكتل الأكبر من 128KB |
التحولات الهيكلية للـ Heap
- عند التخصيص: الكتلة محجوزة (PREV_INUSE مضبوطة).
- عند التحرير (
free): الكتلة تصبح حرة، وتهيأ الـ fd/bk وتربط في قائمة الكتل الحرة. - عند الدمج (Coalescing): كتلتان حرة متجاورتان تُدمجان في كتلة حرة عملاقة.
حساب حجم الكتلة (نظام 64-bit)
الحجم الكلي = أقرب مضاعف للـ 16 من (حجم الهيدر + الحجم المخصص)
الحشو = الحجم الكلي - (حجم الهيدر + الحجم المخصص)
أمثلة:
malloc(1): 16 + 1 = 17 → أقرب مضاعف للـ 16 = 32 → الحشو = 32 - 17 = 15 بايت.malloc(100): 100 + 16 = 116 → أقرب مضاعف للـ 16 = 128 → الحشو = 128 - 116 = 12 بايت.
ملاحظات هامة:
- الحد الأدنى للحجم الكلي هو 32 بايت حتى لو كان
malloc(0). - الكتل الأكبر من 128KB تُخصص عبر mmap ولها هيدر مختلف عادة (32 بايت).
الفرق بين الـ Stack والـ Heap
| Stack | Heap | |
|---|---|---|
| الإدارة | تلقائية (المعالج) | يدوية (المبرمج) |
| النمو | للأعلى → للأسفل (عناوين منخفضة) | للأسفل → للأعلى (عناوين عالية) |
| السرعة | سريعة (علاقة مباشرة بالـ Registers) | أبطأ |
| الحجم | محدود | كبير |
| نموذج التخزين | LIFO | حر |
| الوحدة الأساسية | Stack Frame | Chunk |
| مدى الحياة | حتى نهاية الدالة | حتى free() |
| الخطر | Buffer Overflow (فوق عنوان العودة) | UAF / Double Free / Heap Overflow |
ربط الـ RE — لماذا هذا المقال كنز
الـ Stack Frame هو أساس Buffer Overflow: عنوان العودة (
[ebp+4]) هو الهدف الأشهر للاستغلال — الكتابة فوقه (عبر buffer overflow) تعيد توجيه التنفيذ إلى shellcode أو ROP chain. كل ما شرحته هنا سيبني عليه مفهوم الاستغلال القادم.الـ Chunk هو أساس Heap Exploitation: الـ fd/bk في الكتل الحرة هي ما تستغله هجمات fastbin/tcache poisoning. سلسلة الـ heap exploitation الكاملة ستبنى على ما كتبته في هذا المقال.
الـ Calling Conventions تميز المحلل الحقيقي: رؤية
add esp, 8بعد call = cdecl، وغيابها = stdcall. هذا الفرق يحدد فوراً نوع الكود الذي تحلله.sbrk/mmap: عند رؤية chunk ضخم في تحليل الـ malloc internals تعرف أنه مخصص بـ mmap — تفصيلة صغيرة تميز المحلل الدقيق.
خلاصة
الـ Stack والـ Heap هما ميدانا المعركة الأساسيان في الـ exploitation. فهم بنيتهما (Stack Frame بـ return address، وChunk بـ fd/bk) هو ما سيمكنك من فهم أي ثغرة ذاكرة ستواجهها في مقالاتنا القادمة — من Buffer Overflow إلى Heap Exploitation.