P01NT
0x11 الذاكرة 12/19

الذاكرة الظاهرية: Stack مقابل Heap

بنية الـ Stack Frame والسجلات واتفاقيات الاستدعاء، وChunks الـ Heap — الميدانان الأساسيان للاستغلال.

· 8 دقائق قراءة · 1701 كلمة
المحتويات

بسم الله الرحمن الرحيم.

في هذا المقال سنتحدث عن تصميم الذاكرة الخاص بالـ Stack والـ Heap، ونوضح نقاط الاختلاف بينهما.

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

تخطيط الذاكرة (Memory Layout)

كما ذكرنا في مقال المؤشرات، ذاكرة البرامج في الأنظمة الحديثة x86/x64 تنقسم إلى 5 أقسام رئيسية (والبعض يقول 4 بدمج Data مع BSS):

  1. Text Segment — التعليمات التنفيذية (للقراءة فقط).
  2. Data Segment — المتغيرات العامة المهيأة.
  3. BSS Segment — المتغيرات العامة غير المهيأة.
  4. Heap — التخصيص الديناميكي.
  5. Stack — المتغيرات المحلية وإطارات الدوال.
+-----------------+  عناوين عالية
|      Stack      |  ينمو للأسفل
|        |        |
|        v        |
+-----------------+
|      (فجوة)     |
+-----------------+
|        ^        |
|        |        |
|      Heap       |  ينمو للأعلى
+-----------------+
|      BSS        |
|     Data        |
|     Text        |
+-----------------+  عناوين منخفضة

نتعامل هنا مع الـ User Space فقط — الـ Kernel Space موضوع آخر.

السبب في اتجاهي النمو المتعاكسين: حتى ينمو الـ Stack والـ Heap دون تصادم — كل منهما يتوسع في اتجاه يبتعد عن الآخر.

لماذا الـ Stack أسرع؟ السر في الـ Registers

الـ Stack يُنظم تلقائياً بواسطة المعالج، وأداؤه سريع. السر في ذلك أن علاقة الـ Stack بالـ Registers مباشرة — على عكس الـ Heap الذي يتعامل معها بطريقة غير مباشرة.

أهم السجلات (Registers)

السجلات وحدات تخزينية صغيرة داخل الـ Processor تستخدم في العمليات الحسابية التي تتطلب سرعة عالية:

السجلالاسم الكاملالوظيفة
EAXAccumulatorالاحتفاظ بقيمة الإرجاع (return value) للدوال، والعمليات الحسابية
EBPBase Pointerعنوان قاعدة إطار الـ Stack الحالي
ESPStack Pointerعنوان قمة الـ Stack الحالي
EIPInstruction Pointerعنوان التعليمة القادمة التي سينفذها المعالج
ECXCounterعدّاد في العمليات المتكررة (مثل الـ loop)
EDXDataقرين EAX في الضرب والقسمة، والاحتفاظ بالبيانات
EBXBaseلا وظيفة محددة — يحفظ البيانات في سياقات متعددة
ESI/EDISource/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 عند استدعاء دالة، وتحذف عند انتهائها. هدفها عزل كل دالة عن الأخرى — كل استدعاء ينشئ إطاراً خاصاً. مكوناتها:

  1. الباراميترات (Parameters) — القيم المدخلة للدالة.
  2. عنوان العودة (Return Address) — أين يعود البرنامج بعد انتهاء الدالة.
  3. مؤشر الإطار (Frame Pointer) — قاعدة إطار الـ Stack الحالي.
  4. المتغيرات المحلية (Local Variables) — متغيرات الدالة الحالية.
  5. السجلات المحفوظة (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)

مجموعة قواعد متفق عليها بين المترجم والمجمع تحدد:

  1. كيفية تمرير الباراميترات للدوال.
  2. كيفية إرجاع القيم (Return Values).
  3. مسؤولية تنظيف الستاك بعد الاستدعاء.
  4. أي السجلات يجب حفظها.

لماذا توجد أنماط مختلفة؟ لتحسين الأداء، وللتكامل مع أنظمة ولغات أخرى، ولدعم حالات خاصة (مثل الدوال ذات الباراميترات المتغيرة).

سجلات الحفظ (مهم)

النوعالسجلاتمن يحفظها
Caller-savedEAX, ECX, EDXالمتصل إن احتاجها بعد الاستدعاء
Callee-savedEBX, 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.

إذا كانت الكتلة حرة تُخزن معلومات إضافية في الهيدر:

  • fd: مؤشر للكتلة الحرة التالية.
  • bk: مؤشر للكتلة الحرة السابقة.

آليتا نمو الـ Heap: sbrk و mmap

sbrk()mmap()
الآليةيحرك مؤشر Program Break للأعلىينشئ منطقة ذاكرة منفصلة عن الـ Heap الرئيسي
التحريرتقلص من الأعلى فقطعبر munmap(ptr, size)
القيودلا يمكن تحرير أجزاء من الوسط، غير مناسب للخيوط المتعددةمناسبة للكتل الكبيرة
متىالحجم الافتراضيللكتل الأكبر من 128KB

التحولات الهيكلية للـ Heap

  1. عند التخصيص: الكتلة محجوزة (PREV_INUSE مضبوطة).
  2. عند التحرير (free): الكتلة تصبح حرة، وتهيأ الـ fd/bk وتربط في قائمة الكتل الحرة.
  3. عند الدمج (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

StackHeap
الإدارةتلقائية (المعالج)يدوية (المبرمج)
النموللأعلى → للأسفل (عناوين منخفضة)للأسفل → للأعلى (عناوين عالية)
السرعةسريعة (علاقة مباشرة بالـ Registers)أبطأ
الحجممحدودكبير
نموذج التخزينLIFOحر
الوحدة الأساسيةStack FrameChunk
مدى الحياةحتى نهاية الدالةحتى free()
الخطرBuffer Overflow (فوق عنوان العودة)UAF / Double Free / Heap Overflow

ربط الـ RE — لماذا هذا المقال كنز

  1. الـ Stack Frame هو أساس Buffer Overflow: عنوان العودة ([ebp+4]) هو الهدف الأشهر للاستغلال — الكتابة فوقه (عبر buffer overflow) تعيد توجيه التنفيذ إلى shellcode أو ROP chain. كل ما شرحته هنا سيبني عليه مفهوم الاستغلال القادم.

  2. الـ Chunk هو أساس Heap Exploitation: الـ fd/bk في الكتل الحرة هي ما تستغله هجمات fastbin/tcache poisoning. سلسلة الـ heap exploitation الكاملة ستبنى على ما كتبته في هذا المقال.

  3. الـ Calling Conventions تميز المحلل الحقيقي: رؤية add esp, 8 بعد call = cdecl، وغيابها = stdcall. هذا الفرق يحدد فوراً نوع الكود الذي تحلله.

  4. sbrk/mmap: عند رؤية chunk ضخم في تحليل الـ malloc internals تعرف أنه مخصص بـ mmap — تفصيلة صغيرة تميز المحلل الدقيق.

خلاصة

الـ Stack والـ Heap هما ميدانا المعركة الأساسيان في الـ exploitation. فهم بنيتهما (Stack Frame بـ return address، وChunk بـ fd/bk) هو ما سيمكنك من فهم أي ثغرة ذاكرة ستواجهها في مقالاتنا القادمة — من Buffer Overflow إلى Heap Exploitation.