Featured image of post क्या किसी एक्ज़ीक्यूटेबल फ़ाइल से मूल प्रोग्राम वापस पाया जा सकता है? : डीकंपाइलेशन (Decompilation) का विज्ञान, एल्गोरिदम और सीमाएँ

क्या किसी एक्ज़ीक्यूटेबल फ़ाइल से मूल प्रोग्राम वापस पाया जा सकता है? : डीकंपाइलेशन (Decompilation) का विज्ञान, एल्गोरिदम और सीमाएँ

मशीन कोड बाइनरी (.exe या ELF) से C/C++ सोर्स कोड वापस पाने को 'ऑमलेट से वापस कच्चा अंडा बनाने' जैसा क्यों कहा जाता है? लीनियर स्वीप बनाम रिकर्सिव ट्रैवर्सल डिसअसेंबली, IR लिफ्टिंग और SSA रूप, लैटिस-आधारित टाइप इनफरेंस, डोमिनेटर ट्री और goto-मुक्त कंट्रोल-फ़्लो स्ट्रक्चरिंग (DREAM), ओब्फ़स्केशन (OLLVM/VMProtect) और न्यूरल LLM डीकंपाइलर्स का गहन विश्लेषण।

1. प्रस्तावना: क्या आप ‘ऑमलेट’ से वापस ‘कच्चा अंडा’ बना सकते हैं?

दैनिक सॉफ़्टवेयर विकास में, एक कंपाइलर (जैसे GCC, Clang/LLVM, या MSVC) मानव द्वारा पढ़े जा सकने वाले C, C++ या Rust सोर्स कोड को मशीन-कोड एक्ज़ीक्यूटेबल (Windows की .exe PE फ़ाइल या Linux की ELF बाइनरी) में बदलता है। लेकिन क्या हो जब हमें उल्टी दिशा में जाना पड़े—उदाहरण के लिए जब कोई सुरक्षा विश्लेषक रैनसमवेयर बाइनरी की जाँच करता है, या किसी 25 साल पुराने सिस्टम का सोर्स कोड खो जाने पर उसमें सुधार करना पड़ता है? क्या हम मशीन बाइनरी को किसी डीकंपाइलर (Decompiler) में डालकर मूल C सोर्स कोड वापस पा सकते हैं?

रिवर्स-इंजीनियरिंग जगत में एक प्रसिद्ध कहावत है: “कंपाइलेशन अंडे फेंटकर ऑमलेट बनाने जैसा है; और डीकंपाइलेशन उस बने हुए ऑमलेट से वापस छिलके सहित कच्चे अंडे निकालने का प्रयास है।"

गणित और सूचना सिद्धांत (Information Theory) की दृष्टि से, कंपाइलेशन फ़ंक्शन $\mathcal{C}: \text{Source} \to \text{Binary}$ एक सूचना-ह्रास वाला अनेक-से-एक (Many-to-One, Non-injective) प्रतिचित्रण है। अलग-अलग वेरिएबल नाम, टिप्पणियों, लूप संरचनाओं (for बनाम while) और इनलाइन फ़ंक्शनों वाले अनंत सोर्स प्रोग्राम -O2 या -O3 ऑप्टिमाइज़ेशन के बाद बिल्कुल समान मशीन बाइट अनुक्रम उत्पन्न करते हैं। चूँकि सप्रतिबंध एन्ट्रॉपी $H(\text{Source} \mid \text{Binary}) > 0$ धनात्मक है, इसलिए कोई भी प्रतिलोम फ़ंक्शन $\mathcal{C}^{-1}$ उस सूचना को वापस नहीं ला सकता जो कंपाइलेशन और सिंबल स्ट्रिपिंग (strip) के दौरान मिटा दी गई है। इसके अलावा, राइस के प्रमेय (Rice’s Theorem) और ट्यूरिंग की हॉल्टिंग समस्या के अनुसार, कोड और इनलाइन डेटा को पूर्णतः अलग करना, अप्रत्यक्ष जंप (jmp rax) के सभी लक्ष्यों को जानना और पॉइंटर एलियासिंग तय करना सामान्य स्थिति में गणितीय रूप से अनिर्धार्य (Undecidable) है।

फिर भी, आधुनिक औद्योगिक डीकंपाइलर—जैसे IDA Pro (Hex-Rays), NSA का ओपन-सोर्स Ghidra, Binary Ninja, और angr—प्रतिदिन लाखों बाइट्स के मशीन कोड को अत्यंत पठनीय और सुव्यवस्थित C स्यूडोकोड में बदल देते हैं। इस लेख में हम आधुनिक डीकंपाइलर के छह मुख्य एल्गोरिदमिक चरणों का विश्लेषण करेंगे।


2. कंपाइलेशन क्या नष्ट करता है? : फॉरवर्ड कंपाइलर पाइपलाइन

  1. फ्रंटएंड (पार्सिंग और टाइप चेकिंग $\to$ IR): टिप्पणियाँ, #define मैक्रो, typedef उपनाम, लोकल वेरिएबल नाम और सिंटैक्टिक शुगर इंटरमीडिएट रिप्रेजेंटेशन (LLVM IR) में उतरते समय समाप्त हो जाते हैं।
  2. मिडिल-एंड ऑप्टिमाइज़र (-O2 / -O3): फ़ंक्शन इनलाइनिंग (Function Inlining) फ़ंक्शन की सीमाओं को मिटा देता है; लूप अनरोलिंग और SIMD वेक्टराइज़ेशन नियंत्रण प्रवाह को बदल देते हैं; और स्ट्रेंथ रिडक्शन (Strength Reduction) साधारण पूर्णांक विभाजन x / 10 को जादुई स्थिरांक गुणन (imul 0x66666667) में बदल देता है।
  3. बैकएंड रजिस्टर आवंटन और strip: अनंत SSA वर्चुअल रजिस्टरों को ग्राफ कलरिंग द्वारा x86-64 के मात्र 16 भौतिक रजिस्टरों पर मैप किया जाता है। परिणामस्वरूप, एक ही भौतिक रजिस्टर rax का उपयोग एक ही फ़ंक्शन के अलग-अलग हिस्सों में भिन्न प्रकार के दर्जनों असंबंधित वेरिएबल्स के लिए बार-बार किया जाता है, जबकि strip सिंबल टेबल (.symtab) और DWARF डीबग जानकारी को पूरी तरह मिटा देता है।
  flowchart TD
    subgraph Forward ["फॉरवर्ड कंपाइलेशन (अपरिवर्तनीय सूचना हानि)"]
        S1["उच्च-स्तरीय सोर्स कोड (.c / .cpp / .rs)<br/>नाम, Structs, Typedefs, संरचित लूप"]
        S2["मिडिल-एंड IR (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/>पॉइंटर, Struct ऑफ़सेट और चिह्न (Signedness) की पुनर्प्राप्ति"]
        D4["4. कंट्रोल-फ़्लो स्ट्रक्चरिंग (AST पुनर्प्राप्ति)<br/>डोमिनेटर ट्री और goto-मुक्त क्षेत्र फ़ोल्डिंग (DREAM)"]
        D1 --> D2 --> D3 --> D4
    end

    S3 ==>|"डीकंपाइलर इनपुट"| D1

3. चरण 1: डिसअसेंबली (Disassembly) — निर्देशों को डेटा से अलग करना

x86-64 जैसे परिवर्तनीय-लंबाई निर्देश आर्किटेक्चर ($1$ से $15$ बाइट्स) में, कंपाइलर अक्सर रीड-ओनली डेटा—विशेष रूप से switch स्टेटमेंट के जंप टेबल (Jump Tables)—को सीधे .text कोड सेक्शन के भीतर एम्बेड कर देते हैं।

  1. लीनियर स्वीप (Linear Sweep, objdump -d): पहले बाइट से अंत तक क्रमिक रूप से डिकोड करता है, किंतु बीच में आए जंप टेबल डेटा को गलतफ़हमी में फ़र्ज़ी निर्देश मान लेता है।
  2. रिकर्सिव ट्रैवर्सल (Recursive Traversal, IDA Pro / Ghidra): ज्ञात प्रवेश बिंदुओं (main, एक्सपोर्टेड सिंबल) से शुरू होकर ब्रांच निर्देशों (je, jmp, call) के वास्तविक नियंत्रण प्रवाह ग्राफ का अनुसरण करता है। अप्रत्यक्ष जंप (jmp [rbx + rcx*8]) को हल करने के लिए डीकंपाइलर बैकवर्ड स्लाइसिंग और वैल्यू-सेट एनालिसिस (VSA) का उपयोग करके सीमा जाँच cmp rcx, 12; ja default को खोजता है और जंप टेबल का आकार निकालता है।

4. चरण 2: IR लिफ्टिंग और स्टैटिक सिंगल असाइनमेंट (SSA) डेटाफ़्लो

मशीन निर्देशों में जटिल अंतर्निहित साइड इफ़ेक्ट्स होते हैं (x86 का एक साधारण sub eax, ebx एक साथ छह स्टेटस फ़्लैग्स ZF, CF, SF, OF, PF, AF को बदल देता है)। डीकंपाइलर पहले नेतिव कोड को आर्किटेक्चर-स्वतंत्र इंटरमीडिएट रिप्रेजेंटेशन (Ghidra का P-code, Hex-Rays का Microcode) में लिफ्ट (Lift) करता है और फिर उसे SSA (Static Single Assignment) रूप में बदलता है, जहाँ प्रत्येक वेरिएबल को केवल एक बार मान दिया जाता है (जॉइन पॉइंट्स पर $\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) बनाई जाती है।
  • लाइव-रेंज स्प्लिटिंग: भौतिक रजिस्टर rax को साझा करने वाले असंबंधित SSA जाल (Webs) स्वतः ही स्वतंत्र C लोकल वेरिएबल्स (int v1; बनाम Node *v2;) में बँट जाते हैं!

5. चरण 3: स्टैक फ़्रेम विश्लेषण और लैटिस-आधारित टाइप इनफरेंस (Type Inference)

यद्यपि मशीन कोड में कोई स्पष्ट टाइप नहीं होता, फिर भी CPU निर्देशों का अर्थविज्ञान सख्त बीजीय टाइप बाधाएँ (Type Constraints) उत्पन्न करता है:

  • चिह्नित (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) और CMU के TIE सिस्टम के अनुसार, डीकंपाइलर सभी टाइप्स को $\top$ (कच्चा बिटवेक्टर) और $\bot$ (टाइप टकराव / union) के बीच एक आंशिक रूप से क्रमित लैटिस $(\mathcal{L}, \sqsubseteq)$ में रखता है और मीट ($\sqcap$) द्वारा सभी वेरिएबल्स के सटीक C टाइप निकालता है।
  flowchart TD
    TOP["⊤ (Top: कच्चा N-बिट बिटवेक्टर bit64)"]
    NUM["num64 (स्केलर पूर्णांक या पॉइंटर)"]
    FLT["float64 (IEEE 754 double: addsd, mulsd)"]
    SINT["int64_t (Signed: sar, idiv, jg, jl, movsxd)"]
    UINT["uint64_t (Unsigned: shr, div, ja, jb)"]
    PTR["ptr(τ) (Pointer: [reg] डीरेफ़रेंस)"]
    STRUCT["struct S* (Struct पॉइंटर: [reg + offset] एक्सेस)"]
    BOT["⊥ (Bottom: टाइप टकराव → Cast या Union)"]

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

6. चरण 4: कंट्रोल-फ़्लो स्ट्रक्चरिंग — goto हटाकर if और while वापस पाना

सपाट कंट्रोल-फ़्लो ग्राफ (CFG) को नेस्टेड एब्स्ट्रैक्ट सिंटैक्स ट्री (AST) में समेटने के लिए (Cifuentes 1994), डीकंपाइलर डोमिनेटर ट्री और पोस्ट-डोमिनेटर ट्री की गणना करता है:

  1. प्राकृतिक लूप (Natural Loops): कोई बैक-एज $n \to h$ जहाँ लक्ष्य $h$ स्रोत $n$ को डोमिनेट करता है ($h \text{ dom } n$), हेडर $h$ वाले एक लूप (while, for या do-while) को परिभाषित करता है।
  2. शाखा मिलन बिंदु (if-then-else): किसी ब्रांच नोड का तत्काल पोस्ट-डोमिनेटर if-else डायमंड का सटीक मिलन बिंदु होता है।
  3. goto-मुक्त स्ट्रक्चरिंग (DREAM एल्गोरिदम): जटिल और इर्रिड्यूसिबल CFG के लिए भी DREAM (“No More Gotos”, Yakdan et al. 2015) एल्गोरिदम बूलियन रीचिंग कंडीशंस (Reaching Conditions) और लॉजिक मिनिमाइज़ेशन द्वारा 100% goto-मुक्त साफ़ C कोड उत्पन्न करता है!

7. डीकंपाइलेशन की सीमाएँ: ऑप्टिमाइज़ेशन, ओब्फ़स्केशन और न्यूरल LLM डीकंपाइलर्स

प्रोग्राम तत्वबिना ऑप्टिमाइज़ेशन (-O0 -g DWARF सहित)स्ट्रिप्ड रिलीज़ (-O3 + strip)ओब्फ़स्केटेड (OLLVM / VMProtect)
फ़ंक्शन, वेरिएबल व टाइप नामDWARF/PDB से $100\%$ प्राप्तस्थायी रूप से नष्ट (sub_401120, v2)नष्ट + भ्रामक फ़र्ज़ी सिंबल
स्थिरांक विभाजन (x / 10)सीधा idiv निर्देशमैजिक-नंबर गुणन (0x66666667)मिक्स्ड बूलियन-अंकगणित (MBA) बहुपद
कंट्रोल फ़्लो (if / while)साफ़ संरचित ASTलूप अनरोलिंग व SIMD वेक्टराइज़ेशनकंट्रोल-फ़्लो फ़्लैटनिंग (CFF) विशाल switch
निर्देश अर्थविज्ञान (ISA)मूल x86-64 / ARM64मूल x86-64 / ARM64वर्चुअलाइज़ेशन: कस्टम रैंडम बाइटकोड VM

जहाँ OLLVM और VMProtect जैसे ओब्फ़स्केटर कंट्रोल-फ़्लो फ़्लैटनिंग (CFF), ओपेक प्रेडिकेट्स, मिक्स्ड बूलियन-अंकगणित ($x+y \equiv (x\oplus y) + 2(x\land y)$) और वर्चुअलाइज़ेशन ओब्फ़स्केशन (कोड को कस्टम बाइटकोड VM में बदलना) से स्थैतिक विश्लेषण को रोकते हैं, वहीं 2020 के दशक में न्यूरल LLM डीकंपाइलर्स (LLM4Decompile) औपचारिक प्रोग्राम विश्लेषण को AI के साथ जोड़कर सार्थक वेरिएबल नामों का अनुमान लगा रहे हैं और जटिल मशीन कोड को स्वाभाविक मानव-सदृश C कोड में पुन: अनूदित कर रहे हैं।

comments powered by Disqus