Чому рідний машинний код не можна легко декомпілювати?


16

З мовами віртуальної машини на основі байт-кодів, таких як Java, VB.NET, C #, ActionScript 3.0 тощо, ви іноді чуєте про те, як легко просто завантажити декомпілятор з Інтернету, запустити байт-код через нього один раз і Часто придумай щось не надто далеко від початкового вихідного коду за лічені секунди. Імовірно, така мова є особливо вразливою до цього.

Нещодавно я почав цікавитись, чому ви більше не чуєте про це щодо рідного двійкового коду, коли ви принаймні знаєте, на якій мові він був написаний оригінально (і, таким чином, на яку мову спробувати декомпілювати). Довгий час я вважав, що це просто тому, що рідна машина машини настільки шаленіша і складніша, ніж типовий байт-код.

Але як виглядає байт-код? Це виглядає приблизно так:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

А як виглядає нативний машинний код (у шістнадцятковій формі)? Це, звичайно, виглядає приблизно так:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

І інструкції виходять із дещо схожого настрою:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

Отже, з огляду на мову, щоб спробувати декомпілювати якийсь рідний бінарний код, скажімо, на C ++, що так важко в цьому? Єдині дві ідеї, які одразу приходять до тями, - це 1) це насправді набагато складніше, ніж байт-код, або 2) щось про те, що операційні системи, як правило, пакутують програми та розкидають їх частини, викликає занадто багато проблем. Якщо одна з цих можливостей правильна, поясніть, будь ласка. Але так чи інакше, чому ви ніколи не чуєте про це в основному?

ПРИМІТКА

Я збираюся прийняти одну з відповідей, але хочу спочатку щось згадати. Практично всі посилаються на те, що різні фрагменти вихідного вихідного коду можуть відображати один і той же машинний код; локальні назви змінних втрачаються, ви не знаєте, який тип циклу спочатку використовувався тощо.

Однак приклади на кшталт двох, що були згадані, в моїх очах начебто тривіальні. Деякі відповіді хоч і стверджують, що різниця між машинним кодом та оригінальним джерелом суттєво значно більша, ніж щось таке тривіальне.

Але, наприклад, коли мова йде про такі речі, як імена локальних змінних і типи циклів, байткод також втрачає цю інформацію (принаймні, для ActionScript 3.0). Я вже витягував цей матеріал назад через декомпілятор, і мені було зовсім неважливо, чи називалася змінна strMyLocalString:Stringчи loc1. Я все ще міг заглянути в той невеликий локальний обсяг і побачити, як він використовується без особливих проблем. І forцикл - це майже те саме, що і awhileпетля, якщо ви подумаєте про це. Крім того, навіть коли я би запускав джерело через irrFuscator (який, на відміну від secureSWF, не робить багато більше, ніж просто рандомізувати змінну члена та імена функцій), все одно виглядало так, що ви можете просто почати ізолювати певні змінні та функції в менших класах, малюнок дізнайтеся, як вони використовуються, призначте їм власні імена та працюйте звідти.

Для того, щоб це було великою справою, машинному коду потрібно було б втратити набагато більше інформації, ніж це, і деякі відповіді все ж впадають у це.


35
Важко зробити корову з гамбургерів.
Kaz Dragon

4
Основне питання полягає в тому, що нативний бінарний файл зберігає дуже мало метаданих про програму. Він не зберігає інформації про класи (що робить C ++ особливо складним для декомпіляції), і не завжди навіть нічого про функції - це не обов'язково, оскільки процесор по суті виконує код досить лінійно, по одній інструкції за раз. Крім того, неможливо розмежувати код і дані ( посилання ). Для отримання додаткової інформації, ви можете розглянути питання про пошуку або повторно просити у RE.SE .
ntoskrnl

Відповіді:


39

На кожному кроці складання ви втрачаєте непотрібну інформацію. Чим більше інформації ви втратите від першоджерела, тим важче декомпілювати.

Ви можете створити корисний декомпілятор для байт-коду, оскільки набагато більше інформації зберігається від вихідного джерела, ніж зберігається при створенні кінцевого машинного коду цілі.

Перший крок компілятора - перетворити джерело на деяке для проміжного представлення, часто представленого у вигляді дерева. Традиційно це дерево не містить несемантичну інформацію, таку як коментарі, пробіли тощо. Після того, як це буде викинуто, ви не зможете відновити початкове джерело з цього дерева.

Наступним кроком є ​​перетворення дерева на деяку форму проміжного мови, що полегшує оптимізацію. Тут є досить багато варіантів, і кожна інфраструктура компілятора має свій. Однак, як правило, інформація, як імена локальних змінних, великі структури потоків управління (наприклад, використовували ви цикл для або під час), втрачається. Тут зазвичай відбуваються деякі важливі оптимізації, постійне розповсюдження, інваріантний рух коду, вбудована функція тощо. Кожна з яких перетворює представлення на представлення, яке має еквівалентну функціональність, але виглядає істотно інакше.

Крок після цього - генерувати фактичні інструкції на машині, які можуть включати оптимізацію, що називається "peep-hole", що дає оптимізовану версію загальних шаблонів інструкцій.

З кожним кроком ви втрачаєте все більше і більше інформації, поки в кінці не втратите стільки, що неможливо відновити щось, що нагадує оригінальний код.

З іншого боку, байт-код, як правило, зберігає цікаві та перетворювальні оптимізації до фази JIT (компілятор, що знаходиться в часі), коли виробляється цільовий код машини. Байт-код містить безліч метаданих, таких як локальні типи змінних, структура класу, щоб дозволити збір одного і того ж байт-коду до декількох машинних кодів цілі. Вся ця інформація не потрібна програмі C ++ і відкидається в процесі компіляції.

Існують декомпілятори для різних цільових машинних кодів, але вони часто не дають корисних результатів (те, що ви можете змінити і потім перекомпілювати), оскільки занадто багато вихідного джерела втрачено. Якщо у вас є інформація про налагодження для виконуваного файлу, ви можете зробити ще кращу роботу; але, якщо у вас є інформація про налагодження, у вас, ймовірно, є і оригінальне джерело.


5
Ключовим є той факт, що інформація зберігається для того, щоб JIT працював краще.
btilly

Чи легко DLP-файли C ++ легко декомпілюються?
Panzercrisis

1
Я не вважаю корисним.
chuckj

1
Метадані не "дозволяють компілювати один і той самий байт-код у кілька цілей", він є для роздумів. Проміжне представництво, що підлягає відповідальності, не повинно мати жодного з цих метаданих.
SK-логіка

2
Це не правда. Значна частина даних є для роздумів, але рефлексія - не єдине використання. Наприклад, інтерфейс та визначення класів використовуються для створення заданого зміщення поля, побудови віртуальних таблиць тощо на цільовій машині, що дозволяє їх будувати найбільш ефективно для цільової машини. Ці таблиці будуються компілятором та / або лінкером при створенні власного коду. Після цього дані, які використовуються для їх побудови, відкидаються.
chuckj

11

Втрата інформації, на яку вказують інші відповіді, - це один момент, але це не є порушником. Зрештою, ви не очікуєте повернення оригінальної програми, ви просто хочете будь-яке представлення мовою високого рівня. Якщо код вкладений, ви можете просто дозволити його або автоматично розподілити звичайні обчислення. В принципі ви можете скасувати багато оптимізацій. Але є деякі операції, які в принципі незворотні (без нескінченної кількості обчислень принаймні).

Наприклад, гілки можуть стати обчисленими стрибками. Код таким:

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

Ви можете зібрати (вибачте, що це не справжній асемблер):

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

Тепер, якщо ви знаєте, що х може бути 1 або 2, ви можете подивитися на стрибки і перевернути це легко. А як щодо адреси 0x1012? Чи варто також створити case 3для цього? Вам доведеться простежити всю програму в гіршому випадку, щоб зрозуміти, які значення дозволяються. Ще гірше, можливо, вам доведеться врахувати всі можливі введення користувачів! В основі проблеми полягає в тому, що ви не можете розказувати дані та інструкції.

Якщо говорити, я не був би абсолютно песимістичним. Як ви могли помітити у вищезгаданому "асемблері", якщо x надходить ззовні і не гарантується, що він дорівнює 1 або 2, у вас по суті є погана помилка, яка дозволяє переходити куди завгодно. Але якщо у вашій програмі немає такої помилки, міркувати про це набагато простіше. (Не випадково «безпечні» проміжні мови, такі як CLR IL або байт-код Java, набагато простіше декомпілювати, навіть відкладаючи метадані в сторону.) Тому на практиці слід декомпілювати певні, добре поводитьсяпрограм. Я думаю про індивідуальні процедури функціонального стилю, які не мають побічних ефектів і чітко визначених даних. Я думаю, що є кілька декомпіляторів навколо, які можуть дати псевдокод для простих функцій, але я не маю особливого досвіду роботи з такими інструментами.


9

Причиною, по якій машинний код не може бути легко конвертований назад до вихідного вихідного коду, є те, що при компіляції втрачається багато інформації. Методи та неекспортовані класи можуть бути накреслені, імена локальних змінних втрачені, імена файлів та структури повністю втрачені, компілятори можуть робити неочевидну оптимізацію. Ще одна причина полягає в тому, що декілька різних вихідних файлів можуть створити точно таку ж збірку.

Наприклад:

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

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

int main()
{
    return DoSomething();
}

Може бути складено для:

main:
mov eax, 7;
ret;

Моя збірка досить іржава, але якщо компілятор може перевірити, що оптимізацію можна зробити точно, вона зробить це. Це пов'язано з скомпільований двійковий не потрібно знати імена DoSomethingі Add, а також той факт , що Addметод має два іменованих параметрів компілятор знає , що DoSomethingметод по суті повертає константу, і він може вбудовувати як виклик методу і сам метод.

Метою компілятора є створення збірки, а не спосіб згрупування вихідних файлів.


Подумайте про зміну останньої інструкції на справедливу retі просто скажіть, що ви припускаєте конвенцію C виклику
chuckj

3

Загальні принципи тут - багатозначні відображення та відсутність канонічних представників.

Для простого прикладу явища «багато в одному» ви можете подумати про те, що відбувається, коли ви берете функцію з деякими локальними змінними та компілюєте її до машинного коду. Вся інформація про змінні втрачається, оскільки вони просто стають адресами пам'яті. Щось подібне відбувається з петлями. Ви можете взяти forабо whileцикл, і якщо вони структуровані правильно, ви можете отримати однаковий машинний код з jumpінструкціями.

Це також спричиняє відсутність канонічних представників оригінального вихідного коду для інструкцій машинного коду. Коли ви намагаєтесь декомпілювати петлі, як ви віднесите jumpінструкції назад до циклівних конструкцій? Ви робите з них forпетлі чи whileпетлі.

Питання ще більше переживає той факт, що сучасні компілятори виконують різні форми складання та накладки. Тож до моменту, коли ви перейдете до машинного коду, майже неможливо сказати, з яких конструкцій високого рівня вийшов машинний код низького рівня.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.