Mengapa kode mesin asli tidak dapat dengan mudah didekompilasi?

16

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:Stringatau loc1. Saya masih bisa melihat dalam lingkup lokal kecil itu dan melihat bagaimana itu digunakan tanpa banyak masalah. Dan satu forloop adalah hal yang persis sama dengan awhilelingkaran, 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.

Panzercrisis
sumber
35
Sulit membuat sapi dari hamburger.
Kaz Dragon
4
Masalah utama adalah bahwa biner asli mempertahankan sedikit metadata tentang program ini. Itu tidak menyimpan informasi tentang kelas (membuat C ++ sangat sulit untuk didekompilasi) dan tidak selalu bahkan tentang fungsi - itu tidak diperlukan karena CPU secara inheren mengeksekusi kode dalam mode yang cukup linier, satu instruksi pada satu waktu. Selain itu, tidak mungkin untuk membedakan antara kode dan data ( tautan ). Untuk informasi lebih lanjut, Anda mungkin ingin mempertimbangkan mencari atau re-minta di RE.SE .
ntoskrnl

Jawaban:

39

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.

chuckj
sumber
5
Fakta bahwa informasi disimpan agar JIT dapat bekerja lebih baik adalah kuncinya.
btilly
Apakah C ++ DLL mudah diurai?
Panzercrisis
1
Tidak ke hal yang saya anggap berguna.
chuckj
1
Metadata tidak "untuk memungkinkan kode byte yang sama dikompilasi ke beberapa target", itu ada untuk refleksi. Representasi menengah retargetable tidak perlu memiliki metadata itu.
SK-logic
2
Itu tidak benar. Banyak data ada untuk refleksi tetapi refleksi bukan satu-satunya penggunaan. Misalnya, antarmuka dan definisi kelas digunakan untuk membuat bidang offset, membangun tabel virtual, dll. Pada mesin target yang memungkinkan mereka dibangun dengan cara yang paling efisien untuk mesin target. Tabel-tabel ini dibangun oleh kompiler dan / atau penghubung saat memproduksi kode asli. Setelah ini selesai, data yang digunakan untuk membangunnya dibuang.
chuckj
11

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:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

mungkin dikompilasi (maaf ini bukan assembler asli):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

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 3untuk 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.

jdm
sumber
9

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:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Dapat dikompilasi ke:

main:
mov eax, 7;
ret;

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 DoSomethingdan Add, serta fakta bahwa Addmetode ini memiliki dua parameter bernama, kompiler juga tahu bahwa DoSomethingmetode 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.

Matius
sumber
Pertimbangkan mengubah instruksi terakhir menjadi adil retdan katakan saja Anda mengasumsikan konvensi pemanggilan C.
chuckj
3

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 foratau whileloop dan jika mereka terstruktur tepat maka Anda mungkin mendapatkan kode mesin identik dengan jumpinstruksi.

Ini juga memunculkan kurangnya perwakilan kanonik dari kode sumber asli untuk instruksi kode mesin. Ketika Anda mencoba mendekompilasi loop, bagaimana Anda memetakan jumpinstruksi kembali ke looping constructs? Apakah Anda membuatnya forloop atau whileloop.

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.

davidk01
sumber