Dengan bahasa mesin virtual berbasis bytecode seperti Java, VB.NET, C #, ActionScript 3.0, dll. Anda kadang-kadang mendengar betapa mudahnya untuk hanya mengunduh beberapa decompiler dari Internet, menjalankan bytecode melewatinya sekali, dan seringkali, muncul dengan sesuatu yang tidak terlalu jauh dari kode sumber asli dalam hitungan detik. Seharusnya bahasa semacam ini sangat rentan terhadap hal itu.
Saya baru-baru ini mulai bertanya-tanya mengapa Anda tidak mendengar lebih banyak tentang ini tentang kode biner asli, ketika Anda setidaknya tahu bahasa mana itu ditulis pada awalnya (dan dengan demikian, bahasa mana untuk mencoba mendekompilasi ke dalam). Untuk waktu yang lama, saya pikir itu hanya karena bahasa mesin asli jauh lebih gila dan lebih kompleks daripada bytecode khas.
Tapi seperti apa bytecode? Ini terlihat seperti ini:
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
Dan seperti apa kode mesin asli (dalam hex)? Itu, tentu saja, terlihat seperti ini:
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
Dan instruksinya datang dari kerangka pikiran yang agak mirip:
1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX
Jadi, mengingat bahasa untuk mencoba mendekompilasi beberapa biner asli ke, katakanlah C ++, apa yang sulit tentang itu? Hanya dua ide yang langsung terlintas dalam pikiran adalah 1) itu benar-benar jauh lebih rumit daripada bytecode, atau 2) sesuatu tentang fakta bahwa sistem operasi cenderung membuat paginasi program dan menyebarkan potongan-potongan mereka menyebabkan terlalu banyak masalah. Jika salah satu dari kemungkinan itu benar, mohon jelaskan. Tapi bagaimanapun juga, mengapa Anda tidak pernah mendengar hal ini pada dasarnya?
CATATAN
Saya akan menerima salah satu jawabannya, tetapi saya ingin menyebutkan sesuatu terlebih dahulu. Hampir semua orang merujuk kembali pada fakta bahwa bagian-bagian berbeda dari kode sumber asli dapat dipetakan ke kode mesin yang sama; nama variabel lokal hilang, Anda tidak tahu jenis loop apa yang awalnya digunakan, dll.
Namun contoh-contoh seperti dua yang baru saja disebutkan itu agak sepele di mataku. Beberapa jawaban meskipun cenderung menyatakan bahwa perbedaan antara kode mesin dan sumber asli jauh lebih banyak daripada sesuatu yang sepele ini.
Tetapi misalnya, ketika sampai pada hal-hal seperti nama variabel lokal dan tipe loop, bytecode juga kehilangan informasi ini (setidaknya untuk ActionScript 3.0). Saya telah menarik hal itu kembali melalui dekompiler sebelumnya, dan saya tidak terlalu peduli apakah suatu variabel dipanggil strMyLocalString:String
atau loc1
. Saya masih bisa melihat dalam lingkup lokal kecil itu dan melihat bagaimana itu digunakan tanpa banyak masalah. Dan satu for
loop adalah hal yang persis sama dengan awhile
lingkaran, jika Anda memikirkannya. Juga bahkan ketika saya akan menjalankan sumber melalui irrFuscator (yang, tidak seperti secureSWF, tidak melakukan lebih dari sekadar mengacak variabel anggota dan nama fungsi), itu masih tampak seperti Anda bisa mulai mengisolasi variabel dan fungsi tertentu dalam kelas yang lebih kecil, bagaimana mereka digunakan, tetapkan nama Anda sendiri untuk mereka, dan bekerja dari sana.
Agar ini menjadi masalah besar, kode mesin harus kehilangan lebih banyak informasi dari itu, dan beberapa jawaban masuk ke ini.
sumber
Jawaban:
Pada setiap langkah kompilasi Anda kehilangan informasi yang tidak dapat dipulihkan. Semakin banyak informasi yang hilang dari sumber aslinya, semakin sulit untuk diurai.
Anda dapat membuat de-compiler yang berguna untuk byte-code karena lebih banyak informasi yang disimpan dari sumber asli daripada yang disimpan saat memproduksi kode mesin target akhir.
Langkah pertama dari kompiler adalah mengubah sumber menjadi beberapa untuk representasi menengah yang sering direpresentasikan sebagai pohon. Secara tradisional pohon ini tidak mengandung informasi non-semantik seperti komentar, ruang putih, dll. Setelah ini dibuang, Anda tidak dapat memulihkan sumber asli dari pohon itu.
Langkah selanjutnya adalah merender pohon menjadi beberapa bentuk bahasa perantara yang membuat pengoptimalan menjadi lebih mudah. Ada beberapa pilihan di sini dan masing-masing infrastruktur kompiler memiliki sendiri. Namun, biasanya, informasi seperti nama variabel lokal, struktur aliran kontrol besar (seperti apakah Anda menggunakan loop for atau while) hilang. Beberapa optimasi penting biasanya terjadi di sini, propagasi konstan, gerakan kode invarian, inlining fungsi, dll. Masing-masing mengubah representasi menjadi representasi yang memiliki fungsi setara tetapi terlihat sangat berbeda.
Langkah setelah itu adalah untuk menghasilkan instruksi mesin yang sebenarnya yang mungkin melibatkan apa yang disebut optimasi "peep-hole" yang menghasilkan versi optimal dari pola instruksi umum.
Pada setiap langkah Anda kehilangan semakin banyak informasi sampai, pada akhirnya, Anda kehilangan begitu banyak sehingga menjadi tidak mungkin untuk memulihkan apa pun yang menyerupai kode asli.
Byte-code, di sisi lain, biasanya menyimpan optimasi yang menarik dan transformatif sampai fase JIT (kompilator just-in-time) ketika kode mesin target diproduksi. Byte-code mengandung banyak meta-data seperti tipe variabel lokal, struktur kelas, untuk memungkinkan kode byte yang sama dikompilasi ke beberapa kode mesin target. Semua informasi ini tidak diperlukan dalam program C ++ dan dibuang dalam proses kompilasi.
Ada dekompiler untuk berbagai kode mesin target tetapi sering kali tidak menghasilkan hasil yang berguna (sesuatu yang dapat Anda modifikasi dan kemudian kompilasi ulang) karena terlalu banyak sumber asli yang hilang. Jika Anda memiliki informasi debug untuk yang dapat dieksekusi, Anda dapat melakukan pekerjaan yang lebih baik; tetapi, jika Anda memiliki informasi debug, Anda mungkin memiliki sumber aslinya juga.
sumber
Kehilangan informasi seperti yang ditunjukkan oleh jawaban yang lain adalah satu hal, tetapi itu bukan pelanggar. Lagi pula, Anda tidak mengharapkan program aslinya kembali, Anda hanya ingin representasi apa pun dalam bahasa tingkat tinggi. Jika kode sebaris, Anda bisa membiarkannya, atau secara otomatis faktor perhitungan umum. Pada prinsipnya Anda dapat membatalkan banyak optimasi. Tetapi ada beberapa operasi yang pada prinsipnya tidak dapat dibalikkan (setidaknya tanpa jumlah komputasi yang tidak terbatas).
Misalnya, cabang mungkin menjadi lompatan yang dihitung. Kode seperti ini:
mungkin dikompilasi (maaf ini bukan assembler asli):
Sekarang, jika Anda tahu bahwa x bisa 1 atau 2, Anda dapat melihat lompatan dan membalikkan ini dengan mudah. Tapi bagaimana dengan alamat 0x1012? Haruskah Anda membuat
case 3
untuk itu juga? Anda harus melacak seluruh program dalam kasus terburuk untuk mengetahui nilai apa yang diizinkan. Lebih buruk lagi, Anda mungkin harus mempertimbangkan semua input pengguna yang mungkin! Inti masalahnya adalah Anda tidak dapat membedakan data dan instruksi.Yang sedang berkata, saya tidak akan sepenuhnya pesimis. Seperti yang mungkin Anda perhatikan di 'assembler' di atas, jika x berasal dari luar dan tidak dijamin 1 atau 2, Anda pada dasarnya memiliki bug buruk yang memungkinkan Anda untuk melompat ke mana saja. Tetapi jika program Anda bebas dari bug semacam ini, itu lebih mudah untuk dipikirkan. (Bukan kebetulan bahwa bahasa perantara "aman" seperti CLR IL atau Java bytecode jauh lebih mudah untuk diurai, bahkan mengesampingkan metadata.) Jadi, dalam praktiknya, mungkin untuk mendekompilasi tertentu, berperilaku baikprogram. Saya sedang memikirkan individu, rutinitas gaya fungsional, yang tidak memiliki efek samping dan input yang jelas. Saya pikir ada beberapa dekompiler di sekitar yang dapat memberikan pseudocode untuk fungsi sederhana, tetapi saya tidak punya banyak pengalaman dengan alat-alat seperti itu.
sumber
Alasan mengapa kode mesin tidak dapat dengan mudah dikonversi kembali ke kode sumber asli adalah bahwa banyak informasi yang hilang selama kompilasi. Metode dan kelas yang tidak diekspor dapat diuraikan, nama variabel lokal hilang, nama file dan struktur hilang seluruhnya, kompiler dapat membuat optimasi yang tidak jelas. Alasan lain adalah bahwa beberapa file sumber yang berbeda dapat menghasilkan rakitan yang sama persis.
Sebagai contoh:
Dapat dikompilasi ke:
Perakitan saya sangat berkarat, tetapi jika kompiler dapat memverifikasi bahwa optimasi dapat dilakukan secara akurat, ia akan melakukannya. Hal ini disebabkan biner yang dikompilasi tidak perlu mengetahui nama-nama
DoSomething
danAdd
, serta fakta bahwaAdd
metode ini memiliki dua parameter bernama, kompiler juga tahu bahwaDoSomething
metode dasarnya mengembalikan konstanta, dan itu bisa sejalan baik pemanggilan metode dan metode itu sendiri.Tujuan kompiler adalah untuk membuat rakitan, bukan cara untuk mengikat file sumber.
sumber
ret
dan katakan saja Anda mengasumsikan konvensi pemanggilan C.Prinsip-prinsip umum di sini adalah pemetaan banyak-ke-satu dan kurangnya perwakilan kanonik.
Untuk contoh sederhana dari banyak-ke-satu fenomena Anda dapat berpikir tentang apa yang terjadi ketika Anda mengambil fungsi dengan beberapa variabel lokal dan mengkompilasinya ke kode mesin. Semua informasi tentang variabel hilang karena mereka hanya menjadi alamat memori. Hal serupa terjadi pada loop. Anda dapat mengambil a
for
atauwhile
loop dan jika mereka terstruktur tepat maka Anda mungkin mendapatkan kode mesin identik denganjump
instruksi.Ini juga memunculkan kurangnya perwakilan kanonik dari kode sumber asli untuk instruksi kode mesin. Ketika Anda mencoba mendekompilasi loop, bagaimana Anda memetakan
jump
instruksi kembali ke looping constructs? Apakah Anda membuatnyafor
loop atauwhile
loop.Masalah ini semakin jengkel dengan kenyataan bahwa kompiler modern melakukan berbagai bentuk pelipatan dan inlining. Jadi pada saat Anda mendapatkan kode mesin, hampir tidak mungkin untuk mengetahui dari mana konstruksi tingkat tinggi dari kode mesin tingkat rendah itu.
sumber