فاز 13 · درس 33

دیس‌اسمبلی و جریان کنترل: از بایت‌ها تا ساخت گراف در x86-64

نوع
ساختنی
زمان
حدود 150 دقیقه
زبان‌ها
Python, C, x86-64 assembly
پیش‌نیاز
فاز 13 · 32 (فایل ELF از صفر)
نمایی از بایت‌های باینری و مدارهای منطقی که به یک ساختار گراف جریان کنترل با بلوک‌ها و فلش‌های اتصالی تبدیل می‌شوند
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. قالب دستورالعمل به ازای هر فیلد یک قانون دارد
  4. پیشوندها در چهار گروه قرار می‌گیرند
  5. پیشوند REX چهار بیت اضافه می‌کند و باید کنار آپ‌کد بنشیند
  6. آپ‌کدها در دو نگاشت مجزا قرار دارند
  7. بایت‌های ModRM و SIB عملوندها را مشخص می‌کنند
  8. فیلد جابه‌جایی محلی است که پیونددهنده در آن می‌نویسد
  9. مقادیر بی‌واسطه به جز یک استثنا حداکثر 32 بیت هستند
  10. رمزگشایی دستی یک دستورالعمل
  11. پیمایش خطی
  12. نزول بازگشتی
  13. فیکسچری که در آن پیمایش خطی اشتباه و نزول بازگشتی درست عمل می‌کند
  14. بلوک‌های پایه‌ای
  15. گراف جریان کنترل تابع total
  16. تسلط‌گرها با الگوریتم تکرارشونده
  17. جدول پرش در تابع grade در بهینه‌سازی -O0
  18. بهینه‌ساز در سطح -O1 چه تغییری در grade ایجاد کرد
  19. ابزارهای rea و Ghidra در اینجا چه چیزی ارائه می‌دهند
  20. پیاده‌سازی
  21. شیوه‌ی استفاده
  22. آزمایشگاه تعاملی
  23. آزمایشگاه الف: رمزگشایی روی کاغذ و سنجش
  24. آزمایشگاه ب: بازتخصیص روی دستور lea
  25. آزمایشگاه ج: مشاهده‌ی عدم تطابق دو روش
  26. آزمایشگاه د: محاسبه‌ی دستی تسلط‌گرها
  27. آزمایشگاه تمرینی
  28. آزمایشگاه نمره‌دار
  29. مشاهده‌ی اجرا
  30. خروجی این درس
  31. اعتبارسنجی
  32. حالت‌های شکست در محیط عملیاتی
  33. اصل عملیاتی
  34. برای مطالعه‌ی بیشتر

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

در این درس گام‌به‌گام یک رمزگشای واقعی دستورالعمل‌های معماری x86-64 را بر پایه‌ی راهنمای رسمی اینتل پیاده‌سازی می‌کنید، تفاوت بنیادی پیمایش خطی (Linear sweep) و نزول بازگشتی (Recursive descent) را در مواجهه با تله‌های کد بررسی می‌کنید، و با تفکیک دستورالعمل‌ها به بلوک‌های پایه‌ای، گراف جریان کنترل (CFG) و درخت تسلط را می‌سازید تا ساختار واقعی توابع و حلقه‌ها را به دست آورید.

فهرست دیس‌اسمبلی ادعایی درباره‌ی این است که کدام بایت‌ها دستورالعمل هستند؛ این درس رمزگشا (Decoder) و گرافی را می‌سازد که صحت این ادعا را می‌سنجند.

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

  • رمزگشایی زیرمجموعه‌ی دستورالعمل‌های x86-64 در فیکسچرهای ledger بر اساس قواعد فصل 2 از جلد 2 راهنمای توسعه‌دهنده‌ی نرم‌افزار اینتل (Intel SDM).
  • اثبات درستی رمزگشا در مقایسه با llvm-objdump -d روی تک‌تک دستورالعمل‌های تمام بیلد‌های فیکسچر.
  • اجرای پیمایش خطی (Linear sweep) و نزول بازگشتی (Recursive descent) روی بایت‌های یکسان و توضیح این‌که خطای هرکدام کجاست.
  • خرد کردن دستورالعمل‌ها به بلوک‌های پایه‌ای (Basic blocks) و ساخت گراف جریان کنترل (Control-flow graph یا CFG) با یال‌های نوع‌دار.
  • محاسبه‌ی تسلط‌گرهای بی‌واسطه (Immediate dominators) با الگوریتم تکرارشونده‌ی کوپر، هاروی و کندی (Cooper, Harvey, Kennedy)، و یافتن حلقه‌ها از روی یال‌های بازگشتی (Back edges).
  • بازیابی مقصدهای یک پرش غیرمستقیم (Indirect jump) از روی مدخل‌های بازتخصیص (Relocation entries) که به جدول آن اشاره دارند.

صورت مسئله

شما فایل ledger.c را با بهینه‌سازی -O0 کامپایل کردید و از یک ایجنت (Agent) پرسیدید تابع grade چه کار می‌کند. ایجنت یک دیس‌اسمبلر صدا زد، فهرست خروجی را خواند و پاسخ داد که تابع با دستور jmpq *%rax بدون هیچ مقصد مشخصی تمام می‌شود. بعد هم شبه‌کدی نوشت که فقط یک return 0 داشت.

همان آبجکت‌فایل را در llvm-objdump -d باز می‌کنید و بعد از دستور پرش، چهار بلوک می‌بینید که مقادیر 4، 3، 2 و 1 را ذخیره می‌کنند. این بلوک‌ها از اول در همان فهرست خروجی بودند. اما هیچ بخشی از فهرست نمی‌گفت که اجرای برنامه چطور به آن‌ها می‌رسد؛ به همین خاطر ابزار با آن‌ها مثل کد مرده (Dead code) رفتار کرده بود.

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

نسخه‌ی 6.5.0 ابزار rea دیس‌اسمبلر اختصاصی ندارد. فایل README آن پیشنهاد می‌کند برای تحلیل عمیق از Ghidra، Hopper یا IDA استفاده کنید. برای بررسی آفلاین، rea همچنین می‌تواند نسخه‌ی نصب‌شده‌ی GNU objdump یا Rizin را صدا بزند. گراف فراخوانی (Call graph) آن روی مقصدهای نامشخص متوقف می‌شود: «فراخواننده‌ها/فراخوانده‌ها فقط شامل توابع تفکیک‌شده هستند... جریان محاسباتی بدون مقصد همچنان ناشناخته می‌ماند.». این درس بخشی را پیاده‌سازی می‌کند که این واقعیت‌ها را استخراج می‌کند؛ به این ترتیب وقتی ابزاری آن‌ها را گزارش داد، می‌توانید درباره‌شان قضاوت کنید.

پیمایش خطی در مقابل فرود بازگشتیتابع grade در O0-، 128 بایت
Bytes claimed and control-flow graph of grade under one disassembly methodlinear sweep, finished: 9 blocks, 7 edges, 118 of 118 code bytes, 10 padding bytes decoded as code, jmpq *%rax at 0x74 unresolved.0x300x500x700x90بایت‌های 0x30 تا 0xb0، پایان کد در 0xa60x30-0x6216 دستور، 50 بایتja 0x9a0x62-0x765 دستور، 20 بایتjmpq *%rax0x76-0x7f2 دستور، 9 بایتjmp 0xa10x7f-0x882 دستور، 9 بایتjmp 0xa10x88-0x912 دستور، 9 بایتjmp 0xa10x91-0x9a2 دستور، 9 بایتjmp 0xa10x9a-0xa11 دستور، 7 بایتریزش به 0xa10xa1-0xa63 دستور، 5 بایتretq0xa6-0xb01 دستور، 10 بایتpadding
خانه‌های آبی بایت‌هایی هستند که روش به‌صورت دستورالعمل رمزگشایی کرده است. خانه‌های کهربایی بایت‌های پدینگ پس از آخرین retq هستند که یک روش به‌اشتباه به‌عنوان کد رمزگشایی کرده است. خانه‌های خاکستری بایت‌هایی هستند که هیچ روشی مدعی آن‌ها نشده است. کادر پیوسته نشان‌دهنده‌ی بلوک پایه‌ای است که روش پیدا کرده و کادر خط‌چین بلوکی است که نیافته است. فلش‌ها یال‌های شناخته‌شده برای روش هستند؛ فلش‌های خط‌چین از جدول پرش در rodata. می‌آیند.

قالب دستورالعمل به ازای هر فیلد یک قانون دارد

شکل 1-2 در جلد 2 راهنمای اینتل (Intel SDM)، ترتیب بایت‌های هر دستورالعمل را نشان می‌دهد. ابتدا پیشوندهای اختیاری می‌آیند، بعد یک تا سه بایت آپ‌کد (Opcode)، سپس یک بایت اختیاری ModRM و یک بایت اختیاری SIB. در ادامه یک جابه‌جایی (Displacement) با طول 0، 1، 2 یا 4 بایت، و بعد یک مقدار بی‌واسطه (Immediate) با طول 0، 1، 2 یا 4 بایت قرار می‌گیرد. طول کل دستورالعمل حداکثر 15 بایت است (SDM 2.3.11). بخش‌های 1.1 تا 1.7 از فصل 1 جلد 3 راهنمای AMD (AMD APM) همین قالب را در صفحاتی کمتر شرح داده‌اند و مرجع بسیار خوبی برای بررسی و مقابله هستند.

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

پیشوندها در چهار گروه قرار می‌گیرند

بخش 2.1.1 راهنمای SDM پیشوندهای سنتی (Legacy prefixes) را فهرست می‌کند. گروه 1 شامل پیشوند قفل F0 و پیشوندهای تکرار F2 و F3 است. گروه 2 شامل شش پیشوند بازنویسی سگمنت (Segment override) است (که 2E برای %cs است) به همراه راهنماهای انشعاب (Branch hints). گروه 3 پیشوند 66 است که اندازه‌ی عملوند (Operand-size override) را بازنویسی می‌کند. گروه 4 پیشوند 67 است که اندازه‌ی آدرس (Address-size override) را تغییر می‌دهد.

دفترچه‌ی راهنما می‌گوید از هر گروه حداکثر یک پیشوند مجاز است؛ اما کامپایلرها تعداد بیشتری تولید می‌کنند. پدینگ (Padding) هم‌ترازی در فایل ledger-x86_64-O0.o در آفست 0xf2 به صورت 66 66 66 66 66 2e 0f 1f 84 00 00 00 00 00 است: پنج بایت متوالی 66. ابزار llvm-objdump آن را به صورت nopw %cs:(%rax,%rax) چاپ می‌کند و رمزگشایی هم که شما می‌سازید دقیقاً همین خروجی را می‌دهد. رمزگشا تمام بایت‌های پیشوندی را که می‌بیند جمع‌آوری می‌کند و بعداً می‌پرسد کدام‌ها اهمیت داشته‌اند.

بایت 66 وظیفه‌ی دومی هم دارد. در دستورالعمل‌های SSE این بایت پیشوندی اجباری است که آپ‌کد را مشخص می‌کند؛ بنابراین 66 0f ef c0 دستور pxor %xmm0, %xmm0 است، نه یک عملوند 16 بیتی. رشته‌بایت F3 0f 6f برابر با movdqu است. رمزگشا اول جفت پیشوند اجباری و بایت دوم آپ‌کد را بررسی می‌کند. فقط وقتی این جفت در جدول نباشد، 66 را به معنای عملوند 16 بیتی در نظر می‌گیرد.

پیشوند REX چهار بیت اضافه می‌کند و باید کنار آپ‌کد بنشیند

بخش 2.2.1 در SDM پیشوند REX را تک‌بایتی از 40 تا 4F با الگوی 0100WRXB تعریف می‌کند. بیت W عملوند 64 بیتی را انتخاب می‌کند. بیت R فیلد reg در ModRM را گسترش می‌دهد. بیت X فیلد index در SIB را امتداد می‌دهد. بیت B فیلد r/m در ModRM، فیلد base در SIB، یا رجیستر موجود در بایت آپ‌کد را گسترش می‌دهد (جدول 4-2). پیشوند REX باید درست پیش از آپ‌کد یا بایت‌های گریز (Escape bytes) بیاید؛ اگر بعد از REX یک پیشوند سنتی قرار گیرد، REX نادیده گرفته می‌شود.

در دنباله‌ی 48 89 e5، بایت 48 همان REX با بیت فعال W است. آپ‌کد 89 دستور MOV r/m, r است و بایت ModRM با مقدار e5 رجیستر %rsp را در %rbp می‌نشاند. اگر REX را بردارید، همین بایت‌ها تبدیل به movl %esp, %ebp می‌شوند.

پیشوند REX نام رجیسترهای تک‌بایتی را هم عوض می‌کند. با حضور هر نوع REX، کدهای 4 تا 7 به جای %ah، %ch، %dh و %bh، رجیسترهای %spl، %bpl، %sil و %dil را انتخاب می‌کنند. رمزگشا به همین دلیل دو جدول جداگانه نگه می‌دارد.

آپ‌کدها در دو نگاشت مجزا قرار دارند

آپ‌کد تک‌بایتی حالت رایج است. بایت 0F به نگاشت دوبایتی می‌رود، و دنباله‌های 0F 38 یا 0F 3A به نگاشت‌های سه‌بایتی فرار می‌کنند که در فیکسچرهای این درس هرگز استفاده نمی‌شوند (SDM 2.1.2). بعضی آپ‌کدها از فیلد reg در ModRM به عنوان سه بیت اضافی آپ‌کد استفاده می‌کنند که در راهنما با نشانه‌ی digit/ نوشته می‌شود. مقدار 83 /7 دستور cmp با مقدار بی‌واسطه‌ی 8 بیتی، 83 /5 دستور sub، و FF /4 دستور پرش غیرمستقیم jmp است.

رمزگشای موجود در code/main.py آپ‌کدهای چهار آبجکت‌فایل فیکسچر و چند دستور همسایه را پوشش می‌دهد. این فهرست شامل هشت شکل حسابی، mov، lea، movslq، movzbl، imul، شیفت‌ها، test و idiv است. در ادامه دستورهای push، pop، call، ret، شانزده پرش شرطی، nop چندبایتی و پنج آپ‌کد SSE2 قرار دارند. هر آپ‌کد دیگری خطای DecodeError را همراه با آدرس و دلیل اعلام می‌کند. رمزگشایی که حدس بزند، از رمزگشایی که متوقف شود بدتر است.

بایت‌های ModRM و SIB عملوندها را مشخص می‌کنند

بخش 2.1.3 در SDM بایت ModRM را به سه بخش mod (بیت‌های 7:6)، reg (5:3) و r/m (2:0) تقسیم می‌کند. مقدار mod = 11 یعنی r/m یک رجیستر است. مقادیر mod = 00، 01 و 10 به معنای دسترسی به حافظه به ترتیب بدون جابه‌جایی، با جابه‌جایی 8 بیتی، یا با جابه‌جایی 32 بیتی هستند. مقدار r/m = 100 نشان می‌دهد بایت SIB در ادامه می‌آید. بایت SIB نیز به scale (7:6)، index (5:3) و base (2:0) تقسیم می‌شود و مقدار ایندکس 100 یعنی رجیستر ایندکس وجود ندارد.

دو سطر ویژه در جدول 2-2 در حالت 64 بیتی اهمیت دارند: حالت mod = 00 با r/m = 101 به معنای (%rbp) نیست، بلکه جابه‌جایی 32 بیتی نسبت به دستورالعمل بعدی (RIP-relative) است (SDM 2.2.1.6). حالت mod = 00 با پایه‌ی SIB برابر 101 هم جابه‌جایی 32 بیتی بدون رجیستر پایه است.

دنباله‌ی 8d 44 08 fa از تابع grade در بهینه‌سازی -O0 را ببینید. آپ‌کد 8D همان lea است. بایت ModRM با مقدار 44 برابر 01 000 100 است: جابه‌جایی 8 بیتی، رجیستر %eax، و در ادامه یک SIB. بایت SIB با مقدار 08 برابر 00 001 000 است: مقیاس (Scale) برابر 1، ایندکس %rcx، پایه‌ی %rax. بایت جابه‌جایی fa مقدار 6- است. متن AT&T آن به صورت leal -0x6(%rax,%rcx), %eax درمی‌آید که مقدار score / 10 - 6، یعنی ایندکس switch را محاسبه می‌کند.

دنباله‌ی 48 8d 0d 00 00 00 00 همان leaq (%rip), %rcx است. چهار بایت صفر، فیلد جابه‌جایی یک عملوند وابسته به RIP هستند. در یک آبجکت‌فایل بازتخصیص‌پذیر، این‌ها جایگزین موقت (Placeholder) به حساب می‌آیند. مدخل موجود در سرفصل .rela.text در آفست 0x69 عبارت R_X86_64_PC32 .rodata-0x4 را نشان می‌دهد. استاندارد psABI (جدول 4.9) مقدار R_X86_64_PC32 را به صورت S + A - P تعریف می‌کند: آدرس نماد به‌علاوه‌ی مقدار افزوده‌شده (Addend) منهای آدرس فیلد.

پردازنده مقدار فیلد را به آدرس دستورالعمل بعدی اضافه می‌کند؛ بنابراین مقصد نهایی S + A + (end - P) خواهد بود. برای این دستور lea فیلد شامل چهار بایت پایانی است؛ در نتیجه end - P برابر 4 و مقصد برابر .rodata + (-4) + 4 = .rodata + 0x0 می‌شود. مقدار افزوده‌شده‌ی 4- این چهار بایت را از قبل خنثی می‌کند. تمام عملوندهای وابسته به RIP در فیکسچرها این مقدار 4- را در دل مقدار افزوده‌شده‌ی خود دارند؛ بنابراین .rodata+0x10 در دستور lea تحت -O1 در عمل به معنی .rodata+0x14 است.

رمزگشا آفست فیلد جابه‌جایی را درون هر دستور ثبت می‌کند. متد resolve_reference() آن آفست را در جدول بازتخصیص جستجو می‌کند و گزارش می‌دهد که عملوند پس از پیوند به کجا اشاره خواهد کرد. همین یک تابع است که عبارت leaq (%rip), %rcx را به «جدول موجود در .rodata+0x0» تبدیل می‌کند.

مقادیر بی‌واسطه به جز یک استثنا حداکثر 32 بیت هستند

بخش 2.2.1.5 در SDM می‌گوید مقادیر بی‌واسطه در حالت 64 بیتی حداکثر 32 بیتی می‌مانند و با حفظ علامت گسترش (Sign-extended) پیدا می‌کنند. تنها استثنا دستور MOV r64, imm64 (یعنی B8+r همراه با REX.W) است که در سینتکس AT&T به آن movabsq می‌گویند. رمزگشا مقادیر بی‌واسطه‌ی 8 بیتی را برای عملیات حسابی به صورت علامت‌دار می‌خواند (83 c1 fa برابر با addl $-0x6, %ecx است) و برای تعداد شیفت‌ها و انتخاب‌گرهای بازآرایی (Shuffle) به صورت بدون‌علامت می‌خواند (pshufd $0xee). این تفکیک دقیقاً با خروجی متنی llvm-objdump همخوانی دارد.

انشعاب‌های نزدیک (Near branches)، push و pop به صورت پیش‌فرض عملوند 64 بیتی دارند و پیشوند 66 را نادیده می‌گیرند (SDM 2.2.1.7). به همین دلیل، رمزگشا آن‌ها را بدون نیاز به خواندن پیشوند اندازه‌ی عملوند، pushq، popq، callq و retq نام‌گذاری می‌کند.

رمزگشایی دستی یک دستورالعمل

قبل از این‌که به کد اعتماد کنید، دنباله‌ی 48 69 c0 67 66 66 66 از تابع grade را روی کاغذ رمزگشایی کنید.

  1. بایت 48 در محدوده‌ی 40 تا 4F است؛ پس REX با W = 1 است. اندازه‌ی عملوند 64 بیت خواهد بود.
  2. بایت 69 دستور IMUL r, r/m, imm32 است.
  3. بایت c0 بایت ModRM با مقدار 11 000 000 است: حالت رجیستری، رجیستر %rax، و r/m برابر %rax.
  4. چهار بایت بعدی مقدار بی‌واسطه با ترتیب لیتل‌اندین (Little-endian) هستند: 0x66666667.
  5. ترتیب AT&T به صورت مقدار بی‌واسطه، مبدأ، مقصد است: imulq $0x66666667, %rax, %rax.

طول این دستور هفت بایت است و llvm-objdump هم دقیقاً همین متن را چاپ می‌کند. این ثابت عددی، همان معکوس ضربی است که به کامپایلر clang امکان می‌دهد تقسیم بر 10 را با یک ضرب و دستور بعدیِ sarq $0x22 (که 34 بیت شیفت می‌دهد) انجام دهد. در درس 34 دوباره به این جفت برمی‌خورید؛ جایی که بازگرداننده (Lifter) آن را دوباره به عبارت score / 10 برمی‌گرداند.

رمزگشا این پنج مرحله را در متدهای Decoder.decode()، _State.modrm() و _State.finish() انجام می‌دهد. آن‌ها را به همین ترتیب بخوانید و هر شاخه‌ی شرطی را با بخش نام‌برده از SDM در داک‌استرینگ آن تطبیق دهید.

پیمایش خطی

شوارتز، دبرای و اندروز در بخش 3.1 مقاله‌ی "Disassembly of Executable Code Revisited" (WCRE 2002) پیمایش خطی را شرح می‌دهند: از بایت اول بخش کد شروع کنید، یک دستور را رمزگشایی کنید، کار را از بایت بعد از آن ادامه دهید و در انتهای بخش متوقف شوید. ابزار objdump و همچنین llvm-objdump به همین روش کار می‌کنند.

def linear_sweep(code: bytes, start: int = 0, end: int | None = None) -> SweepResult:
    position = start
    while position < end:
        try:
            instruction = decode(code[:end], position)
        except DecodeError:
            undecodable.append((position, code[position]))
            position += 1
            continue
        instructions.append(instruction)
        position = instruction.end

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

پیمایش خطی داور صحت (Oracle) این درس هم هست. رمزگشا باید در تک‌تک دستورالعمل‌های تمام فیکسچرها با خروجی llvm-objdump -d مطابقت داشته باشد؛ خود llvm-objdump هم یک پیمایش خطی است. متن ذخیره‌شده‌ی آن در code/fixtures/*.objdump.txt قرار دارد. تست مربوطه هر سطر را به آدرس، بایت‌ها و متن تفکیک می‌کند، کامنت‌های بعد از # را کنار می‌گذارد و مقایسه را انجام می‌دهد. چهار آبجکت‌فایل به ترتیب شامل 86، 56، 84 و 11 دستورالعمل (در مجموع 237 دستور) هستند و رمزگشا با تمام آن‌ها تطابق دارد.

نزول بازگشتی

بخش 3.2 همان مقاله، روش دوم را پیمایش بازگشتی می‌نامد: از یک نقطه‌ی ورود مشخص شروع کنید و تا رسیدن به یک انتقال کنترل، رمزگشایی را ادامه دهید. در پرش مستقیم، از نقطه‌ی مقصد کار را دنبال کنید. برای انشعاب شرطی، هم از مقصد و هم از دستور بعدی ادامه دهید؛ و برای یک فراخوانی (Call)، بعد از آن را پیش بگیرید، چون تابع فراخوانده‌شده برمی‌گردد. در برخورد با دستور بازگشت (Return)، متوقف شوید. مجموعه‌ای از آدرس‌های دیده‌شده را نگه دارید تا پیمایش تمام شود.

if instruction.flow == "branch":
    worklist.append(instruction.target)
    address = instruction.end
elif instruction.flow == "indirect-jump":
    table = resolver(instruction, instructions) if resolver else None
    if table is None:
        unresolved.append(instruction)
        break

حالت شکست در این روش برعکس است. پرش غیرمستقیم هیچ مقصدی درون بایت‌های خود ندارد. پیمایش همان‌جا متوقف می‌شود و تمام بلوک‌هایی که فقط از طریق آن پرش در دسترس هستند ناشناخته می‌مانند. شکل 2 مقاله خطر دومی را نشان می‌دهد: مجموعه‌ای از مقاصدِ به دست آمده از بازتخصیص‌ها که به میانه‌ی یک دستورالعمل اشاره می‌کنند. پیاده‌سازی نزول در main.py این وضعیت را به عنوان تداخل ثبت و آن مسیر را متوقف می‌کند؛ بنابراین هم‌پوشانی به جای این‌که در سکوت دو بار رمزگشایی شود، آشکار می‌شود.

آندریسه و دیگران عملکرد نه دیس‌اسمبلر را روی 981 باینری کامپایلر ارزیابی کردند (USENIX Security 2016). وجود داده‌های درون‌خطی و کدهای هم‌پوشان در خروجی‌های واقعی کامپایلر بسیار نادرتر از فرضیات پژوهش‌ها بود، و سخت‌ترین عملیات پایه، بازیابی مرزهای توابع از آب درآمد. هر دو یافته را به خاطر بسپارید: تله‌ای که در بخش بعد می‌بینید در خروجی واقعی کامپایلر نادر است، اما پرش غیرمستقیم موجود در تابع grade بسیار متداول است.

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

فایل code/fixtures/sweep-trap.s شامل 24 بایت کد اسمبلی دست‌نویس با یک جدول چهاربایتی در بدنه‌ی تابع است. تابع با دستور movzbl (%rcx,%rax), %eax ایندکس جدول را می‌خواند و از روی جدول می‌پرد.

lookup:
        movslq  %edi, %rax
        leaq    .Ltable(%rip), %rcx
        movzbl  (%rcx,%rax), %eax
        jmp     .Ldone
.Ltable:
        .byte   0x31, 0xc0, 0x90, 0x48
.Ldone:
        addl    $1, %eax
        retq

آن را با دستور clang -target x86_64-unknown-linux-gnu -c sweep-trap.s بیلد کنید. چهار بایت این جدول طوری انتخاب شده‌اند که شبیه دستورالعمل‌هایی معقول رمزگشایی شوند. دنباله‌ی 31 c0 همان xorl %eax, %eax است. بایت 90 دستور nop است. بایت 48 یک پیشوند REX است؛ بنابراین پیمایش خطی آن را با سه بایت دستور واقعی addl $1, %eax ادغام می‌کند و خروجی addq $0x1, %rax را می‌دهد. متن ثبت‌شده‌ی llvm-objdump دقیقاً همین را نشان می‌دهد:

       e: eb 04                        	jmp	0x14 <lookup+0x14>
      10: 31 c0                        	xorl	%eax, %eax
      12: 90                           	nop
      13: 48 83 c0 01                  	addq	$0x1, %rax
      17: c3                           	retq

هیچ بخشی از این فهرست خراب به نظر نمی‌رسد. همه‌ی بایت‌ها رمزگشایی شده‌اند، تمام نمادها (Mnemonics) واقعی هستند و دستور retq هم سر جایش است. با این حال فهرست از سه جهت ایراد دارد: پرش در آفست 0xe به آدرس 0x14 می‌رود، در حالی که هیچ دستوری در فهرست از آدرس 0x14 شروع نمی‌شود. دستوری که واقعاً در 0x14 اجرا می‌شود یک دستور 32 بیتی addl است، اما فهرست یک دستور 64 بیتی addq را یک بایت عقب‌تر نشان می‌دهد. چهار بایت جدول هم به عنوان کد ظاهر شده‌اند.

نزول بازگشتی از نماد lookup شش دستورالعمل را رمزگشایی می‌کند، پرش به 0x14 را پی می‌گیرد و دستور addl $0x1, %eax را گزارش می‌دهد. این روش هیچ‌کدام از چهار بایت جدول و هیچ‌یک از هشت بایت پدینگ قبل از plus_one را کد در نظر نمی‌گیرد. تابع trap_report() در main.py این مقایسه را چاپ می‌کند:

"linearSweep": {
  "instructions": 9,
  "atJumpTarget": {"starts": "0x13", "text": "addq\t$0x1, %rax", "startsAtTarget": false},
  "claimedDataBytes": 4,
  "claimedPaddingBytes": 8
},
"recursiveDescent": {
  "instructions": 6,
  "atJumpTarget": "addl\t$0x1, %eax",
  "claimedDataBytes": 0,
  "claimedPaddingBytes": 0
}

تست test_sweep_and_objdump_share_the_same_mistake بررسی می‌کند که llvm-objdump در آدرس 0x13 عبارت addq $0x1, %rax را چاپ کرده باشد. این ابزار مرجع، خودش بر پایه‌ی پیمایش خطی کار می‌کند و نقطه‌ی کور آن را به ارث می‌برد. تطابق با مرجع صحت کارکرد رمزگشا را بررسی می‌کند، و روش نزول بازگشتی مشخص می‌کند کدام دستورالعمل‌ها واقعاً اجرا می‌شوند.

بلوک‌های پایه‌ای

آلن در مقاله‌ی "Control Flow Analysis" (1970) بلوک پایه‌ای را تعریف کرد: دنباله‌ای از دستورالعمل‌ها که ورود به آن فقط از طریق اولین دستور و خروج از آن تنها از طریق آخرین دستور ممکن است. یکدان و همکاران این مفهوم را برای دکامپایل در بخش II.A.2 مقاله‌ی "No More Gotos" (NDSS 2015) بازنویسی کرده‌اند که در آن گره سربرگ (Header node) همان نقطه‌ی ورود تابع است.

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

for instruction in self.instructions:
    if instruction.address in leaders or current is None or current.end != instruction.address:
        current = Block(instruction.address, [])
        self.blocks[instruction.address] = current
    current.instructions.append(instruction)

یال‌ها از آخرین دستور هر بلوک به دست می‌آیند. دستور بازگشت هیچ جانشینی ندارد. دستور پرش، یک جانشین با برچسب jump دارد. انشعاب شرطی دو جانشین با برچسب‌های taken و fallthrough دارد. دستور عادی در انتهای بلوک به بلوک بعدی سرریز می‌کند (Fallthrough)، که این حالت وقتی پیش می‌آید که دستور بعدی مقصد یک انشعاب باشد. پرش غیرمستقیم مقاصد جدول خود را با برچسب table می‌آورد، یا اگر مقصدی پیدا نشود، بلوک در مجموعه‌ی unresolved ثبت می‌شود.

دو واقعیت دیگر از دل همین بررسی بیرون می‌آید: بلوکی که از نقطه‌ی ورود قابل دسترسی نباشد، غیرقابل‌دسترس (Unreachable) است و پیمایش خطی روی تابع grade با -O0 پنج بلوک از این نوع تولید می‌کند. بلوکی که آدرس شروعش داخل دستورالعملِ فهرستی از یک متد دیگر بیفتد، همان تداخل ناشی از تله است.

گراف جریان کنترل تابع total

تابع total تحت بهینه‌سازی -O1 طولی معادل 31 بایت دارد و شامل 13 دستورالعمل است:

      40: 85 f6                        	testl	%esi, %esi
      42: 7e 18                        	jle	0x5c <total+0x1c>
      44: 89 f1                        	movl	%esi, %ecx
      46: 31 d2                        	xorl	%edx, %edx
      48: 31 c0                        	xorl	%eax, %eax
      4a: 66 0f 1f 44 00 00            	nopw	(%rax,%rax)
      50: 03 04 97                     	addl	(%rdi,%rdx,4), %eax
      53: 48 ff c2                     	incq	%rdx
      56: 48 39 d1                     	cmpq	%rdx, %rcx
      59: 75 f5                        	jne	0x50 <total+0x10>
      5b: c3                           	retq
      5c: 31 c0                        	xorl	%eax, %eax
      5e: c3                           	retq

لیدرها عبارتند از: 0x40 (ورود)، 0x44 (بعد از jle)، 0x50 (مقصد jne)، 0x5b (بعد از jne)، و 0x5c (مقصد jle). پنج بلوک و پنج یال:

مبدأ مقصد نوع دلیل
0x40 0x44 fallthrough شرط jle برقرار نشده، شمارنده مثبت است
0x40 0x5c taken شمارنده صفر یا منفی است، برگرداندن 0
0x44 0x50 fallthrough دستور nopw پدینگ هم‌ترازی درون بلوک است و اجرای برنامه از آن رد می‌شود
0x50 0x50 taken دستور jne 0x50، بدنه‌ی حلقه
0x50 0x5b fallthrough خروج از حلقه، برگرداندن حاصل جمع

دستور شش‌بایتی nopw در آدرس 0x4a پدینگ است تا سر حلقه (Loop head) در 0x50 روی مرز 16 بایتی بنشیند. چون قابل دسترسی است، به بلوک 0x44 تعلق دارد. دستور تک‌بایتی nop در 0x5f بعد از آخرین retq هم پدینگ است و هیچ مسیری به آن نمی‌رسد. پیمایش خطی روی این تابع یک بلوک ششم برای آن می‌سازد و تابع unreachable() آن را گزارش می‌دهد.

در سطح بهینه‌سازی -O2 همین تابع 140 بایت با دو حلقه است. بدنه‌ی SSE2 در آدرس 0x80 با دستور paddd هشت مقدار را در هر دور جمع می‌زند و باقیمانده‌ی اسکالر در 0xc0 اجرا می‌شود. گراف جریان کنترل شامل دو یال بازگشتی 0x80 -> 0x80 و 0xc0 -> 0xc0 است، و تست‌ها هر دو را بررسی می‌کنند.

تسلط‌گرها با الگوریتم تکرارشونده

بلوک d بر بلوک b تسلط دارد (Dominates)، اگر هر مسیری از نقطه‌ی ورود به b از d بگذرد. تسلط‌گر بی‌واسطه‌ی (Immediate dominator یا idom) بلوک b، آن تسلط‌گری از b است که از همه به b نزدیک‌تر است. این مفهوم از طریق آلن (1970) به پروسر (1959) برمی‌گردد و ساختاری است که درس 34 برای تعیین جای درست if و while به آن نیاز دارد.

کوپر، هاروی و کندی مقاله‌ی "A Simple, Fast Dominance Algorithm" (2001) را نوشتند. آن‌ها نشان می‌دهند که فرمول‌بندی جریان داده، اگر در ترتیب پس‌مرتبه‌ی معکوس (Reverse postorder) با نمایشی فشرده اجرا شود، در گراف‌های کمتر از حدود هزار گره از الگوریتم لنگاور-تارجان (Lengauer-Tarjan 1979) سریع‌تر عمل می‌کند. مقاله‌ی آن‌ها تصریح می‌کند که هدفش «معرفی الگوریتم جدید نیست»، بلکه نشان می‌دهد الگوریتم‌های ساده می‌توانند در عمل بهتر باشند و برای آموزش هم بسیار مناسب‌اند. گراف جریان کنترل یک تابع تقریباً هیچ‌وقت به هزار گره نمی‌رسد.

def intersect(first: int, second: int) -> int:
    while first != second:
        while number[first] > number[second]:
            first = idom[first]
        while number[second] > number[first]:
            second = idom[second]
    return first

changed = True
while changed:
    changed = False
    for start in order[1:]:
        processed = [p for p in self.predecessors(start) if p in idom]
        candidate = processed[0]
        for other in processed[1:]:
            candidate = intersect(other, candidate)
        if idom.get(start) != candidate:
            idom[start] = candidate
            changed = True

آرایه‌ی number جایگاه هر بلوک را در پیمایش پس‌مرتبه‌ی معکوس نگه می‌دارد و نقطه‌ی ورود در آن اندیس 0 دارد. متد intersect دو نشانگر را در درخت تسلط فعلی آن‌قدر بالا می‌برد تا به هم برسند. در متن مقاله، مقایسه با شماره‌های پس‌مرتبه و با علامت < نوشته شده است. این کد از شماره‌های پس‌مرتبه‌ی معکوس استفاده می‌کند؛ بنابراین مقایسه با > انجام می‌شود. بقیه‌ی بخش‌ها دقیقاً مطابق شبه‌کد مقاله است.

برای تابع total در سطح -O1 نتیجه به صورت idom(0x44) = 0x40، idom(0x50) = 0x44، idom(0x5b) = 0x50 و idom(0x5c) = 0x40 است. یال 0x50 -> 0x50 یک یال بازگشتی است؛ چون مقصد آن بر مبدأ مسلط است، و یال بازگشتی نشانه‌ی حلقه است (آلن 1970). تابع back_edges() این یال را بازمی‌گرداند. تست test_dominators_on_a_hand_built_diamond_with_a_loop همین کد را روی دنباله‌ای 16 بایتی با یک نقطه‌ی اتصال (Join point) اجرا می‌کند تا intersect دو گره قبلی (Predecessor) ببیند.

جدول پرش در تابع grade در بهینه‌سازی -O0

در حالت -O0، کامپایلر clang ساختار switch در تابع grade را به الگوی جدول پرشِ مستقل از موقعیت (Position-independent jump table idiom) کامپایل می‌کند:

      5d: 83 e8 04                     	subl	$0x4, %eax
      60: 77 38                        	ja	0x9a <grade+0x6a>
      62: 48 8b 45 f0                  	movq	-0x10(%rbp), %rax
      66: 48 8d 0d 00 00 00 00         	leaq	(%rip), %rcx
		0000000000000069:  R_X86_64_PC32	.rodata-0x4
      6d: 48 63 04 81                  	movslq	(%rcx,%rax,4), %rax
      71: 48 01 c8                     	addq	%rcx, %rax
      74: ff e0                        	jmpq	*%rax

این دنباله را به عنوان چهار گزاره‌ی واقعی بخوانید: دستور subl $0x4 که بعد از آن ja آمده بررسی محدوده (Bounds check) است: ایندکس‌های بالاتر از 4 به حالت پیش‌فرض (Default) در 0x9a می‌روند؛ پس جدول پنج مدخل دارد. دستور leaq با بازتخصیص خود آدرس جدول را در %rcx می‌گذارد و بخش قبلی نشان داد که این آدرس .rodata+0x0 است. دستور movslq یک مدخل 32 بیتی علامت‌دار را با مقیاس 4 بارگذاری می‌کند. دستور addq پایه‌ی جدول را اضافه می‌کند؛ بنابراین هر مدخل برابر با فاصله‌ی جدول تا بلوک case متناظرش است.

در فایل آبجکت، همه‌ی بایت‌های جدول صفر هستند. مقاصد فقط به عنوان بازتخصیص‌هایی در .rela.rodata وجود دارند:

RELOCATION RECORDS FOR [.rodata]:
OFFSET           TYPE                     VALUE
0000000000000000 R_X86_64_PC32            .text+0x91
0000000000000004 R_X86_64_PC32            .text+0x8c
0000000000000008 R_X86_64_PC32            .text+0x87
000000000000000c R_X86_64_PC32            .text+0x82
0000000000000010 R_X86_64_PC32            .text+0x86

فرمول S + A - P را برای مدخل k در آفست 4k به کار ببرید. مقدار ذخیره‌شده برابر با .text + A - (.rodata + 4k) است، و کد مقدار .rodata + 0 را به آن اضافه می‌کند؛ بنابراین مقصد نهایی برابر با .text + A - 4k خواهد بود. مدخل 0 برابر 0x91 - 0 = 0x91، مدخل 1 برابر 0x8c - 4 = 0x88، و مدخل 2 برابر 0x87 - 8 = 0x7f است. مدخل‌های 3 و 4 برابر 0x82 - 12 = 0x76 و 0x86 - 16 = 0x76 هستند. حالت‌های 6، 7، 8، 9 و 10 از عبارت score / 10 به بلوک‌هایی می‌روند که مقادیر 1، 2، 3، 4 و 4 را ذخیره می‌کنند. این رفتار، خط به خط با کد مبدأ C مطابقت دارد، از جمله حالت مشترک برای مقادیر 9 و 10.

متد recover_table() این محاسبات را انجام می‌دهد. تابع jump_table_resolver() این الگو را از دستور jmpq به سمت عقب تطبیق می‌دهد: دستور addq سازنده‌ی رجیستر، movslq بارگذاری‌کننده‌ی آن، leaq سازنده‌ی آدرس پایه و ja بررسی‌کننده‌ی کران را پیدا می‌کند. سپس الگوریتم نزول، چهار مقصد مجزا را به صف اضافه کرده و ادامه می‌دهد. در فایل اجرایی پیوند‌خورده، مقادیر نهایی در جدول قرار دارند و بازتخصیصی در کار نیست. در آن وضعیت، همین تابع مقادیر 32 بیتی علامت‌دار را می‌خواند و آدرس جدول را به آن‌ها اضافه می‌کند؛ فیلد evidence هم مسیری را که مقاصد را استخراج کرده ثبت می‌کند.

آمارهای تابع grade در بهینه‌سازی -O0، به دست آمده از دستور python3 code/main.py cfg code/fixtures/ledger-x86_64-O0.o grade:

روش دستورالعمل‌ها بلوک‌ها یال‌ها بایت‌ها چه اشکالی دارد
پیمایش خطی 34 9 7 118 کد + 10 پدینگ 5 بلوک غیرقابل‌دسترس، بدون یال به چهار بلوک case، پدینگ به صورت nopw رمزگشایی شده
نزول بازگشتی 25 3 2 82 کد 36 بایت کد هرگز دیده نشده، پرش jmpq *%rax در 0x74 حل‌نشده باقی مانده
نزول همراه با جدول 33 8 11 118 کد هیچ مشکلی ندارد

با بازیابی جدول، درخت تسلط کامل می‌شود. تسلط‌گر بی‌واسطه‌ی (idom) هر بلوک case برابر با 0x62 است؛ یعنی همان بلوکی که دستور پرش را دارد. تسلط‌گر بی‌واسطه‌ی 0xa1 همان نقطه‌ی ورود 0x30 است؛ چون مسیر پیش‌فرض از 0x9a بدون عبور از 0x62 به 0xa1 می‌رسد.

بهینه‌ساز در سطح -O1 چه تغییری در grade ایجاد کرد

شاید جایی بخوانید که تابع grade یک switch است که به یک جدول پرش کامپایل می‌شود. اما بایت‌ها چیز دقیق‌تری نشان می‌دهند. در سطوح بهینه‌سازی -O1 و -O2 اصلاً هیچ پرش غیرمستقیمی در grade وجود ندارد:

      2c: 83 f9 04                     	cmpl	$0x4, %ecx
      2f: 77 0c                        	ja	0x3d <grade+0x2d>
      31: 89 c8                        	movl	%ecx, %eax
      33: 48 8d 0d 00 00 00 00         	leaq	(%rip), %rcx
		0000000000000036:  R_X86_64_PC32	.rodata+0x10
      3a: 8b 04 81                     	movl	(%rcx,%rax,4), %eax
      3d: c3                           	retq

مقدار افزوده‌شده‌ی بازتخصیص با مقدار 0x10 به اضافه‌ی 4 معمول، جدول را در .rodata+0x14 می‌نشاند. عملیات بارگذاری با یک دستور ساده‌ی movl انجام می‌شود، و بایت‌های موجود در آن بخش این‌ها هستند: 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 04 00 00 00. کامپایلر پنج بلوک case را با جدولی شامل پنج مقدار بازگشتی جایگزین کرده است. در اینجا بازتخصیص، جدول و بررسی محدوده داریم. جریان کنترل از سه بلوک و سه یال همراه با یک انشعاب شرطی تشکیل شده است. تابع find_value_tables() آن را به عنوان یک value-table با مقادیر [1, 2, 3, 4, 4] و شواهد evidence: "bytes" گزارش می‌دهد.

تابع grade_label در بهینه‌سازی -O2 باز هم جلوتر می‌رود: آرایه‌ی labels شامل پنج اشاره‌گر رشته، به جدولی از پنج آفست 32 بیتی در .rodata+0x0 تبدیل شده است. هر مدخل یک بازتخصیص R_X86_64_PC32 نسبت به بخش رشته‌ها دارد، و تابع آدرس جدول را به مقداری که بارگذاری می‌کند می‌افزاید. متد recover_table() این پنج بازتخصیص را می‌خواند و آفست‌های 0, 2, 4, 6, 8 را در بخش .rodata.str1.1 بازمی‌گرداند که مقادیر F، D، C، B و A را دارند. همان محاسباتی که جدول پرش را بازیابی می‌کند، جدول داده‌های نسبی را هم بازیابی می‌کند؛ چون هر دو بر پایه‌ی S + A - P در یک آفست مشخص هستند.

درس این دو بیلد این است: دقیقاً چیزی را نام ببرید که بایت‌ها نشان می‌دهند. جدول پرش، گراف جریان کنترل را عوض می‌کند. جدول مقادیر، نوع بارگذاری داده را عوض می‌کند. اگر هر دو را صرفاً «switch» بنامیم، تفاوتی پنهان می‌ماند که درس 34 باید آن را بازسازی کند.

ابزارهای rea و Ghidra در اینجا چه چیزی ارائه می‌دهند

ابزار rea نسخه‌ی 6.5.0 با ارائه‌دهنده‌ی Ghidra 12.1.4، متن Listing تابع را از متد procedure_assembly به صورت یک رشته برمی‌گرداند. متد inspect_native_instruction بایت‌ها، طول، متن رمزگشایی‌شده، توکن‌های عملوند، ارجاعات نوع‌دار و جریان کنترلی یک دستورالعمل را برمی‌گرداند و بازگشت دستور را از طریق p-code دسته‌بندی می‌کند. متد xrefs آدرس‌های مبدأ یکتای مرتب‌شده را از ReferenceManager.getReferencesTo در Ghidra تحویل می‌دهد. متد analyze_function گراف جریان کنترل (CFG) و بدنه‌ی (body) تابع را می‌آورد. متد procedure_info بدنه را به شکل بازه‌های فراگیر ارائه می‌دهد و یادآوری می‌کند: «شکاف‌ها و بخش‌های انتهایی مشترک نباید بایت‌های اختصاصی تابع شمرده شوند».

همه‌ی این موارد، همان خروجی سریالایزشده‌ی نزول بازگشتی Ghidra به همراه تحلیل‌گرهای آن هستند. پروفایل تحلیل rea نسخه‌های قرارداد را برای instruction_flow_evidence، function_references و jump_table_evidence تثبیت می‌کند؛ بنابراین هر نتیجه مشخص می‌کند از چه قواعدی تولید شده است. اما نکته اینجاست که نمی‌تواند مراحل کار خودش را نشان دهد. وقتی Ghidra جدول را پیدا نکند، جریان کنترل را «ناشناخته» (unknown) علامت می‌زند و بلوک‌های case از میان فراخواننده‌ها، فراخوانده‌ها و CFG حذف می‌شوند.

حالا سازوکار کار دست شماست. دستور python3 code/main.py cfg را روی همان فایل آبجکت اجرا کنید و نقاط شروع بلوک‌ها، تعداد یال‌ها و جدول بازیابی‌شده را با خروجی ارائه‌دهنده مقایسه کنید. هرجا توافق داشتند، یعنی دو استخراج مستقل و مطمئن دارید. هرجا اختلاف داشتند، آدرس مشخصی دارید که باید مستقیماً بررسی شود؛ و این همان چیزی است که درس 35 به عنوان مجهول ثبت می‌کند.

نکته‌ای درباره‌ی رمزگشا به عنوان یک برنامه: Capstone در نسخه‌ی 6.0.0-Alpha10 (2026-07-21) خواندن و نوشتن‌های خارج از محدوده را در CVE-2026-55893 و CVE-2026-55894 برطرف کرد، و نسخه‌ی 5.0.9 (2026-05-28) هم اصلاحیه‌ها را بک‌پورت کرد. یک رمزگشا بایت‌هایی را می‌خواند که خودش ننوشته است. محدودیت 15 بایتی، بررسی هرس (Truncation check) در _Cursor.byte() و خودداری از حدس زدن آپ‌کدهای ناشناخته، اسکریپت main.py را در برابر ورودی‌های پیش‌بینی‌نشده محافظت می‌کنند.

پیاده‌سازی

اجرای دمو:

cd phases/13-tools-and-protocols/33-disassembly-and-control-flow
python3 code/main.py

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

قالب خروجی مورد انتظار:

{
  "fixtures": {
    "ledger-x86_64-O0.o": {
      "oracle": "86/86 instructions agree with llvm-objdump",
      "functions": {
        "grade": {
          "range": "0x30-0xa6",
          "paddingBytes": 10,
          "linearSweep": {"instructions": 34, "blocks": 9, "edges": 7, "unreachableBlocks": ["0x76", "0x7f", "0x88", "0x91", "0xa6"], "...": "..."},
          "recursiveDescent": {"instructions": 25, "blocks": 3, "edges": 2, "unresolvedIndirectJumps": ["0x74"], "...": "..."},
          "recursiveDescentWithTables": {"instructions": 33, "blocks": 8, "edges": 11, "...": "..."},
          "tables": [{"kind": "jump-table", "table": ".rodata+0x0", "count": 5, "entries": ["0x91", "0x88", "0x7f", "0x76", "0x76"], "evidence": "relocation", "...": "..."}]
        }
      }
    }
  },
  "sweepTrap": {"...": "..."}
}

کد code/main.py را به این ترتیب بخوانید:

  1. کلاس ElfObject سرفصل‌های بخش‌ها، نمادها و مدخل‌های .rela.* را می‌خواند و متد symbolize() آدرس‌ها را مثل llvm-objdump نام‌گذاری می‌کند.
  2. متد Decoder.decode() پیشوندها و REX را می‌خواند، سپس بر اساس نگاشت تک‌بایتی یا دوبایتی آپ‌کدها پیش می‌رود.
  3. متد _State.modrm() فیلدهای ModRM، SIB و جابه‌جایی را می‌خواند و آفست فیلد جابه‌جایی را ذخیره می‌کند.
  4. متد resolve_reference() فرمول S + A - P را روی بازتخصیصِ فیلد جابه‌جایی یا فیلد مقدار بی‌واسطه اعمال می‌کند.
  5. توابع linear_sweep() و recursive_descent() دو روش دیس‌اسمبلی هستند که خطای هرکدام ثبت می‌شود.
  6. توابع recover_table() و jump_table_resolver() محتوای جدول را از روی بازتخصیص‌ها یا بایت‌های خام آن بازیابی می‌کنند.
  7. کلاس ControlFlowGraph لیدرها، بلوک‌ها و یال‌های نوع‌دار را می‌سازد و dominators() الگوریتم تکرارشونده را پیاده‌سازی می‌کند.
  8. متد analyze_function() تمام روش‌ها را روی یک تابع اجرا می‌کند و compare_with_oracle() صحت کار رمزگشا را می‌سنجد.

دو دستور دیگر هم وجود دارد: python3 code/main.py disasm FILE.o فهرستی از دستورات را به همراه مقصد بازتخصیص برای دستوراتی که دارند چاپ می‌کند؛ و python3 code/main.py cfg FILE.o FUNCTION نتایج تحلیل یک تابع مشخص را نمایش می‌دهد.

شیوه‌ی استفاده

روی یک آبجکت‌فایل جدید، این سه روش را دقیقاً به همین ترتیب به کار بگیرید:

  1. اول مقایسه با مرجع را اجرا کنید. رمزگشایی که روی یک دستور عادی با llvm-objdump اختلاف داشته باشد باگ دارد و تمام ساختارهای بعدی این باگ را به ارث می‌برند.
  2. نزول بازگشتی را از نماد هر تابع شروع کنید و لیست unresolved را بخوانید. هر مدخل، یک پرش یا فراخوانی غیرمستقیم است که مقاصدش را ندارید.
  3. برای هر پرش حل‌نشده، الگوی جدول پرش و بازتخصیصِ اشاره‌کننده به آن را پیدا کنید. اگر این الگو تطابق نداشت، بایت‌های قبل از پرش را بخوانید و هرچه می‌بینید یادداشت کنید. این یادداشت اولین مجهول شما در درس 35 خواهد بود.
  4. خروجی پیمایش خطی و نزول بازگشتی را بایت به بایت مقایسه کنید. بایت‌هایی که فقط پیمایش خطی مدعی کد بودنشان است، پدینگ یا داده هستند. بایت‌هایی هم که هیچ‌کدام ادعای مالکیتشان را ندارند، یا غیرقابل‌دسترس هستند یا به تابعی تعلق دارند که هنوز کشف نکرده‌اید.
  5. گراف جریان کنترل را از نزول بازگشتی همراه با جدول‌های بازیابی‌شده بسازید و یال‌های بازگشتی را بررسی کنید. هر یال بازگشتی یک حلقه است که در درس 34 به while یا for تبدیل می‌شود.

فایل‌های متنی مرجع را کنار آبجکت‌فایل‌ها نگه دارید. وقتی به نسخه‌ی جدیدتری از کامپایلر مهاجرت می‌کنید، دوباره آن‌ها را ذخیره کنید؛ چون تغییر در فهرست خروجی یعنی تغییر در فیکسچر.

آزمایشگاه تعاملی

آزمایشگاه الف: رمزگشایی روی کاغذ و سنجش

از دایرکتوری code، پایتون را باز کنید:

cd phases/13-tools-and-protocols/33-disassembly-and-control-flow/code
python3 -q

اجرا کنید:

from main import *
decode(bytes.fromhex("48 8d 0d 00 00 00 00"), 0).text
decode(bytes.fromhex("8d 44 08 fa"), 0).text
decode(bytes.fromhex("66 66 66 66 66 2e 0f 1f 84 00 00 00 00 00"), 0).length

قبل از هر فراخوانی، خروجی مورد انتظار را روی کاغذ بنویسید. بعد بایت 48 را از دستور اول بردارید و تغییر اندازه‌ی عملوند را ببینید. در ادامه دستور decode(bytes.fromhex("0f 0a"), 0) را امتحان کنید و پیام DecodeError را بخوانید.

آزمایشگاه ب: بازتخصیص روی دستور lea

elf = load_fixture("ledger-x86_64-O0.o")
code = elf.section(".text").data
lea = decode(code, 0x66)
lea.displacement_offset
resolve_reference(elf, lea)

ارجاع نشان می‌دهد که مقصد .rodata+0x0 است. آدرس را به 0x116 در grade_label تغییر دهید و ارجاع را به داخل بخش .data بخوانید. سپس دستور callq در آدرس 0x10e را رمزگشایی و تفکیک کنید. فیلد مقدار بی‌واسطه حاوی R_X86_64_PLT32 grade-0x4 است و مقصد تفکیک‌شده 0x30 می‌شود که همان تابع grade است.

آزمایشگاه ج: مشاهده‌ی عدم تطابق دو روش

trap = load_fixture("sweep-trap.o")
text = trap.section(".text").data
[i.text for i in linear_sweep(text, 0, 0x20).instructions]
[i.text for i in recursive_descent(text, [0], 0x20).sorted_instructions()]

دستورالعمل‌ها را در هر لیست بشمارید و دستوری را پیدا کنید که از 0x13 شروع می‌شود. بعد آخرین بایت جدول در فایل sweep-trap.s را از 0x48 به 0xe8 تغییر دهید، دوباره بیلد کنید و هر دو روش را اجرا نمایید. این بار پیمایش خطی، دستورات addl و retq را وارد یک دستور callq با مقصدی خارج از بخش جاری می‌کند؛ طوری که به نظر می‌رسد تابع اصلاً بازگشت ندارد.

آزمایشگاه د: محاسبه‌ی دستی تسلط‌گرها

analysis = analyze_function(load_fixture("ledger-x86_64-O1.o"), "total")
analysis.cfg.edges
analysis.cfg.reverse_postorder()
analysis.cfg.dominators()
analysis.cfg.back_edges()

قبل از دیدن نتیجه، پنج بلوک را روی کاغذ بکشید و تسلط‌گرها را حساب کنید. بعد همین چهار خط را روی تابع grade تحت -O0 اجرا کنید و توضیح دهید چرا idom(0xa1) برابر 0x30 است نه 0x62.

آزمایشگاه تمرینی

یک محل فراخوانی غیرمستقیم (Indirect call site) به فیکسچرها اضافه کنید و تحلیل را گسترش دهید.

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

  • یک تابع C بنویسید که فراخوانی را از طریق اشاره‌گر تابعی انجام دهد که از یک آرایه‌ی کوچک انتخاب می‌شود. آن را با -O0 و -O1 بیلد کنید و آبجکت‌فایل‌ها را همراه با متن llvm-objdump آن‌ها commit کنید.
  • رمزگشا را طوری تنظیم کنید که روی هر دو آبجکت‌فایل با مرجع هماهنگ باشد. آپ‌کدها را فقط وقتی اضافه کنید که دستوری در فیکسچر به آن‌ها نیاز پیدا کند.
  • تابع recursive_descent() را طوری توسعه دهید که موقع برخورد با فراخوانی غیرمستقیم، محل فراخوانی را در calls با مقدار هدف None ثبت کند و بعد از آن به کار ادامه دهد.
  • یک تفکیک‌کننده بنویسید که آرایه‌ی اشاره‌گر تابع را از طریق بازتخصیص‌های آن بخواند و توابع محتمل برای فراخوانی را گزارش دهد.
  • مجموعه‌ی توابع فراخوانده‌شده را با عنوان indirectCalls همراه با نوع شواهد برای هر مقصد به گزارش تابع اضافه کنید.
  • تستی بنویسید که اگر تفکیک‌کننده مقصدی را برگرداند که نماد تابع نیست، با خطا متوقف شود.

موفقیت یعنی دستور python3 code/main.py cfg تمام توابع فراخوانده‌شده‌ی محتمل را با بازتخصیصی که اثباتش می‌کند نشان دهد، و مقایسه با مرجع همچنان روی تمام آبجکت‌فایل‌ها پاس شود.

آزمایشگاه نمره‌دار

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

  1. t1: تعداد دستورالعمل‌هایی که نزول بازگشتی در تابع grade با -O1 رمزگشایی می‌کند.
  2. t2: تعداد بلوک‌های پایه‌ای در تابع total با -O1.
  3. t3: آدرس سر حلقه‌ی تابع total با -O1، به صورت هگزادسیمال.
  4. t4: در sweep-trap.o، آدرسی که دستور پوشش‌دهنده‌ی 0x14 در پیمایش خطی از آن شروع می‌شود، به صورت هگزادسیمال.
  5. t5: تعداد مدخل‌های جدول پرش تابع grade با -O0.

بررسی پاسخ‌ها:

cd phases/13-tools-and-protocols/33-disassembly-and-control-flow
python3 code/lab.py

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

مشاهده‌ی اجرا

ماشین Inside a Decompiler این درس را به عنوان محیط رمزگشایی و تابلوی بلوک‌ها اجرا می‌کند. این پیوند فایل sweep-trap.o را با پیمایش خطی باز می‌کند: محیط چهار بایت داده را نشان می‌دهد که به عنوان کد رمزگشایی شده‌اند، و یک نشانه‌گذار مقصد پرش در 0x14 را وسط دستور addq در 0x13 مشخص می‌کند. رمزگشا را روی نزول بازگشتی بگذارید تا پرش از روی داده‌ها را ببینید.

خروجی این درس

این درس شامل فایل outputs/skill-disassembly-and-control-flow.md است. از آن استفاده کنید تا یک فایل آبجکت و نام یک تابع را به فهرستی معتبر، بلوک‌ها، یال‌های نوع‌دار، تسلط‌گرها و تمام انتقال‌های حل‌نشده تبدیل کنید. این ساختار نیازمند مقایسه با مرجع، هر دو روش دیس‌اسمبلی، حساب‌رسی بایت‌ها، شواهد بازتخصیص برای تمام جدول‌های بازیابی‌شده، و توضیحی روشن از بخش‌های ناشناخته است.

اعتبارسنجی

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

cd phases/13-tools-and-protocols/33-disassembly-and-control-flow
python3 code/main.py
python3 code/tests/test_main.py

اعتبارسنجی باید موارد زیر را اثبات کند:

  • تطابق رمزگشا با llvm-objdump -d روی تمام 237 دستورالعمل موجود در چهار فیکسچر
  • انتخاب عملوندهای 64 بیتی با REX.W، و گسترش فیلدهای رجیستری با REX.R و REX.B
  • نمایش عملوندهای حافظه در فرمت AT&T توسط ModRM و SIB، شامل مقیاس، ایندکس و جابه‌جایی علامت‌دار
  • ثبت جابه‌جایی وابسته به RIP همراه با آفست فیلد مربوطه درون دستورالعمل
  • رمزگشایی پیشوندهای سنتی مکرر و بازنویسی سگمنت درست مانند خروجی مرجع
  • انتخاب آپ‌کد SSE با پیشوند اجباری 66 در حالی که رجیسترها 32 بیتی باقی بمانند
  • بسط علامت برای مقادیر بی‌واسطه‌ی 8 بیتی در عملیات حسابی و در نظر گرفتن بدون‌علامت برای تعداد شیفت‌ها
  • پرتاب خطای DecodeError در دستورالعمل‌های طولانی‌تر از 15 بایت، دستورات هرس‌شده، و آپ‌کدهای ناشناخته
  • تفکیک R_X86_64_PC32 در دستور lea به جدول، و R_X86_64_PLT32 در دستور call به تابع فراخوانده‌شده
  • رمزگشایی جدول تله به صورت کد توسط پیمایش خطی و نبود دستور در آدرس مقصد پرش
  • ادعا نکردن نزول بازگشتی نسبت به جدول و پدینگ، و خواندن دستور addl $0x1, %eax در مقصد
  • بروز همان خطای پیمایش خطی توسط llvm-objdump در فایل تله
  • ثبت ورود به میانه‌ی یک دستورالعمل به عنوان یک تداخل
  • متوقف شدن نزول بازگشتی در مواجهه با پرش غیرمستقیم حل‌نشده و ثبت آن در فهرست
  • وجود پنج بلوک، پنج یال نوع‌دار، یک یال بازگشتی و تسلط‌گرهای بی‌واسطه‌ی مورد انتظار در تابع total با -O1
  • تولید همان تسلط‌گرهای الگوریتم روی ساختار لوزی دست‌ساز همراه با حلقه
  • وجود دو حلقه در تابع total با -O2
  • استخراج یک جدول پرش با پنج مدخل از .rela.rodata برای grade با -O0 با مقاصد 0x91, 0x88, 0x7f, 0x76, 0x76
  • وجود سه بلوک و یک پرش حل‌نشده در تابع grade با -O0 در غیاب جدول
  • تبدیل تابع grade با -O1 و -O2 به یک جدول مقادیر [1, 2, 3, 4, 4] بدون پرش غیرمستقیم
  • خواندن جدول رشته‌های نسبی از طریق پنج بازتخصیص در تابع grade_label با -O2
  • انطباق دقیق داده‌های توکار تصویر تعاملی با داده‌های حاصل از تحلیل

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

حالت شکست ابزار ضعیف چه گزارش می‌دهد تحلیل چه چیزی را باید اثبات کند
داده درون .text فهرستی به ظاهر درست که تک‌تک بایت‌هایش رمزگشایی شده‌اند نزول بازگشتی نباید هیچ بایت داده‌ای را ادعا کند و مقصد پرش باید اول یک دستور باشد
پدینگ پس از آخرین بازگشت یک دستور nopw در انتهای تابع این بایت‌ها از نقطه‌ی ورود تابع در دسترس نیستند
پرش غیرمستقیم از طریق جدول عبارت "jmp rax" و چهار بلوک مرده آدرس جدول از بازتخصیص lea، تعداد مدخل‌ها از بررسی کران، و مقاصد از .rela.rodata به دست آیند
تبدیل switch به جدول مقادیر گزارش «جدول پرش به همراه بازتخصیص» بارگذاری با movl از .rodata+0x14، سه بلوک، و بدون هیچ انتقال غیرمستقیم
فراخوانی با جابه‌جایی صفر دستور callq 0x113؛ یعنی همان دستور بعدی بازتخصیص R_X86_64_PLT32 grade-0x4 به آدرس 0x30 تفکیک شود
حدس زدن آپ‌کد ناشناخته توسط رمزگشا نام نمادینی که درست به نظر می‌رسد ایجاد DecodeError همراه با آدرس، و ثبت بایت توسط پیمایش خطی
دستورالعمل طولانی‌تر از 15 بایت یک «دستورالعمل» بسیار طولانی مکان‌نما قبل از خواندن بایت 16 خطا صادر کند
انشعاب به میانه‌ی یک دستورالعمل دو فهرست با بایت‌های متداخل ثبت رکورد تداخل همراه با هر دو آدرس
گزارش حلقه به عنوان کد خطی نبود while در شبه‌کد یک یال بازگشتی که مقصد آن بر مبدأ مسلط است
ذخیره‌ی داور صحت از یک کامپایلر دیگر پاس شدن تست‌ها روی متنی که کهنه است فایل README فیکسچر باید کامپایلر و دستور دقیق را مشخص کند

اصل عملیاتی

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

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

خودآزمایی

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

  1. پیش از درسخروجی دیس‌اسمبل از llvm-objdump نشان می‌دهد که تک‌تک بایت‌های یک تابع به نمادهای معتبر (Mnemonics) رمزگشایی شده‌اند. این موضوع چه چیزی را اثبات می‌کند؟
  2. حین درسبایت‌های 48 8d 0d 00 00 00 00 به leaq (%rip), %rcx رمزگشایی می‌شوند و rela.text. حاوی R_X86_64_PC32 .rodata-0x4 در فیلد جابه‌جایی است. پس از پیوند (Link)، عملوند به کجا اشاره می‌کند؟
  3. حین درسدر نمونه‌ی آزمایشی، بایت‌های جدول 31 c0 90 48 هستند و دستور واقعی بعدی 83 c0 01 (یعنی addl $1, %eax) است. پیمایش خطی در مقصد پرش 0x14 چه چیزی چاپ می‌کند؟
  4. حین درسفرود بازگشتی روی تابع grade در O0- در jmpq *%rax متوقف شده و سه بلوک را گزارش می‌دهد. چه شواهدی (Evidence) به آن امکان می‌دهد تا چهار بلوک case دیگر را نیز پیدا کند؟
  5. پس از درسچرا الگوریتم تکراری Dominator مربوط به کوپر، هاروی و کندی، بلوک‌ها را در ترتیب معکوس پسا‌ترتیبی (Reverse Postorder) پردازش می‌کند؟
  6. پس از درسدر سطح O1- کامپایلر clang تابع grade را به‌صورت cmpl $4, %ecx; ja; leaq (%rip), %rcx به‌همراه بازنشانی به rodata. و سپس movl (%rcx,%rax,4), %eax; retq کامپایل می‌کند. تحلیل باید چه چیزی گزارش دهد؟

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

تفاوت اصلی پیمایش خطی با نزول بازگشتی در دیس‌اسمبلی چیست؟

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

چگونه مقاصد یک پرش غیرمستقیم (Jump Table) در فایل آبجکت بازیابی می‌شوند؟

با ردیابی دستورات قبل از پرش برای پیدا کردن الگوی جدول، خواندن مدخل‌های بازتخصیص در بخش .rela.rodata و اعمال رابطه‌ی S + A - P روی آفست‌های جدول، مقاصد واقعی استخراج می‌شوند.

بلوک پایه‌ای (Basic Block) در تحلیل باینری چه مفهومی دارد؟

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

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

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