Featured image of post هل يمكن استعادة الكود المصدري من ملف تنفيذي؟ : علم وخوارزميات وحدود فك الترجمة (Decompilation)

هل يمكن استعادة الكود المصدري من ملف تنفيذي؟ : علم وخوارزميات وحدود فك الترجمة (Decompilation)

لماذا يُشبَّه فك ترجمة ملف ثنائي (.exe أو ELF) إلى كود مصدري بلغة C/C++ بمحاولة «إعادة البيض المقلي إلى بيض نيئ داخل قشرته»؟ استكشاف تقني عميق للتفكيك بالمسح الخطي مقابل التتبع العودي، والرفع إلى التمثيل الوسيط (IR) وصيغة SSA، واستنتاج الأنواع عبر الشبكات الرياضية، وهيكلة تدفق التحكم دون goto (خوارزمية DREAM)، والتعمية (OLLVM/VMProtect)، ومفككات الترجمة العصبية المعتمدة على النماذج اللغوية الكبيرة.

1. مقدمة: هل يمكنك إعادة «العجة» إلى «بيض نيئ» داخل قشرته؟

في تطوير البرمجيات اليومي، يقوم المترجم (Compiler مثل GCC أو Clang/LLVM أو MSVC) بتحويل الكود المصدري المقروء المكتوب بلغة C أو C++ أو Rust إلى ملف تنفيذي بلغة الآلة (ملف .exe بصيغة PE في ويندوز أو ELF في لينكس). ولكن ماذا يحدث عندما نحتاج إلى السير في الاتجاه المعاكس — مثلاً عندما يحلل باحث أمني برمجية فدية خبيثة، أو يدقق مهندس في برنامج تشغيل مغلق المصدر، أو تحتاج مؤسسة إلى صيانة نظام قديم عمره 25 عاماً فُقد كوده المصدري؟ هل يمكننا إدخال الملف الثنائي في مفكك ترجمة (Decompiler) لاستعادة كود C الأصلي؟

تُلخص مقولة شهيرة في مجتمع الهندسة العكسية هذا التحدي: «الترجمة (Compilation) تشبه خفق البيض وقليه لصنع عجة (Omelet)؛ أما فك الترجمة (Decompilation) فهو محاولة إعادة العجة إلى بيض نيئ داخل قشرته».

من منظور الرياضيات ونظرية المعلومات، فإن دالة الترجمة $\mathcal{C}: \text{Source} \to \text{Binary}$ هي تطبيق متعدد-إلى-واحد (غير متباين) يفقد المعلومات. فعدد لا نهائي من البرامج المصدرية المختلفة — في أسماء المتغيرات، والتعليقات، وهياكل الحلقات (for مقابل while)، والدوال المضمنة (inlined) — تتحول بعد التحسين (-O2 أو -O3) إلى نفس تسلسل البايتات حرفياً. وبما أن الإنتروبيا الشرطية $H(\text{Source} \mid \text{Binary}) > 0$ موجبة تماماً، فلا توجد دالة عكسية حتمية $\mathcal{C}^{-1}$ قادرة على استرداد ما مُحي فيزيائياً أثناء الترجمة وتجريد الرموز (strip). والأدهى من ذلك أنه بموجب مبرهنة رايس (Rice’s Theorem) ومسألة التوقف لتورينغ، فإن الفصل الدقيق بين التعليمات والبيانات، وحل جميع وجهات القفز غير المباشر (jmp rax)، وتحليل تداخل المؤشرات (Pointer Aliasing) هي مسائل غير قابلة للحسم رياضياً (Undecidable) في الحالة العامة.

ومع ذلك، تنجح مفككات الترجمة الصناعية الحديثة — مثل IDA Pro (Hex-Rays) وGhidra (المفتوح المصدر من وكالة NSA) وBinary Ninja وangr — يومياً في تحويل ميغابايتات من لغة التجميع الصماء إلى كود C شبه أصلي واضح ومهيكل. في هذه المقالة، نشرح المراحل الخوارزمية الست لمفكك الترجمة الحديث.


2. ماذا تدمر عملية الترجمة؟ : خط الأنابيب الأمامي

  1. الواجهة الأمامية (التحليل اللغوي وفحص الأنواع $\to$ IR): تختفي التعليقات، وماكرو #define، وأسماء typedef، وأسماء المتغيرات المحلية، والسكر النحوي عند النزول إلى التمثيل الوسيط (LLVM IR).
  2. محسّن المستوى الأوسط (-O2 / -O3): يذيب تضمين الدوال (Function Inlining) الحدود بين الدوال؛ ويغير فك الحلقات (Loop Unrolling) والتوجيه الشعاعي (SIMD) بنية التحكم؛ ويستبدل تقليل القوة (Strength Reduction) قسمة الأعداد الصحيحة البسيطة x / 10 بعملية ضرب في عدد سحري ثابت النقطة (imul 0x66666667).
  3. تخصيص المسجلات وتجريد الرموز (strip): تُسقط المسجلات الافتراضية اللانهائية عبر تلوين البيان على 16 مسجلاً فيزيائياً فقط في معمارية x86-64. ونتيجة لذلك، يُعاد استخدام نفس المسجل الفيزيائي مثل rax لعشرات المتغيرات غير المترابطة ذات الأنواع المختلفة، بينما يحذف أمر strip جدول الرموز (.symtab) ومعلومات تصحيح الأخطاء DWARF.
  flowchart TD
    subgraph Forward ["الترجمة الأمامية (فقدان غير عكوس للمعلومات)"]
        S1["كود مصدري عالي المستوى (.c / .cpp / .rs)<br/>أسماء، هياكل Structs، حلقات مهيكلة"]
        S2["التمثيل الوسيط (LLVM IR)<br/>إزالة السكر النحوي، تضمين الدوال، فك الحلقات"]
        S3["ملف آلة ثنائي مجرد (.exe / ELF)<br/>إعادة استخدام المسجلات، محو الأنواع، قفزات مسطحة"]
        S1 -->|"الواجهة الأمامية"| S2 -->|"المحسّن والواجهة الخلفية"| S3
    end

    subgraph Backward ["فك الترجمة العكسي (إعادة البناء الخوارزمي)"]
        D1["1. التفكيك (Disassembly)<br/>تحويل البايتات إلى تعليمات (المسح الخطي مقابل التتبع العودي)"]
        D2["2. الرفع إلى IR وتحويل SSA<br/>أعلام صريحة، فصل فترات الحياة، سلاسل Def-Use"]
        D3["3. تحليل إطار المكدس واستنتاج الأنواع بالشبكات<br/>استعادة المؤشرات، إزاحات الهياكل، والإشارة"]
        D4["4. هيكلة تدفق التحكم (استعادة شجرة AST)<br/>أشجار الهيمنة والطي الخالي من goto (DREAM)"]
        D1 --> D2 --> D3 --> D4
    end

    S3 ==>|"مدخل لمفكك الترجمة"| D1

3. المرحلة الأولى: التفكيك (Disassembly) — فصل التعليمات عن البيانات

في المعماريات ذات أطوال التعليمات المتغيرة مثل x86-64 (من $1$ إلى $15$ بايت)، يدمج المترجم غالباً بيانات للقراءة فقط — ولا سيما جداول القفز (Jump Tables) الخاصة بجمل switch — مباشرة داخل قسم الكود .text.

  1. المسح الخطي (Linear Sweep، مثل objdump -d): يفك تشفير البايتات بالتتابع من البداية للنهاية، لكنه يفسر جداول القفز المدمجة خطأً على أنها تعليمات زائفة.
  2. التتبع العودي (Recursive Traversal، مثل IDA Pro وGhidra): يبدأ من نقاط الدخول المعروفة (main والدوال المصدرة) ويتتبع بيان تدفق التحكم الفعلي عبر تعليمات التفرع (je, jmp, call). ولحل القفزات غير المباشرة (jmp [rbx + rcx*8])، يستخدم مفكك الترجمة التشريح الخلفي (Backward Slicing) وتحليل مجموعات القيم (Value-Set Analysis, VSA) للعثور على فحص الحدود cmp rcx, 12; ja default واستنتاج حجم الجدول.

4. المرحلة الثانية: الرفع إلى التمثيل الوسيط (IR Lifting) وصيغة التعيين الأحادي الساكن (SSA)

تحمل تعليمات الآلة آثاراً جانبية ضمنية معقدة (فتعليمة sub eax, ebx واحدة في x86 تعدل ستة أعلام حالة في آن واحد: ZF, CF, SF, OF, PF, AF). يرفع مفكك الترجمة (Lifting) الكود الأصلي أولاً إلى تمثيل وسيط مستقل عن المعالج (P-code في Ghidra، وMicrocode في Hex-Rays)، ثم يحوله إلى صيغة التعيين الأحادي الساكن (SSA Form) حيث يُعيَّن كل متغير مرة واحدة فقط (مع إدراج دوال $\phi$ $\text{rax}_3 = \phi(\text{rax}_1, \text{rax}_2)$ عند نقاط التقاء التفرعات):

  • حذف الأعلام الميتة: يتم حذف أكثر من $90\%$ من حسابات الأعلام التي لا يقرؤها أي قفز شرطي فوراً عبر سلاسل Def-Use.
  • نشر التعبيرات: يتم تعويض التعريف ZF_2 = (eax_2 == 0) مباشرة داخل if (ZF_2) لاستعادة الشرط if (eax_2 == 0).
  • فصل فترات الحياة (Live-Range Splitting): تنفصل شبكات SSA غير المتصلة التي تشاركت نفس المسجل الفيزيائي rax تلقائياً إلى متغيرات محلية مستقلة في لغة C (int v1; مقابل Node *v2;)!

5. المرحلة الثالثة: تحليل المكدس واستنتاج الأنواع عبر الشبكات الرياضية (Lattice)

رغم أن كود الآلة يخلو من الأنواع الصريحة، فإن دلالات تعليمات المعالج تفرض قيوداً جبرية صارمة على الأنواع:

  • التمييز بين الأعداد بإشارة (int) وبدون إشارة (unsigned int): تثبت القفزات الشرطية jg / jl (Greater/Less) والقسمة بإشارة idiv والإزاحة الحسابية sar وتمديد الإشارة movsxd أن المعامل عدد صحيح بإشارة (int)؛ بينما تثبت ja / jb (Above/Below) وdiv والإزاحة المنطقية shr والتمديد الصفري movzx أنه عدد صحيح بدون إشارة (unsigned int)!
  • إعادة بناء المؤشرات والهياكل (struct): يولد تحصيل العنوان [rbx] قيد المؤشر $\text{ptr}(\tau)$. ويكشف الوصول إلى إزاحات ثابتة متعددة ([rbx + 0] و[rbx + 8] و[rbx + 16]) أن rbx مؤشر إلى هيكل مركب struct S *.
  • شبكة الأنواع (Type Lattice) والتوحيد: استناداً إلى ألان مايكروفت (1999) ونظام TIE (جامعة كارنيغي ميلون)، ينظم مفكك الترجمة جميع الأنواع في شبكة مرتبة جزئياً $(\mathcal{L}, \sqsubseteq)$ بين القمة $\top$ (متجه بتات خام) والقاع $\bot$ (تعارض أنواع / union)، ويحل متباينات الأنواع الفرعية عالمياً عبر الحد الأدنى الأكبر (Meet $\sqcap$).
  flowchart TD
    TOP["⊤ (القمة: متجه بتات خام bit64)"]
    NUM["num64 (عدد صحيح أو مؤشر)"]
    FLT["float64 (عدد عشري IEEE 754: addsd, mulsd)"]
    SINT["int64_t (بإشارة: sar, idiv, jg, jl, movsxd)"]
    UINT["uint64_t (بدون إشارة: shr, div, ja, jb)"]
    PTR["ptr(τ) (مؤشر: تحصيل [reg])"]
    STRUCT["struct S* (مؤشر هيكل: وصول [reg + offset])"]
    BOT["⊥ (القاع: تعارض أنواع → تحويل Cast أو Union)"]

    TOP --> NUM
    TOP --> FLT
    NUM --> SINT
    NUM --> UINT
    UINT --> PTR
    PTR --> STRUCT
    SINT --> BOT
    STRUCT --> BOT
    FLT --> BOT

6. المرحلة الرابعة: هيكلة تدفق التحكم — إزالة goto لاستعادة if وwhile

لطي بيان تدفق التحكم المسطح (CFG) في شجرة بناء نحوي مجردة (AST) مهيكلة (Cifuentes 1994)، يحسب مفكك الترجمة شجرة الهيمنة (Dominator Tree) وشجرة الهيمنة البعدية (Post-Dominator Tree):

  1. كشف الحلقات الطبيعية: أي حافة خلفية (Back-Edge) $n \to h$ يهيمن فيها الهدف $h$ على المصدر $n$ ($h \text{ dom } n$) تعرّف حلقة طبيعية رأسها $h$ (while أو for أو do-while).
  2. كشف التفرعات (if-then-else): يحدد المهيمن البعدي المباشر لعقدة التفرع نقطة التقاء معين if-else بدقة.
  3. الهيكلة الخالية من goto (خوارزمية DREAM): حتى مع البيانات غير القابلة للاختزال، تحسب خوارزمية DREAM («No More Gotos»، Yakdan et al. 2015) شروط الوصول المنطقية (Reaching Conditions) وتطبق تبسيط الدوال المنطقية لإنتاج كود C خالٍ بنسبة 100% من جمل goto!

7. حدود فك الترجمة: تمويه التحسين، والتعمية، ومفككات الترجمة العصبية (LLM)

عنصر البرنامجغير محسّن (-O0 -g مع DWARF)إصدار مجرد (-O3 + strip)معمّى (OLLVM / VMProtect)
أسماء الدوال والمتغيرات والأنواعمستعادة بنسبة $100\%$ من DWARFممحوة نهائياً (sub_401120, v2)ممحوة + رموز مضللة زائفة
القسمة على ثابت (x / 10)تعليمة idiv مباشرةضرب في عدد سحري (0x66666667)كثيرات حدود منطقية-حسابية مختلطة (MBA)
تدفق التحكم (if / while)شجرة AST مهيكلة نظيفةفك الحلقات وتوجيه SIMDتسطيح تدفق التحكم (CFF) في switch ضخم
دلالات التعليمات (ISA)تعليمات x86-64 / ARM64 أصليةتعليمات x86-64 / ARM64 أصليةالتعمية بالمحاكاة الافتراضية: آلة bytecode مخصصة

بينما تهاجم أدوات التعمية (OLLVM وVMProtect) التحليل الساكن عبر تسطيح تدفق التحكم (CFF)، والمسندات المعتمة (Opaque Predicates)، والحساب المنطقي المختلط (MBA، $x+y \equiv (x\oplus y) + 2(x\land y)$)، والتعمية بالمحاكاة الافتراضية (تحويل الكود إلى bytecode عشوائي خاص)، يشهد عقد العشرينيات ظهور فك الترجمة العصبي بالنماذج اللغوية الكبيرة (LLM4Decompile) الذي يجمع بين التحليل الرسمي والذكاء الاصطناعي الإحصائي لاستنتاج أسماء متغيرات ودوال ذات معنى وإعادة صياغة الكود المحسّن في لغة C طبيعية.

comments powered by Disqus