فاز 13 · درس 32

کالبدشناسی فایل باینری: ساخت خوانشگر ELF از صفر با پایتون

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python (stdlib), C (fixture only)
پیش‌نیاز
فاز 0 · درس 01 (محیط توسعه) همراه با clang نصب‌شده، پایتون 3.10 یا جدیدتر، و آشنایی با محاسبات مبنای 16 (Hexadecimal)
طرح شماتیک لایه‌های جدول‌های داده‌ای ساختار باینری در کنار نشانگرهای آفست هگزادسیمال
فهرست مطالب
  1. اهداف یادگیری
  2. پیش از شروع: مجوزها و قوانین حقوقی
  3. صورت مسئله
  4. ساختار فایل: جدول‌هایی که به جدول‌های دیگر اشاره دارند
  5. هدرهای بخش‌ها و دو جدول رشته‌ها
  6. نمادها: پیوند، نوع، بخش، مقدار، اندازه
  7. بازآدرس‌دهی‌ها: معنای بایت‌های صفر
  8. هدرهای برنامه: نمای اجرا برای بارگذار سیستم
  9. همین مفاهیم در قالب ساختار Mach-O
  10. پیاده‌سازی از صفر
  11. استفاده از ابزارهای واقعی
  12. کارگاه تعاملی
  13. بخش اول: بررسی دستی هدر فایل
  14. بخش دوم: جدول مقادیر پشت ساختار switch
  15. بخش سوم: مقایسه‌ی سه بیلد مختلف از یک سورس‌کد
  16. بخش چهارم: بررسی همان تابع در فرمت Mach-O
  17. تمرین عملی
  18. تمرین ارزیابی‌شده
  19. مشاهده‌ی نحوه‌ی اجرا
  20. خروجی این درس
  21. اعتبارسنجی
  22. حالت‌های شکست در محیط عملیاتی
  23. قواعد عملیاتی
  24. برای مطالعه‌ی بیشتر

بسیاری از برنامه‌نویسان باینری‌های کامپایل‌شده را جعبه‌های سیاهی از بایت‌های غیرقابل‌فهم می‌دانند و برای درک محتوای آن‌ها به ابزارهای سنگینی مانند Ghidra یا متغیرهای وابسته‌ی محیطی متکی می‌شوند. در محیط‌های توسعه و سرورها، راه‌اندازی این ابزارهای پیچیده نیازمند ملزومات محیطی متعدد است، در حالی که درک دقیق مدل داده‌ای پشت باینری‌ها همه‌چیز را شفاف می‌کند.

یک فایل باینری کامپایل‌شده در حقیقت مجموعه‌ای ساخت‌یافته از جدول‌های متصل به هم است. با نوشتن یک خوانشگر مستقل با کتابخانه‌ی استاندارد پایتون، نه‌تنها از ابزارهای خارجی بی‌نیاز می‌شوید، بلکه متوجه خواهید شد کد ماشین چطور در کنار جدول‌های نمادها و اطلاعات بازآدرس‌دهی سازمان‌دهی می‌شود.

برنامه‌ی کامپایل‌شده مجموعه‌ای از جدول‌ها است که به یکدیگر ارجاع می‌دهند. ابزار خوانشگری که خودتان نوشته‌اید خروجی مشخصی تولید می‌کند که می‌توانید آن را بایت‌به‌بایت بررسی کنید.

اهداف یادگیری

  • بیان قاعده‌ی مجوز دسترسی برای تمام اهداف این مسیر آموزشی و محدودیت‌های چهارگانه‌ی بند 17 U.S.C. 1201(f).
  • خواندن هدر ELF، جدول هدر بخش‌ها و هر دو جدول رشته‌ها با struct.unpack.
  • رمزگشایی (Decode) درایه‌های .symtab و رکوردهای .rela بر اساس نام‌گذاری‌های استاندارد gABI و x86-64 psABI.
  • بازگرداندن بایت‌های دقیق یک تابع مشخص و نگاشت تک‌تک آفست‌های فایل به ساختار مالک آن.
  • خواندن هدرهای برنامه و نگاشت یک آدرس مجازی به آفست فایل از طریق PT_LOAD.
  • خواندن فرمان‌های بارگذاری (Load commands) در یک آبجکت‌فایل Mach-O و انطباق مفهومی هرکدام با ساختارهای متناظر در ELF.

پیش از شروع: مجوزها و قوانین حقوقی

تمام اهداف بررسی‌شده در درس‌های 32 تا 36 برنامه‌هایی هستند که خودتان نوشته و کامپایل کرده‌اید. سورس‌کد در فایل code/fixtures/ledger.c قرار دارد و شامل چهار تابع کوتاه به زبان C است. شما مالک سورس‌کد، دستور کامپایلر و فایل‌های خروجی کامپایل‌شده (Object files) حاصل از آن هستید. در اینجا هیچ مجوزی برای خواندن، هیچ قفل نرم‌افزاری برای رد کردن، و هیچ کد متعلق به شخص ثالثی وجود ندارد. این مسیر آموزشی هرگز به موضوعاتی مثل محافظت‌های نرم‌افزاری، بررسی لایسنس یا برنامه‌هایی که متعلق به شما نیست نمی‌پردازد.

این قاعده از آنجا ناشی می‌شود که قانون، فرایند دیس‌اسمبلی (Disassembly) را نوعی نسخه‌برداری در نظر می‌گیرد. در پرونده‌ی Sega Enterprises Ltd. v. Accolade, Inc., 977 F.2d 1510 (9th Cir. 1992)، دادگاه حکم داد نسخه‌های میانی ایجادشده در جریان دیس‌اسمبلی، طبق قانون کپی‌رایت مصداق نسخه‌برداری هستند. دادگاه در ادامه اعلام کرد: «چنانچه دیس‌اسمبلی تنها راه دسترسی به ایده‌ها و عناصر عملکردی موجود در یک برنامه‌ی رایانه‌ای دارای حق نشر باشد و دلیل موجهی برای این دسترسی وجود داشته باشد، دیس‌اسمبلی از نظر حقوقی مصداق استفاده‌ی منصفانه (Fair use) از اثر محافظت‌شده است.»

پرونده‌ی دوم Sony Computer Entertainment, Inc. v. Connectix Corp., 203 F.3d 596 (9th Cir. 2000) است. شرکت Connectix هنگام ساخت یک شبیه‌ساز (Emulator)، از بایوس پلی‌استیشن کپی‌برداری کرد و دادگاه این نسخه‌برداری‌ها را مصداق استفاده‌ی منصفانه دانست. در هر دو پرونده، کپی‌برداری با هدف تحقیق و مطالعه مورد حمایت قرار گرفت؛ اما هیچ‌یک از این احکام شامل استفاده از کدهای کپی‌شده در محصول نهایی نمی‌شدند.

دو ماده‌ی قانونی این مرزها را تعیین می‌کنند. بند 17 U.S.C. 1201(f) به شخصی که «حق استفاده از یک نسخه‌ی برنامه‌ی رایانه‌ای را به‌صورت قانونی به دست آورده است» اجازه می‌دهد تدابیر کنترل دسترسی را دور بزند، اما مشروط به اینکه «صرفاً با هدف شناسایی و تحلیل آن دسته از عناصر برنامه باشد که برای دستیابی به سازگاری عملیاتی (Interoperability) یک برنامه‌ی مستقل با سایر برنامه‌ها ضروری هستند». چهار محدودیت متوالی اعمال می‌شوند: نسخه‌ی قانونی، هدف سازگاری عملیاتی، کدهایی که پیش‌تر «به‌سادگی در دسترس» نبوده‌اند، و تحلیلی که در سایر جنبه‌ها ناقض حقوق نباشد. بخش 1201(f) صرفاً استثنایی بر قاعده‌ی ممنوعیت دور زدن تدابیر فنی است؛ مسئله‌ی نسخه‌برداری همچنان تابع قواعد استفاده‌ی منصفانه باقی می‌ماند.

در اتحادیه‌ی اروپا، بند 5(3) از دستورالعمل Directive 2009/24/EC ناظر بر مطالعه‌ی نرم‌افزار است. فردی که حق استفاده از یک نسخه را دارد می‌تواند «عملکرد برنامه را برای تعیین ایده‌ها و اصولی که زیربنای هر عنصر از برنامه است، مشاهده، بررسی یا آزمایش کند». این حق هنگام بارگذاری، نمایش، اجرا، انتقال یا ذخیره‌سازی برنامه اعمال می‌شود.

ماده‌ی 6 دیکامپایل (Decompilation) را بدون اجازه‌ی دارنده‌ی حق نشر مجاز می‌داند، مشروط بر اینکه «برای به دست آوردن اطلاعات ضروری جهت دستیابی به سازگاری عملیاتی یک برنامه‌ی مستقل، غیرقابل چشم‌پوشی باشد». شروط آن با بند 1201(f) همخوانی دارد: اقدامات توسط دارنده‌ی لایسنس انجام شود، اطلاعات قبلاً به‌سادگی در دسترس نبوده باشد، و اقدامات صرفاً به بخش‌های مورد نیاز محدود بماند. ماده‌ی 6(2) استفاده از نتیجه را برای اهداف دیگر یا برای برنامه‌ای که در بیان به‌صورت اساسی مشابه باشد ممنوع می‌کند. طبق ماده‌ی 8، هر شرط قراردادی که مغایر با ماده‌ی 6 یا بند 5(3) باشد، باطل و بلااثر است.

منبع آنچه مجاز است شروط
بند 17 U.S.C. 1201(f) دور زدن کنترل دسترسی برای تحلیل برنامه نسخه‌ی قانونی، هدف سازگاری عملیاتی، عدم دسترسی ساده به اطلاعات، عدم نقض قوانین دیگر
دستورالعمل 2009/24/EC بند 5(3) مشاهده، بررسی و آزمایش یک برنامه‌ی در حال اجرا داشتن حق استفاده از نسخه، اقدام در چارچوب فرایند عادی بارگذاری و اجرا
دستورالعمل 2009/24/EC ماده‌ی 6 دیکامپایل کردن دارنده‌ی لایسنس، هدف سازگاری عملیاتی، عدم دسترسی ساده به اطلاعات، محدود بودن به بخش‌های مورد نیاز
Sega v. Accolade دیس‌اسمبلی به عنوان استفاده‌ی منصفانه تنها راه دسترسی به ایده‌ها و عناصر عملکردی، وجود دلیل موجه
Sony v. Connectix نسخه‌برداری‌های میانی حین مهندسی معکوس غیرناقض بودن محصول نهایی

این نکات مستقیماً از متن قوانین و آرای قضایی استخراج شده‌اند و مشاوره‌ی حقوقی به شمار نمی‌روند. اگر در آینده هدفی متعلق به شما نبود، حتماً پیش از باز کردن فایل، مجوز کتبی بگیرید و آن را در کنار شواهد (Evidence) خود نگه دارید. بخش 2.1 از راهنمای Reverse Engineering 101 ارجاعات کامل و پرونده‌های دادگاه دادگستری اروپا (CJEU) را در اختیارتان می‌گذارد.

صورت مسئله

فایل ledger.c را کامپایل کرده‌اید و دستور llvm-objdump -t را روی فایل آبجکت اجرا کرده‌اید. خروجی، تابع percent_change را با مقدار 0000000000000000 و اندازه‌ی 0000000000000010 لیست می‌کند. کدام 16 بایت از این فایل 1,768 بایتی متعلق به این تابع است؟ این فهرست همچنین رکورد R_X86_64_PC32 .rodata+0x10 را در آفست 0x36 نشان می‌دهد و چهار بایت موجود در آن نقطه از فایل صفر هستند. این بایت‌ها در نهایت به چه چیزی تبدیل می‌شوند و چرا وقتی جدول از 0x14 شروع می‌شود، مقدار افزوده (Addend) برابر با 0x10 است؟

ابزار rea به پرسش‌های مربوط به چیدمان حافظه از طریق inspect_binary_layout پاسخ می‌دهد؛ ابزاری که تنها روی Linux x64 با نسخه‌ی pwntools 4.15.0 از طریق متغیر محیطی REA_PWNTOOLS_PYTHON در دسترس است. این ابزار روی سیستم Mac وجود ندارد. دستور address_to_file_offset نیز نیازمند یک نشست اجرایی در Ghidra و یک کپی کامل از بایت‌های فایل اصلی با هش SHA-256 یکسان است. ابزار inspect_artifact نیز صرفاً محتوای نگه‌دارنده (Container) را بر اساس مسیر و هش محتوا فهرست می‌کند و هرگز جداول داخلی یک آبجکت‌فایل را باز نمی‌کند. بنابراین باید خوانشگر را خودتان در حدود 950 خط کد پایتون با struct.unpack و بدون هیچ کتابخانه‌ی جانبی بنویسید. تست‌ها تک‌تک جدول‌ها را با متون خروجی llvm-objdump که قبلاً در قالب فایل‌های آزمایشی (Fixtures) ذخیره شده‌اند، مقایسه می‌کنند.

نقشه بایت‌هاروی یک ساختار یا نماد کلیک کنید
Byte map of ledger-x86_64-O1.oThe 1768 bytes of the object file drawn to scale. Selected: percent_change, 16 bytes at offset 0x40.ELF header 0x0 +64.text 0x40 +159.rodata 0xe0 +80.rodata.str1.1 0x130 +10.comment 0x13a +47.symtab 0x170 +312.rela.text 0x2a8 +72.rela.rodata 0x2f0 +120.strtab 0x368 +190section header table 0x428 +704percent_change 0x40 +16
این نوار کل فایل شیء (object file) را با مقیاس واقعی از آفست 0x0 در سمت چپ تا آخرین بایت در سمت راست رسم می‌کند. رنگ آبی ساختار یا تابع انتخاب‌شده است، آبی روشن بخشی (section) است که تابع انتخاب‌شده را نگه می‌دارد، کادرهای خاکستری سایر ساختارها هستند و فاصله‌های خالی فضاهای غیرفعال‌اند. سطر‌های زیر نوار، بایت‌های واقعی انتخاب را نشان می‌دهند؛ 16 بایت در هر سطر همراه با آفست فایل در سمت چپ.

ساختار فایل: جدول‌هایی که به جدول‌های دیگر اشاره دارند

راهنمای استاندارد System V gABI در فصل 4، شکل 4-1، دو نما از یک فایل را ترسیم می‌کند. «نمای پیوند» (Linking view) شامل یک هدر ELF، یک جدول هدر برنامه‌ی اختیاری، بخش‌ها (Sections) و یک جدول هدر بخش‌های الزامی است. «نمای اجرا» (Execution view) شامل یک هدر ELF، جدول هدر برنامه‌ی الزامی، سگمنت‌ها (Segments) و یک جدول هدر بخش‌های اختیاری است. gABI جمله‌ای کلیدی اضافه می‌کند که بر کل ساختار خوانشگر ما حاکم است: «تنها هدر ELF جایگاه ثابتی در فایل دارد». هر ساختار دیگری با آفستی پیدا می‌شود که در ساختار دیگری ذخیره شده است.

همچنین gABI انواع اسکالر را مشخص کرده و بیان می‌کند: «ELF از فیلدهای بیتی (Bit-fields) استفاده نمی‌کند». اندازه‌ی فیلدهای Elf64_Addr و Elf64_Off برابر با 8 بایت، Elf64_Half برابر با 2 بایت، Elf64_Word برابر با 4 بایت و Elf64_Xword برابر با 8 بایت است. استاندارد x86-64 psABI در بخش 4.1.1 مواردی شامل ELFCLASS64، ترتیب بایت ELFDATA2LSB، مقدار EM_X86_64 (62) برای e_machine و مقدار 0 برای e_flags را الزامی می‌داند. ترکیب اندیان کوچک (Little-endian) با طول‌های ثابت به این معناست که می‌توان با استفاده از struct.unpack و پیشوند <، هر رکورد را با یک فراخوانی خواند.

64 بایت اول فایل ledger-x86_64-O1.o به این صورت است:

00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0100 3e00 0100 0000 0000 0000 0000 0000  ..>.............
00000020: 0000 0000 0000 0000 2804 0000 0000 0000  ........(.......
00000030: 0000 0000 4000 0000 0000 4000 0b00 0100  ....@.....@.....

این مقادیر را با صفحه‌ی "ELF Header" در gABI تطبیق دهید. فیلد e_ident شامل 16 بایت است: 7f 45 4c 46 (بایت‌های جادویی)، کلاس 2 (64 بیتی)، داده‌ی 1 (Endianness کوچک)، نسخه‌ی 1، شناسه‌ی OS ABI برابر با 0 (System V) و پدینگ (حاشیه‌گذاری).

سپس فیلد e_type با مقدار 0x0001 نشان‌دهنده‌ی ET_REL (آبجکت‌فایل بازآدرس‌پذیر) است. مقدار e_machine یعنی 0x003e برابر با 62 یا EM_X86_64 است. مقدار e_entry صفر است زیرا یک آبجکت‌فایل نقطه‌ی ورود ندارد. مقدار e_phoff صفر است زیرا آبجکت‌فایل فاقد هدرهای برنامه است. مقدار e_shoff برابر با 0x428، آفست جدول هدر بخش‌ها در فایل است. ردیف آخر مقادیر e_ehsize برابر با 0x40، e_phentsize برابر با 0، e_phnum برابر با 0، e_shentsize برابر با 0x40، e_shnum برابر با 0x0b (11 هدر) و e_shstrndx برابر با 0x0001 را در خود جای داده است.

برنامه‌ی خوانشگر این ردیف را در قالب یک فراخوانی پیاده می‌کند:

fields = struct.unpack_from("<HHIQQQIHHHHHH", data, 16)

رشته‌ی فرمت دقیقاً همان نمودار gABI است که از چپ به راست خوانده می‌شود: دو نیم‌کلمه (Halfword)، یک کلمه (Word)، سه مقدار 8 بایتی، یک کلمه و شش نیم‌کلمه. خوانشگر فایلی را که بایت‌های جادویی، کلاس یا ترتیب بایت متفاوتی داشته باشد نمی‌پذیرد، و مقادیر e_shentsize یا e_phentsize غیر از 64 و 56 را نیز رد می‌کند. هرگونه اندازه‌ی نادرست باعث خطا در تمام آفست‌های بعدی می‌شود؛ بنابراین این بررسی در ابتدای کد قرار می‌گیرد.

هدرهای بخش‌ها و دو جدول رشته‌ها

هر هدر بخش، ساختاری از نوع Elf64_Shdr به طول 64 بایت است (شکل 4-8 در gABI): فیلدهای sh_name، sh_type، sh_flags، sh_addr، sh_offset، sh_size، sh_link، sh_info، sh_addralign و sh_entsize. رشته‌ی فرمت آن <IIQQQQIIQQ است. فیلد sh_name آفستی درون بخشی است که ایندکس آن در e_shstrndx مشخص شده و این نام با اولین بایت صفر پایان می‌یابد. در این آبجکت‌فایل، e_shstrndx برابر با 1 است و clang آن بخش را .strtab نام‌گذاری کرده؛ در نتیجه یک جدول واحد هم نام بخش‌ها و هم نام نمادها (Symbols) را پوشش می‌دهد.

خوانشگر، 11 هدر این آبجکت کامپایل‌شده با بهینه‌سازی -O1 را چاپ می‌کند:

Idx Name             Type         Flags   Offset     Size       Addr Link Info Align
  0                  NULL                    0x0      0x0        0x0    0    0     0
  1 .strtab          STRTAB                0x368     0xbe        0x0    0    0     1
  2 .text            PROGBITS     AX        0x40     0x9f        0x0    0    0    16
  3 .rela.text       RELA         I        0x2a8     0x48        0x0   10    2     8
  4 .rodata          PROGBITS     A         0xe0     0x50        0x0    0    0     8
  5 .rela.rodata     RELA         I        0x2f0     0x78        0x0   10    4     8
  6 .rodata.str1.1   PROGBITS     AMS      0x130      0xa        0x0    0    0     1
  7 .comment         PROGBITS     MS       0x13a     0x2f        0x0    0    0     1
  8 .note.GNU-stack  PROGBITS              0x169      0x0        0x0    0    0     1
  9 .llvm_addrsig    LLVM_ADDRSIG E        0x368      0x0        0x0   10    0     1
 10 .symtab          SYMTAB                0x170    0x138        0x0    1    9     8

ایندکس 0 هدر تهی (Null header) رزروشده است. بخش .text شامل 159 بایت کد در آفست 0x40 فایل (دقیقاً پس از هدر) است که پرچم‌های A (تخصیص‌یافته در حافظه) و X (قابل اجرا) دارد. بخش .rodata شامل 80 بایت داده‌ی فقط‌خواندنی است. بخش .rodata.str1.1 شامل پنج رشته‌ی برچسب F\0D\0C\0B\0A\0 با طول 10 بایت است و پرچم‌های M (قابل merge) و S (رشته‌ها) را دارد.

بخش .comment شامل امضای کامپایلر به طول 47 بایت یعنی \0Apple clang version 17.0.0 (clang-1700.6.3.2)\0 است. بخش .llvm_addrsig دارای نوع 0x6fff4c03 است که در محدوده‌ی اختصاصی سیستم‌عامل یعنی بین SHT_LOOS و SHT_HIOS قرار می‌گیرد که توسط gABI رزرو شده و فایل ELF.h متعلق به LLVM آن را نام‌گذاری کرده است. پرچم E در آن نشان‌دهنده‌ی SHF_EXCLUDE است؛ یعنی پیونددهنده (Linker) آن را کنار می‌گذارد.

ستون‌های sh_link و sh_info همان اشاره‌گرهای میان جداول هستند. بخش .rela.text دارای sh_info برابر با 2 و sh_link برابر با 10 است؛ به این معنی که بخش 2 را تکه (Patch) می‌زند و نمادهای خود را از بخش 10 می‌گیرد. بخش .symtab دارای sh_link برابر با 1 (جدول رشته‌ها برای نام نمادها) و sh_info برابر با 9 (ایندکس اولین نماد غیرمحلی) است. مقدار sh_addr در تمامی سطرها صفر است؛ چرا که آبجکت‌فایل هنوز آدرسی در حافظه ندارد و پیونددهنده این آدرس‌ها را تخصیص می‌دهد.

دو قاعده‌ی gABI به خوانشگر اجازه می‌دهند نقشه‌ی کاملی از فایل ترسیم کند. بخش‌ها «پیوسته در حافظه هستند و با یکدیگر همپوشانی ندارند» و فایل «ممکن است حاوی فضای غیرفعال باشد». خوانشگر تمام ساختارها را بر اساس آفست مرتب می‌کند و فاصله‌های خالی را پر می‌کند:

     0x0 +64    header   ELF header
    0x40 +159   section  .text
    0xdf +1     gap      inactive space
    0xe0 +80    section  .rodata
   0x130 +10    section  .rodata.str1.1
   0x13a +47    section  .comment
   0x169 +7     gap      inactive space
   0x170 +312   section  .symtab
   0x2a8 +72    section  .rela.text
   0x2f0 +120   section  .rela.rodata
   0x368 +190   section  .strtab
   0x426 +2     gap      inactive space
   0x428 +704   table    section header table

این سه فاصله‌ی خالی در مجموع 10 بایت هستند و همگی ناشی از تراز حافظه (Alignment) هستند. بخش .rodata دارای sh_addralign برابر با 8 است؛ بنابراین به‌جای 0xdf از آفست 0xe0 آغاز می‌شود. بخش .symtab ترازی برابر با 8 دارد، بنابراین 7 بایت بدون استفاده پس از .comment باقی می‌ماند. جدول هدر بخش‌ها نیز روی مرز 8 بایتی تراز شده است.

بخش‌هایی از نوع SHT_NOBITS هیچ بایتی از فایل را اشغال نمی‌کنند؛ در نتیجه نقشه‌ی حافظه آن‌ها را در نظر نمی‌گیرد. هیچ عنصری در این آبجکت‌فایل چنین نوعی ندارد. بخش .bss در یک برنامه‌ی پیوندخورده از این نوع است و اگر خوانشگری در آنجا به مقدار sh_size اتکا کند، داده‌هایی فراتر از انتهای فایل را خواهد خواند.

نمادها: پیوند، نوع، بخش، مقدار، اندازه

هر رکورد Elf64_Sym برابر با 24 بایت است (شکل 4-18 در gABI): شامل فیلدهای st_name، st_info، st_other، st_shndx، st_value، st_size با فرمت <IBBHQQ. فیلد st_info دو بخش را در خود فشرده است: نوع پیوند (Binding) در 4 بیت بالا و نوع نماد در 4 بیت پایین. راهنمای gABI پیوندها را با نام‌های STB_LOCAL (0)، STB_GLOBAL (1)، STB_WEAK (2) و انواع نماد را با نام‌های STT_NOTYPE (0)، STT_OBJECT (1)، STT_FUNC (2)، STT_SECTION (3)، STT_FILE (4) مشخص می‌کند. 2 بیت پایینی st_other نمایانگر سطح دید (Visibility) است که در اینجا در تمام موارد برابر با STV_DEFAULT (0) است.

Idx            Value   Size Bind   Type    Section          Name
  0              0x0      0 LOCAL  NOTYPE  *UND*
  1              0x0      0 LOCAL  FILE    *ABS*            ledger.c
  2              0x0     20 LOCAL  OBJECT  .rodata          labels.rel
  3              0x0      0 LOCAL  SECTION .rodata          .rodata
  4              0x2      2 LOCAL  OBJECT  .rodata.str1.1   .L.str.1
  5              0x4      2 LOCAL  OBJECT  .rodata.str1.1   .L.str.2
  6              0x6      2 LOCAL  OBJECT  .rodata.str1.1   .L.str.3
  7              0x8      2 LOCAL  OBJECT  .rodata.str1.1   .L.str.4
  8              0x0      0 LOCAL  SECTION .rodata.str1.1   .rodata.str1.1
  9              0x0     16 GLOBAL FUNC    .text            percent_change
 10             0x10     46 GLOBAL FUNC    .text            grade
 11             0x40     31 GLOBAL FUNC    .text            total
 12             0x60     63 GLOBAL FUNC    .text            grade_label

ایندکس 0 نماد تعریف‌نشده‌ی رزروشده است. فیلد st_shndx بخشی را تعیین می‌کند که نماد را تعریف کرده است و سه مقدار رزروشده دارد: SHN_UNDEF با مقدار 0، SHN_ABS با مقدار 0xfff1 برای نام فایل، و SHN_COMMON با مقدار 0xfff2. نمادهای مربوط به بخش‌ها نام اختصاصی ندارند؛ بنابراین llvm-objdump نام همان بخش را برای آن‌ها چاپ می‌کند و خوانشگر ما نیز همین کار را انجام می‌دهد. قاعده‌ی gABI مبنی بر اینکه «تمام نمادهای دارای پیوند STB_LOCAL قبل از نمادهای ضعیف (Weak) و سراسری (Global) می‌آیند» در 9 سطر اول دیده می‌شود، و بخش .symtab دقیقاً به همین علت مقدار 9 را در sh_info ثبت کرده است.

در یک فایل بازآدرس‌پذیر، st_value نمایانگر آفست درون همان بخش مربوط به نماد است، زیرا مقدار sh_addr صفر است. در نتیجه، آفست فایل برای یک تابع از رابطه‌ی sh_offset + st_value - sh_addr به دست می‌آید و طول آن همان st_size است:

section = elf.sections[symbol.st_shndx]
file_offset = section.sh_offset + (symbol.st_value - section.sh_addr)
code = elf.data[file_offset : file_offset + symbol.st_size]

برای تابع percent_change این مقدار برابر با 0x40 + 0 است و آن 16 بایت عبارتند از: 85 ff 74 09 29 fe 6b c6 64 99 f7 ff c3 31 c0 c3. اندازه‌ها به اندازه‌ی نقطه‌ی شروع اهمیت دارند. تابع grade در آفست 0x3e از بخش .text به پایان می‌رسد. دو بایت بعدی یعنی 66 90 در واقع دستور nop هستند که کامپایلر اضافه کرده است تا تابع بعدی یعنی total در مرز 16 بایتی یعنی آفست 0x40 آغاز شود. این دو بایت متعلق به هیچ نمادی نیستند. درس 33 از st_size به عنوان شرط توقف دیس‌اسمبلر استفاده می‌کند و درس 36 پیش از مقایسه‌ی نسخه‌ی بازسازی‌شده، این بایت‌ها را ماسک می‌کند.

بازآدرس‌دهی‌ها: معنای بایت‌های صفر

هر رکورد Elf64_Rela دارای 24 بایت است (شکل 4-23 در gABI): شامل r_offset، r_info و مقدار علامت‌دار r_addend با فرمت <QQq. استاندارد gABI فیلد r_info را با دو ماکرو تفکیک می‌کند: ماکروی ELF64_R_SYM(i) معادل i >> 32 و ماکروی ELF64_R_TYPE(i) معادل i & 0xffffffff. در یک فایل بازآدرس‌پذیر، r_offset آفستی درون بخشی است که مقدار sh_info در این جدول به آن اشاره دارد. نام انواع و فرمول‌ها برگرفته از جدول 4.9 در x86-64 psABI است. در این جدول، S مقدار نماد، A مقدار افزوده (Addend)، P مکان اعمال پچ و L درایه‌ی PLT است. نوع R_X86_64_64 برابر با S + A، نوع R_X86_64_PC32 برابر با S + A - P و نوع R_X86_64_PLT32 برابر با L + A - P محاسبه می‌شود.

فایل حاصل از بهینه‌سازی -O1 هشت رکورد دارد:

  .rela.text       0x36 R_X86_64_PC32          .rodata+0x10
  .rela.text       0x84 R_X86_64_PC32          .rodata+0x24
  .rela.text       0x93 R_X86_64_PC32          .rodata-0x4
  .rela.rodata      0x0 R_X86_64_PC32          .rodata.str1.1
  .rela.rodata      0x4 R_X86_64_PC32          .L.str.1+0x4
  .rela.rodata      0x8 R_X86_64_PC32          .L.str.2+0x8
  .rela.rodata      0xc R_X86_64_PC32          .L.str.3+0xc
  .rela.rodata     0x10 R_X86_64_PC32          .L.str.4+0x10

رکورد اول را بررسی کنیم. آفست 0x36 درون تابع grade قرار دارد که از 0x10 تا 0x3e کشیده شده است. دستورالعمل موجود در آفست 0x33 مقدار 48 8d 0d 00 00 00 00 است که یک دستور lea با آدرس‌دهی نسبی به ثبات RIP محسوب می‌شود، و جابه‌جایی (Displacement) چهار بایتی آن در آفست 0x36 می‌نشیند. پردازنده این جابه‌جایی را به آدرس دستورالعمل بعدی، یعنی 0x3a، اضافه می‌کند.

کامپایلر می‌خواهد نتیجه به .rodata + 0x14 اشاره کند؛ نقطه‌ای که جدولی با پنج درایه‌ی 32 بیتی مقادیر 1, 2, 3, 4, 4 شروع می‌شود. بنابراین مقدار ذخیره‌شده باید (.rodata + 0x14) - 0x3a باشد. فرمول S + A - P به ازای P = 0x36 حاصل .rodata + A - 0x36 را به دست می‌دهد. با مساوی قرار دادن این دو رابطه، مقدار A = 0x14 - 4 = 0x10 به دست می‌آید. مقدار افزوده حامل آفست هدف منهای آن چهار بایتی است که بین محل جابه‌جایی و انتهای دستور فاصله انداخته‌اند.

این جدول همان روشی است که کامپایلر clang برای پیاده‌سازی دستور switch در تابع grade تحت فلگ -O1 به کار برده است. کد، مقدار score / 10 را از طریق ضرب در 0x66666667 و دو شیفت محاسبه می‌کند، عدد 6 را از آن تفریق می‌کند، با 4 مقایسه می‌نماید و در نهایت به این جدول ارجاع می‌دهد. فایل ELF با فلگ -O1 هیچ نمادی را برای این جدول نگه نمی‌دارد؛ مرجع مورد استفاده، نماد بخش .rodata به همراه یک مقدار افزوده است، و تنها نسخه‌ی کامپایل‌شده‌ی arm64 از همین سورس است که آن را l_switch.table.grade نام‌گذاری می‌کند. رکورد دوم در آفست 0x84 از تابع grade_label به جدول دومی از مقادیر 8 بایتی در .rodata + 0x28 اشاره می‌کند. رکورد سوم در 0x93 به .rodata + 0x0 یعنی شروع labels.rel اشاره دارد.

پنج رکورد بخش .rela.rodata محتوای labels.rel را پر می‌کنند. هر درایه در آفست 4i قرار گرفته و مقدار افزوده‌ی 4i را دریافت می‌کند؛ در نتیجه S + A - P به آدرس رشته منهای پایه‌ی جدول خلاصه می‌شود. تابع grade_label یک درایه را با دستور movslq می‌خواند، پایه‌ی جدول را دوباره به آن اضافه می‌کند و به اشاره‌گر می‌رسد. اولین رکورد به نماد بخش .rodata.str1.1 با مقدار افزوده‌ی 0 اشاره دارد زیرا رشته‌ی F اولین بایت آن بخش است. سایر رکوردها به .L.str.1 تا .L.str.4 اشاره می‌کنند که برچسب‌های محلی کامپایلر برای رشته‌های D، C، B و A هستند.

یک سورس‌کد واحد در سه سطح مختلف بهینه‌سازی، جدول‌های کاملاً متفاوتی می‌سازد. خوانشگر این تفاوت را بدون نیاز به دیس‌اسمبلر نشان می‌دهد:

ساخت محل استقرار labels نحوه‌ی انتخاب در grade نحوه‌ی دسترسی grade_label به grade
-O0 بخش .data، پنج اشاره‌گر 8 بایتی، پنج رکورد R_X86_64_64 جدول پرش (Jump table) در .rodata: پنج رکورد R_X86_64_PC32 با اشاره به داخل .text یک فراخوانی با رکورد R_X86_64_PLT32 grade-0x4
-O1 بخش .rodata به‌صورت labels.rel، پنج درایه‌ی نسبی 32 بیتی جدول مقادیر در .rodata+0x14، یک رکورد R_X86_64_PC32 به‌صورت درون‌خطی (Inlined)، بدون فراخوانی
-O2 مشابه -O1 مشابه -O1 به‌صورت درون‌خطی، اندازه‌ی total به 140 بایت می‌رسد

در سطح بهینه‌سازی -O0، ساختار switch یک جدول پرش واقعی است که پنج درایه‌ی آن به داخل بخش .text در آفست‌های +0x91، +0x8c، +0x87، +0x82 و +0x86 ارجاع می‌دهند. فراخوانی تابع grade نیز به همان دلیلی که برای lea گفته شد، مقدار افزوده‌ی -0x4 دارد. در بهینه‌سازی -O1، بهینه‌ساز متوجه شد که همه‌ی شاخه‌ها مقداری ثابت برمی‌گردانند؛ بنابراین پرش‌ها را با جدولی از نتایج جایگزین کرد. درس 33 هر دو ساختار را رمزگشایی (Decode) می‌کند.

هدرهای برنامه: نمای اجرا برای بارگذار سیستم

یک آبجکت‌فایل بازآدرس‌پذیر هدر برنامه ندارد؛ بنابراین مقدار e_phnum برابر با 0 است. یک فایل اجرایی یا یک فایل اشتراکی (Shared object) دارای این هدرها است و بارگذار سیستم‌عامل (Loader) هیچ بخش دیگری از فایل را نمی‌خواند. ساختار Elf64_Phdr برابر با 56 بایت است (شکل 5-1 در gABI): شامل فیلدهای p_type، p_flags، p_offset، p_vaddr، p_paddr، p_filesz، p_memsz، p_align با فرمت <IIQQQQQQ. استاندارد gABI سه قاعده را برای درایه‌های PT_LOAD مشخص می‌کند: آن‌ها بر اساس ترتیب صعودی p_vaddr چیده می‌شوند؛ اگر p_memsz بزرگ‌تر از p_filesz باشد فضای باقیمانده با صفر پر می‌شود؛ و فیلد p_vaddr به پیمانه‌ی p_align با p_offset هم‌نهشت است.

تمام اهداف این مسیر برنامه‌هایی هستند که خودتان ساخته‌اید و هیچ لینکری روی این سیستم‌عامل Mac خروجی اجرایی لینوکس از ledger.o تولید نمی‌کند. بنابراین فایل آزمایشی tiny-x86_64 توسط اسکریپت code/fixtures/build_tiny_elf.py ساخته می‌شود که 736 بایت را با متد struct.pack می‌نویسد. این فایل شامل هدر ELF، دو هدر برنامه، تابع _start به طول 36 بایت، بخش .rodata به طول 3 بایت، یک جدول نمادها و شش هدر بخش است. تابع _start رشته‌ی ok\n را در توصیف‌گر فایل 1 می‌نویسد و با وضعیت خروج 42 پایان می‌یابد. این فایل روی لینوکس x86-64 اجرا می‌شود و در اینجا به عنوان نمونه‌ای برای ارزیابی مسیر پردازش هدر برنامه استفاده شده است.

Program headers:
  PT_LOAD          r-x offset=0x0 vaddr=0x400000 filesz=0x2e0 memsz=0x2e0 align=0x1000
  PT_GNU_STACK     rw- offset=0x0 vaddr=0x0 filesz=0x0 memsz=0x0 align=0x10

entry 0x4000b0 is file offset 0xb0 through PT_LOAD

تنها سگمنت PT_LOAD کل این فایل 736 بایتی را روی آدرس 0x400000 نگاشت می‌کند. خوانشگر با پیدا کردن سگمنت PT_LOAD دربرگیرنده‌ی آدرس و محاسبه‌ی p_offset + (address - p_vaddr)، آدرس حافظه را به آفست فایل تبدیل می‌کند. برای نقطه‌ی ورود برنامه، این محاسبه به‌صورت 0 + (0x4000b0 - 0x400000) = 0xb0 انجام می‌شود که همان ابتدای بخش .text است.

این همان محاسباتی است که ابزار address_to_file_offset در rea انجام می‌دهد. برای یک هدف در Ghidra، این ابزار مقادیر {address, file_offset} را برمی‌گرداند. ارائه‌دهنده‌ی Hopper مقادیر provider_file_offset و image_base_file_offset را نیز گزارش می‌دهد. این ابزار نیازمند یک نسخه‌ی دست‌نخورده از فایل اصلی است و هش SHA-256 آن باید کاملاً با فایل هدف مطابقت داشته باشد. در مستندات ابزار inspect_binary_layout از rea، نکته‌ی مهمی ذکر شده است: «آدرس پیوندخورده لزوماً آدرس محیط اجرا (Runtime) نیست». یک فایل اجرایی مستقل از موقعیت (PIE)، در آدرس پایه‌ای بارگذاری می‌شود که کرنل (Kernel) انتخاب می‌کند؛ در نتیجه آفست فایل تنها مختصات پایدار است.

هدرهای برنامه علت اجرای موفق یک فایل باینری فاقد نماد (Stripped) را نیز توضیح می‌دهند. یک لینکر می‌تواند جدول هدر بخش‌ها را به‌طور کامل حذف کند که در این صورت e_shoff صفر می‌شود. ابزاری که صرفاً روی بخش‌ها پیمایش می‌کند، دیگر چیزی نخواهد دید؛ اما بارگذار سیستم اصلاً نیازی به این بخش‌ها ندارد.

همین مفاهیم در قالب ساختار Mach-O

شرکت اپل مشخصات مکتوبی در قالب متن برای ساختار Mach-O منتشر نمی‌کند؛ هدرهای موجود در سورس‌کد هسته‌ی XNU نقش مرجع استاندارد را دارند: فایل‌های EXTERNAL_HEADERS/mach-o/loader.h، nlist.h و reloc.h. فایل آزمایشی ledger-arm64-O1.o همان کد ledger.c است که با دستور clang -target arm64-apple-macos -O1 -fno-asynchronous-unwind-tables -c بیلد شده است.

ساختار mach_header_64 برابر با 32 بایت است: magic، cputype، cpusubtype، filetype، ncmds، sizeofcmds، flags، reserved. بایت‌های جادویی برابر با 0xfeedfacf یعنی MH_MAGIC_64 است. مقدار cputype برابر با 0x0100000c یعنی CPU_TYPE_ARM64، فیلد filetype برابر با 1 یا همان MH_OBJECT است. فیلد ncmds برابر با 5 و مقدار sizeofcmds برابر با 616 است: فرمان‌های بارگذاری دقیقاً 616 بایت بعد از هدر را به خود اختصاص داده‌اند و هرکدام با ساختار {cmd, cmdsize} آغاز می‌شوند. پرچم 0x2000 نمایانگر MH_SUBSECTIONS_VIA_SYMBOLS است.

Load commands:
  0 LC_SEGMENT_64                cmdsize=472 at 0x20
  1 LC_BUILD_VERSION             cmdsize=24 at 0x1f8
  2 LC_LINKER_OPTIMIZATION_HINT  cmdsize=16 at 0x210
  3 LC_SYMTAB                    cmdsize=24 at 0x220
  4 LC_DYSYMTAB                  cmdsize=80 at 0x238

فرمان LC_SEGMENT_64 (با کد 0x19) معادل Mach-O برای هر دو جدول ساختار ELF است. ساختار segment_command_64 شامل این فیلدها است: segname، vmaddr، vmsize، fileoff، filesize، maxprot، initprot، nsects و flags. سپس در ادامه‌ی همین دستور، به تعداد nsects رکورد از نوع section_64 (هر کدام 80 بایت) قرار می‌گیرند. توضیحات فایل loader.h برای ساختار MH_OBJECT قید می‌کند که بخش‌ها «در یک سگمنت بی‌نام و بدون پدینگ (حاشیه‌گذاری) سگمنت» قرار دارند. فایل آزمایشی ما دقیقاً همین را نشان می‌دهد: یک سگمنت بدون نام، مقدار fileoff برابر با 648، مقدار filesize برابر با 456 و پنج بخش.

  Idx Section                    Addr   Size Offset Reloff Nreloc Type
    1 __TEXT,__text               0x0   0xd0    648   1104      6 S_REGULAR
    2 __DATA,__const             0xd0   0x28    856   1152      5 S_REGULAR
    3 __TEXT,__cstring           0xf8    0xa    896      0      0 S_CSTRING_LITERALS
    4 __TEXT,__const            0x108   0x40    912      0      0 S_REGULAR
    5 __LD,__compact_unwind     0x148   0x80    976   1192      4 S_REGULAR

هر بخش نام سگمنت والد خود را در فیلد segname می‌آورد؛ بنابراین دو بخش با نام __const کاملاً از هم متمایزند، زیرا یکی __DATA,__const و دیگری __TEXT,__const است. رکوردهای بازآدرس‌دهی از طریق فیلدهای reloff و nreloc به ازای هر بخش نگهداری می‌شوند، در حالی که در فرمت ELF یک بخش مجزای .rela.X به ازای هر هدف ایجاد می‌شود.

هر رکورد relocation_info شامل 8 بایت است. ابتدا یک مقدار علامت‌دار r_address می‌آید. به دنبال آن یک فیلد 32 بیتی فشرده قرار دارد که به‌صورت r_symbolnum (24 بیت)، r_pcrel (1 بیت)، r_length (2 بیت)، r_extern (1 بیت) و r_type (4 بیت) تقسیم می‌شود. شش رکورد موجود در __text در واقع سه جفت از رکوردهای ARM64_RELOC_PAGE21 و ARM64_RELOC_PAGEOFF12 هستند، یعنی به ازای هر ارجاع به جدول یک جفت. از آنجا که ترکیب دو دستور adrp و add در معماری arm64 همان کاری را انجام می‌دهد که یک دستور lea در معماری x86-64 انجام می‌داد، هر ارجاع به دو رکورد نیاز دارد. پنج رکورد موجود در __DATA,__const از نوع ARM64_RELOC_UNSIGNED با طول r_length برابر با 3 هستند؛ بنابراین هر کدام یک اشاره‌گر مطلق 8 بایتی را پچ می‌کنند.

در سطح بهینه‌سازی -O1، خروجی arm64 آرایه‌ی labels را به عنوان اشاره‌گرهای ساده نگه داشته، در حالی که بیلد x86-64 یک جدول نسبی برای آن ساخته است.

فرمان LC_SYMTAB (با کد 0x2) شامل مقادیر symoff برابر با 1240، nsyms برابر با 17، stroff برابر با 1512 و strsize برابر با 176 است؛ حاصل جمع 1512 و 176 برابر با 1688 یعنی دقیقاً اندازه‌ی فایل می‌شود. هر ساختار nlist_64 برابر با 16 بایت است: n_strx، n_type، n_sect، n_desc و n_value. فیلد n_type یک بیت‌فیلد است. برابری N_TYPE (ماسک 0x0e) با مقدار N_SECT (0xe) نشان می‌دهد نماد درون بخشی تعریف شده که شماره‌ی ترتیبی آن بر پایه‌ی یک، برابر با n_sect است. بیت N_EXT (0x01) نماد خارجی را مشخص می‌کند.

ساختار nlist_64 فیلدی برای اندازه ندارد. خوانشگر برای به دست آوردن طول 24 بایتی تابع _percent_change، آدرس نماد بعدی در همان بخش را که تابع _grade در آدرس 0x18 است مبنا قرار می‌دهد. نمادهای زبان C در اینجا یک خط زیر (_) اضافه در ابتدا دارند؛ بنابراین نام تابع _percent_change است.

فرمان LC_DYSYMTAB (با کد 0xb) همان قاعده‌ی اولویت نمادهای محلی را ثبت می‌کند که در sh_info از هدر ELF دیدیم: مقادیر ilocalsym برابر با 0، nlocalsym برابر با 13، iextdefsym برابر با 13 و nextdefsym برابر با 4. فرمان LC_BUILD_VERSION (با کد 0x32) مقادیر platform برابر با 1 (macOS)، نسخه‌ی حداقل minos برابر با 26.0 و sdk برابر با 26.2 را به‌صورت اعداد صحیح فشرده‌ی XXXX.YY.ZZ ثبت می‌کند. به دلیل وجود همین فرمان، این فایل آزمایشی arm64 به‌صورت بایت‌های مستقیم در مخزن commit شده است؛ زیرا کامپایل مجدد روی سیستمی دیگر با SDK متفاوت، فایل متفاوتی تولید می‌کند.

متد compare_formats در خوانشگر ما، دو فایل را در کنار یکدیگر مقایسه می‌کند:

مفهوم ساختار ELF در ledger-x86_64-O1.o ساختار Mach-O در ledger-arm64-O1.o
شناسه‌ی فایل فیلد e_ident با بایت‌های 7f 45 4c 46، کلاس 64، داده‌ی LSB فیلد magic با مقدار 0xfeedfacf
ماشین فیلد e_machine برابر با 62 یعنی EM_X86_64 فیلد cputype برابر با 0x100000c یعنی CPU_TYPE_ARM64
نوع فایل فیلد e_type برابر با 1 یعنی ET_REL فیلد filetype برابر با 1 یعنی MH_OBJECT
جایگاه جدول‌ها مقدار e_shoff برابر با 0x428، 11 هدر بخش 5 فرمان بارگذاری در 616 بایت پس از هدر
کد اجرایی بخش .text در آفست 0x40، اندازه 0x9f، پرچم‌های AX بخش __TEXT,__text در آفست 648، اندازه 0xd0، درون سگمنت بی‌نام
نام نمادها بخش .symtab (بخش 10)، نام‌ها در .strtab از طریق sh_link برابر با 1 فرمان LC_SYMTAB با symoff برابر 1240، تعداد nsyms برابر 17، نام‌ها در stroff برابر 1512
اندازه‌ی نماد فیلد st_size: تابع percent_change برابر با 16 بایت بدون فیلد: تفریق آدرس نماد بعدی از فعلی، 24 بایت
بازآدرس‌دهی‌ها بخش .rela.text، سه رکورد، R_X86_64_PC32 بخش __text با reloff برابر 1104، شش رکورد از نوع PAGE21 و PAGEOFF12
بایت‌های تابع تابع percent_change: 16 بایت در آفست 0x40 تابع _percent_change: 24 بایت در آفست 0x288

ابزار rea فایل‌های Mach-O را با inspect_macho بررسی می‌کند؛ دستوری که دستورات otool -h -l، nm -gjU، dyld_info -imports -exports و dwarfdump --uuid را از طریق ابزار xcrun فراخوانی می‌کند. خوانشگر شما به هیچ‌کدام از این‌ها نیازی ندارد و دقیقاً همان مقادیر symoff و stroff را استخراج می‌کند که دستور otool -l نمایش می‌دهد. ابزار open_binary در rea فرمت‌های ELF، MZ، PE و نسخه‌های Mach-O چندمعماری (Universal) یا تک‌معماری (Thin) را از روی بایت‌های جادویی تشخیص می‌دهد. همچنین فیلد e_machine در آفست 18 را مستقیماً می‌خواند. این دقیقاً همان 20 بایت اولی است که در اینجا پیاده کرده‌اید. در مورد فایلی که هیچ هدری ندارد، باید مشخصات را به‌صورت format: raw-binary همراه با معماری و آدرس ورود تعیین کنید. ارائه‌دهنده‌ی Ghidra فایل‌های چندمعماری را رد می‌کند و می‌خواهد بخش مربوط به یک معماری جدا شود؛ خوانشگر شما نیز با دیدن بایت‌های 0xcafebabe همین رفتار را در پیش می‌گیرد.

پیاده‌سازی از صفر

اجرای خوانشگر که با کتابخانه‌ی استاندارد پایتون نوشته شده است:

cd phases/13-tools-and-protocols/32-program-anatomy-elf-from-scratch
python3 code/main.py

این برنامه ابتدا فایل ledger-x86_64-O1.o را می‌خواند و هدر، بخش‌ها، نمادها، بازآدرس‌دهی‌ها و نقشه‌ی آفست‌های فایل آن را چاپ می‌کند. سپس بایت‌های چهار تابع را نمایش داده، فایل ledger-arm64-O1.o را پردازش کرده و جدول مقایسه‌ای را خروجی می‌دهد. در نهایت نیز هدرهای برنامه‌ی فایل tiny-x86_64 را چاپ می‌کند. سطرهای مورد انتظار به این شکل است:

percent_change: 16 bytes at file offset 0x40: 85 ff 74 09 29 fe 6b c6 64 99 f7 ff c3 31 c0 c3
grade: 46 bytes at file offset 0x50: 48 63 c7 48 69 c0 67 66 66 66 ...
entry 0x4000b0 is file offset 0xb0 through PT_LOAD

برای خواندن یک فایل مشخص می‌توانید مسیر آن را ارسال کنید: python3 code/main.py code/fixtures/ledger-x86_64-O0.o. خوانشگر بر اساس چهار بایت اول، ساختار ELF یا Mach-O را تشخیص می‌دهد.

کد فایل code/main.py را به این ترتیب مطالعه کنید:

  1. تابع ElfFile._read_header() بایت‌های جادویی، کلاس، ترتیب بایت و اندازه‌ی درایه‌ها را بررسی کرده و سپس هدر را باز می‌کند.
  2. توابع ElfFile._read_sections() و read_cstring() جدول هدر بخش‌ها را خوانده و نام‌ها را بر اساس e_shstrndx پیدا می‌کنند.
  3. تابع ElfFile._read_symbols() فیلد st_info را تفکیک کرده، نام‌ها را با ارجاع به sh_link برمی‌گرداند و ایندکس‌های رزروشده‌ی بخش‌ها را به فرمتی خوانا تبدیل می‌کند.
  4. تابع ElfFile._read_relocations() تمام بخش‌های SHT_RELA را پیمایش کرده، فیلد r_info را می‌شکند و نام انواع را بر اساس جدول 4.9 استخراج می‌کند.
  5. متدهای ElfFile.function_span() و function_bytes() یک نماد را به بازه‌ای از فایل و بایت‌های واقعی آن تبدیل می‌کنند.
  6. تابع ElfFile.file_offset_map() تمام ساختارها را بر اساس آفست مرتب کرده و فضاهای خالی را مشخص می‌سازد.
  7. توابع ElfFile._read_program_headers() و address_to_file_offset() به پردازش فایل‌های اجرایی می‌پردازند.
  8. کلاس MachOFile بخش‌های هدر، فرمان‌های بارگذاری، ساختار LC_SEGMENT_64 به همراه بخش‌های آن، LC_SYMTAB، LC_BUILD_VERSION و بازآدرس‌دهی‌های هر بخش را بازخوانی می‌کند.
  9. تابع compare_formats() مفاهیم مشترک را در هر دو فایل همراه با مقادیر استخراج‌شده در کنار هم قرار می‌دهد.

استفاده از ابزارهای واقعی

برای استفاده از این خوانشگر در درس‌های بعدی، پس از اضافه کردن پوشه‌ی code این درس به sys.path، آن را با دستور from main import ElfFile ایمپورت کنید. در درس 33 توابع function_bytes و relocations_for(".text") از این کلاس فراخوانی می‌شوند. درس 35 خروجی آن را به عنوان شواهد (Evidence) ثبت می‌کند و درس 36 از آن برای استخراج آفست‌های بازآدرس‌دهی جهت ماسک کردن بایت‌ها در مقایسه استفاده می‌کند.

کد C اختصاصی خود را با همان دستور clang -target x86_64-unknown-linux-gnu -c به یک فایل آبجکت کامپایل کنید و این خوانشگر را روی آن اجرا نمایید. نام بازآدرس‌دهی‌ها را صرفاً زمانی اضافه کنید که در جدول 4.9 استاندارد x86-64 psABI آمده باشند. نوع یک بخش را تنها در صورتی ثبت کنید که در gABI یا اسناد ابزار کامپایلر شما آمده باشد. هر زمان که خوانشگر با مقداری ناشناخته روبرو شود، به‌جای حدس زدن، مقدار عددی را در مبنای 16 چاپ می‌کند.

کارگاه تعاملی

بخش اول: بررسی دستی هدر فایل

درون پوشه‌ی code، محیط پایتون را باز کنید:

cd phases/13-tools-and-protocols/32-program-anatomy-elf-from-scratch/code
python3 -q

این دستورات را اجرا کنید:

import struct
from main import ElfFile, FIXTURES
data = (FIXTURES / "ledger-x86_64-O1.o").read_bytes()
data[:4], data[4], data[5]
struct.unpack_from("<HHIQQQIHHHHHH", data, 16)
elf = ElfFile(data)
elf.header.e_shoff, elf.header.e_shnum, elf.sections[elf.header.e_shstrndx].name

چندتایی خروجی نشان می‌دهد که e_type برابر با 1، e_machine برابر با 62، و e_shoff برابر با 1064 یعنی 0x428 است. سطر آخر مشخص می‌کند که بخش 1 یعنی .strtab نام بخش‌ها را در خود دارد.

بخش دوم: جدول مقادیر پشت ساختار switch

rodata = elf.section_bytes(elf.section(".rodata"))
[int.from_bytes(rodata[i : i + 4], "little") for i in range(0x14, 0x28, 4)]
[int.from_bytes(rodata[i : i + 8], "little") for i in range(0x28, 0x50, 8)]
[r for r in elf.relocations_for(".text") if r.symbol == ".rodata"]

هر دو جدول مقادیر 1, 2, 3, 4, 4 را دارند. این مقادیر را با فایل ledger.c مقایسه کنید: عبارت grade(65) مقدار 65 / 10 - 6 = 0 را محاسبه کرده و درایه‌ی ایندکس 0 یعنی عدد 1 را می‌خواند که همان مقدار شاخه‌ی case 6 است. دستور grade(100) درایه‌ی ایندکس 4 یعنی عدد 4 را بازمی‌گرداند که خروجی مشترک شاخه‌های case 10 و case 9 است.

بخش سوم: مقایسه‌ی سه بیلد مختلف از یک سورس‌کد

for level in ("O0", "O1", "O2"):
    obj = ElfFile.from_path(FIXTURES / f"ledger-x86_64-{level}.o")
    print(level, [s.name for s in obj.sections if s.name in (".data", ".rodata")])
    print(level, sorted({r.type_name for r in obj.relocations}))
    print(level, {s.name: s.st_size for s in obj.symbols if s.type == "FUNC"})

آبجکت حاصل از کامپایل با فلگ -O0 شامل بخش .data و سه نوع بازآدرس‌دهی است. خروجی‌های کامپایل -O1 و -O2 فاقد بخش .data هستند و فقط یک نوع بازآدرس‌دهی دارند. اندازه‌ی تابع total در فلگ -O0 برابر با 66 بایت، در -O1 برابر با 31 بایت و در -O2 برابر با 140 بایت است.

بخش چهارم: بررسی همان تابع در فرمت Mach-O

from main import MachOFile
macho = MachOFile.from_path(FIXTURES / "ledger-arm64-O1.o")
[(c.name, c.cmdsize) for c in macho.load_commands]
macho.function_span("_percent_change")
macho.function_bytes("_percent_change")[:4].hex()

طول این بازه 24 بایت و در آفست 648 فایل است. چهار بایت اول a0000034 است که نمایش اندیان کوچک از دستورالعمل 0x340000a0 به شمار می‌رود؛ دستوری که خروجی llvm-objdump -d آن را به‌صورت cbz w0, 0x14 نمایش می‌دهد. این همان شرط before == 0 است؛ آزمونی که بیلد x86-64 آن را با بایت‌های 85 ff 74 09 کدگذاری کرده بود.

تمرین عملی

قابلیت‌های خوانشگر را برای پشتیبانی از یک برنامه‌ی پیوندخورده توسعه دهید. روی یک سیستم لینوکس x86-64، فایل‌های clang -O1 -shared -fPIC code/fixtures/ledger.c -o libledger.so و clang -O1 code/fixtures/ledger.c -o ledger_main را به همراه یک تابع main دلخواه کامپایل کنید و خوانشگر را روی هر دو اجرا نمایید.

نیازمندی‌ها:

  • خواندن بخش .dynsym با ساختار Elf64_Sym؛ چرا که مقدار SHT_DYNSYM برابر با 11 بوده و چیدمان رکورد بدون تغییر باقی می‌ماند.
  • خواندن بخش .dynamic در قالب رکوردهای Elf64_Dyn متشکل از فیلدهای d_tag و d_val، و نام‌گذاری فیلدهای DT_NEEDED، DT_STRTAB، DT_SYMTAB، DT_RELA و DT_JMPREL بر اساس صفحه‌ی "Dynamic Section" در gABI.
  • فهرست کردن نمادهای تعریف‌نشده با مقدار st_shndx برابر با SHN_UNDEF به عنوان توابع ورودی (Imports)، و رکوردهای R_X86_64_JUMP_SLOT در بخش .rela.plt به عنوان نقاط فراخوانی آن‌ها.
  • نگاشت هر سگمنت PT_LOAD به بخش‌های درون آن، با استناد به قاعده‌ی gABI مبنی بر اینکه «هر سگمنت فایل آبجکت شامل یک یا چند بخش است».
  • اثبات این نکته از طریق address_to_file_offset که بایت‌های هر تابع تعریف‌شده در فایل اشتراکی، دقیقاً با بایت‌های خروجی function_bytes مطابقت دارد.
  • ذخیره‌ی متن خروجی دستورات llvm-objdump -h -t -r -p و llvm-objdump -T برای هر دو فایل درون مسیر code/fixtures/ و ارزیابی خروجی خوانشگر با آن‌ها در قالب تست‌های خودکار.

معیار موفقیت شما زمانی است که فهرست توابع ورودی، نقشه‌ی سگمنت‌ها و بازه‌ی تمام توابع کاملاً با خروجی‌های ذخیره‌شده‌ی ابزار مطابقت داشته باشند، بدون آنکه حین اجرای تست‌ها هیچ ابزار جانبی فراخوانی شود.

تمرین ارزیابی‌شده

پنج پرسش درباره‌ی فایل‌های آزمایشی این درس آماده شده است. پاسخ‌ها را با استفاده از کدی که نوشته‌اید استخراج کنید و در فایل code/lab_answers.json بنویسید. مقادیر عددی را به‌صورت عدد یا رشته‌های هگزادسیمال مثل "0x40"، و لیست‌ها را به‌صورت آرایه‌های متناظر در JSON ثبت کنید.

  1. t1: مقدار e_shoff برای فایل ledger-x86_64-O1.o، به‌صورت عدد.
  2. t2: مقدار st_size برای تابع percent_change در فایل ledger-x86_64-O1.o.
  3. t3: آفست بخش .text در فایل ledger-x86_64-O1.o، به‌صورت هگزادسیمال.
  4. t4: نام انواع بازآدرس‌دهی در فایل ledger-x86_64-O0.o، به‌صورت مرتب‌شده و در قالب یک لیست.
  5. t5: پنج مقدار عددی صحیح در آفست .rodata+0x14 برای فایل ledger-x86_64-O1.o، در قالب یک لیست.

پاسخ‌های خود را بررسی کنید:

cd phases/13-tools-and-protocols/32-program-anatomy-elf-from-scratch
python3 code/lab.py

اسکریپت ارزیابی، مقدار مورد انتظار را با اجرای code/main.py روی فایل‌های موجود در مخزن محاسبه می‌کند. این اسکریپت برای هر بخش یکی از وضعیت‌های PASS، FAIL یا TODO و در نهایت یک امتیاز کلی را چاپ می‌کند، و تنها در صورتی با کد 0 خارج می‌شود که هر پنج مورد تأیید شوند. با دستور python3 code/lab.py my-answers.json می‌توانید فایل دیگری را بررسی کنید. فایل تست code/tests/test_lab.py نشان می‌دهد که اسکریپت ارزیابی، پاسخ‌های درست را تأیید و پاسخ‌های اشتباه را رد می‌کند.

مشاهده‌ی نحوه‌ی اجرا

محیط تعاملی Inside a Decompiler این درس را در قالب دو ایستگاه اول خود اجرا می‌کند. ابزار چرخان، هدر فایلی را می‌خواند که تابع percent_change را در بر دارد و بخش پایینی با ورود به بخش .text، نمادها را استخراج می‌کند. نمایش نمادها را خاموش کنید تا متوجه شوید جدول نمادها چه جزئیاتی را با خود حمل می‌کرده است.

خروجی این درس

این درس فایل مهارتی outputs/skill-program-anatomy-elf-from-scratch.md را تولید می‌کند. این مهارت در سیستم‌هایی مانند Claude، Cursor، Codex، OpenClaw، Hermes یا هر ایجنت دیگری که از استاندارد SKILL.md پشتیبانی می‌کند قابل استفاده است. این مهارت فایل آبجکت یا فایل اجرایی ساخته‌شده توسط کاربر را تحویل گرفته و گزارش کاملی از ساختار و چیدمان بایت‌ها ارائه می‌دهد؛ گزارشی که بازه‌ی تک‌تک ساختارها، نمادها و رکوردهای بازآدرس‌دهی را مشخص کرده و صحت آن‌ها را با ابزارهای استاندارد مقایسه می‌کند.

اعتبارسنجی

اجرای دمو و مجموعه‌ی تست‌های معین:

cd phases/13-tools-and-protocols/32-program-anatomy-elf-from-scratch
python3 code/main.py
python3 -m unittest discover -s code/tests -v

این آزمون‌ها موارد زیر را تأیید می‌کنند:

  • مطابقت دقیق ایندکس، نام، اندازه، آدرس و نوع تک‌تک بخش‌ها با خروجی ذخیره‌شده‌ی llvm-objdump -h برای هر سه آبجکت‌فایل x86-64 و فایل اجرایی کوچک.
  • مطابقت دقیق مقدار، نوع پیوند، جنس نماد، بخش، اندازه و نام نمادها با خروجی متنی llvm-objdump -t.
  • تطابق کامل آفست، نام نوع، نماد و مقدار افزوده‌ی رکوردهای بازآدرس‌دهی با خروجی llvm-objdump -r، از جمله رکوردهای R_X86_64_PLT32 و R_X86_64_64 در فلگ بهینه‌سازی -O0.
  • انطباق هر دو هدر برنامه‌ی tiny-x86_64 با خروجی llvm-objdump -p و خالی بودن این جدول در آبجکت‌فایل‌ها.
  • صحت اندازه‌ی تابع percent_change (16 بایت) در آفست 0x40 مطابق با بایت‌های مورد انتظار و قرارگیری تمام توابع در محدوده‌ی .text.
  • پوشش کامل تمامی بایت‌های فایل‌های آزمایشی توسط نقشه‌ی آفست، همراه با مشخص شدن جایگاه سه فضای خالی تراز حافظه در فایل کامپایل‌شده با -O1.
  • نگاشت درست نقطه‌ی ورود فایل tiny-x86_64 به آفست 0xb0 از طریق PT_LOAD و عدم پذیرش نگاشت آدرس در آبجکت‌فایل بازآدرس‌پذیر.
  • انطباق هدر Mach-O، تمام فرمان‌های بارگذاری، سگمنت، پنج بخش موجود، LC_SYMTAB و LC_BUILD_VERSION با خروجی ثبت‌شده‌ی --private-headers.
  • مطابقت نمادها و 15 بازآدرس‌دهی فایل Mach-O با خروجی llvm-objdump و اندازه‌ی 24 بایتی تابع _percent_change در آفست 648.
  • مدیریت خطای FormatError در مواجهه با بایت‌های جادویی اشتباه، کلاس 32 بیتی، فایل Big-endian، جدول ناقص، باینری چندمعماری و فایل سورس‌کد C.
  • تولید بایت‌به‌بایت فایل tiny-x86_64 مطابق با نسخه‌ی ذخیره‌شده توسط اسکریپت build_tiny_elf.py.
  • میزبانی بایت‌های دقیق فایل ledger-x86_64-O1.o در ماژول مربوطه یعنی site/figures-re-32.js.

حالت‌های شکست در محیط عملیاتی

حالت شکست گزارش حاصل از ارزیابی ناقص آنچه خوانشگر باید اثبات کند
استفاده از st_value به عنوان آفست مستقیم فایل «تابع percent_change در آفست 0 قرار دارد» آفست واقعی sh_offset + st_value - sh_addr است که اینجا 0x40 می‌شود
شمارش بایت‌های پدینگ به عنوان کد اجرایی «طول تابع grade برابر با 48 بایت است» فیلد st_size برابر با 46 است و بایت‌های 66 90 بعد از آن متعلق به هیچ نمادی نیست
خواندن نام بخش‌ها از جدول اشتباه تولید نام‌های نامفهوم یا بروز Exception فیلد e_shstrndx جدول صحیح را تعیین می‌کند که اینجا بخش 1 است
اتکا به sh_size برای بخش‌های SHT_NOBITS تلاش برای خواندن فراتر از انتهای فایل بخش .bss بایتی در فایل ندارد و باید در نقشه نادیده گرفته شود
خواندن بایت‌های صفر به عنوان عملوند مستقیم دستور «دستور lea 0(%rip) است» رکورد بازآدرس‌دهی در .rela.text در آفست 0x36 به .rodata+0x10 ارجاع می‌دهد
تلقی مقدار افزوده به عنوان آفست هدف «شروع جدول از آفست 0x10 است» رابطه‌ی S + A - P با انتهای دستورالعمل، آدرس 0x14 را نتیجه می‌دهد
پیمایش بخش‌ها روی یک باینری Strip شده «فایل هیچ کدی ندارد» سگمنت‌های PT_LOAD تمام آدرس‌های مورد نیاز بارگذار را پوشش می‌دهند
یکی فرض کردن آدرس لینک‌شده با آدرس زمان اجرا محاسبه‌ی آفست اشتباه با تغییر آدرس پایه آفست فایل حاصل از PT_LOAD تنها مختصات پایدار است
جستجوی بخش‌های Mach-O صرفاً بر اساس sectname ادغام نادرست دو بخش __const با یکدیگر شناسه‌ی یکتا ترکیب segname,sectname است
انتظار وجود فیلد اندازه در رکورد nlist_64 بروز Exception یا خواندن صفر بایت شروع نماد بعدی در همان بخش مشخص‌کننده‌ی پایان تابع است
خواندن فایل چندمعماری مشابه یک فایل Mach-O معمولی «بایت‌های جادویی 0xcafebabe ناشناخته هستند» فایل باید رد شده و ارائه‌ی بخش تک‌معماری درخواست شود
کامپایل مجدد آبجکت arm64 روی یک سیستم Mac دیگر «فایل آزمایشی تغییر کرده است» فیلد LC_BUILD_VERSION نگارش SDK را نگه می‌دارد؛ بنابراین بایت‌های ثبت‌شده در مخزن ملاک هستند

قواعد عملیاتی

هدری را بخوانید که به اعتبار آن اطمینان دارید، آفست‌های درج‌شده در آن را دنبال کنید و تک‌تک فیلدها را بر مبنای اسناد مرجع استانداردی که آن را تعریف کرده نام‌گذاری نمایید. خروجی ابزارهای استانداردی را که کد خود را با آن‌ها مقایسه کرده‌اید در قالب فایل‌های آزمایشی در مخزن ثبت کنید تا امکان ارزیابی مجدد بدون نیاز به آن ابزارها فراهم باشد. وقتی مقداری در مشخصات استاندارد نام مشخصی ندارد، مقدار عددی آن را گزارش دهید. این ابزار خوانشگر که عملکرد آن بایت‌به‌بایت قابل سنجش است، پایه‌ای خواهد بود که تمام درس‌های بعدی بر روی آن بنا می‌شوند.

برای مطالعه‌ی بیشتر

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسکدام قانون برای تمام اهداف تحلیل در درس‌های 32 تا 36 صدق می‌کند؟
  2. حین درسیک نماد در یک فایل شیء قابل بازنشانی (relocatable) دارای st_value برابر 0x10 و st_shndx برابر 2 است، و بخش 2 دارای sh_offset برابر 0x40 و sh_addr برابر 0 است. بایت‌های آن در کجای فایل قرار دارند؟
  3. حین درسیک رکورد در .rela.text دارای آفست 0x36، نوع R_X86_64_PC32 و مقدار .rodata+0x10 است، و دستور lea که این فیلد را نگه می‌دارد در 0x3a خاتمه می‌یابد. دستور lea پس از پیوند (linking) به کجا اشاره می‌کند؟
  4. حین درسلودر ELF برای نگاشت یک فایل اجرایی به حافظه کدام ساختار را می‌خواند؟
  5. پس از درسیک خواننده‌ی Mach-O چگونه طول یک تابع را از یک مدخل nlist_64 پیدا می‌کند؟
  6. پس از درسچرا آزمون‌های این درس، خواننده را به جای اجرای مستقیم ابزار، با متن ذخیره‌شده‌ی خروجی llvm-objdump مقایسه می‌کنند؟

سؤالات متداول

تنها بخش با مکان ثابت در ساختار فایل ELF چیست؟

بر اساس استاندارد System V gABI، تنها ساختاری که مکان ثابتی در ابتدای فایل دارد هدر ELF است و جایگاه تمامی جدول‌ها و بخش‌های دیگر با آفست‌های ذخیره‌شده درون ساختارهای دیگر مشخص می‌شود.

آفست واقعی بایت‌های یک تابع در فایل ELF بازآدرس‌پذیر چگونه محاسبه می‌شود؟

در یک آبجکت‌فایل بازآدرس‌پذیر، آفست فایل از طریق فرمول sh_offset + st_value - sh_addr محاسبه می‌شود و طول آن برابر با فیلد st_size نماد است.

چرا فایل‌های اجرایی فاقد نماد (Stripped) بدون مشکل در لینوکس اجرا می‌شوند؟

چون بارگذار سیستم‌عامل برای اجرای فایل فقط به هدرهای برنامه (Program Headers) و سگمنت‌های PT_LOAD اتکا می‌کند و هیچ نیازی به جدول هدر بخش‌ها یا نمادها ندارد.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.