Почему машинный код не может быть легко декомпилирован?

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цикл - это почти то же самое, что иwhileцикл, если вы думаете об этом. Кроме того, даже если бы я запускал исходный код через irrFuscator (который, в отличие от secureSWF, не делает намного больше, чем просто рандомизирует имена переменных и функций-членов), все равно выглядело, как если бы вы могли просто начать изолировать определенные переменные и функции в меньших классах, узнайте, как они используются, присвойте им свои собственные имена и работайте оттуда.

Для того, чтобы это имело большое значение, машинный код должен был бы потерять намного больше информации, чем это, и некоторые из ответов действительно идут в это.

Panzercrisis
источник
35
Трудно сделать корову из гамбургеров.
Каз Драгон
4
Основная проблема заключается в том, что собственный двоичный файл содержит очень мало метаданных о программе. Он не содержит никакой информации о классах (что делает С ++ особенно сложным для декомпиляции) и не всегда даже ничего о функциях - в этом нет необходимости, поскольку ЦП по своей природе выполняет код довольно линейно, по одной инструкции за раз. Кроме того, невозможно различить код и данные ( ссылка ). Для получения дополнительной информации, вы можете рассмотреть вопрос о поиске или повторно просить у RE.SE .
ntoskrnl

Ответы:

39

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

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

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

Следующим шагом является преобразование дерева в некую форму промежуточного языка, которая облегчает оптимизацию. Здесь есть довольно много вариантов, и каждая инфраструктура компилятора имеет свою собственную. Однако, как правило, такая информация, как имена локальных переменных, большие структуры потока управления (например, используется ли цикл for или while), теряется. Здесь обычно происходят некоторые важные оптимизации: постоянное распространение, движение инвариантного кода, вставка функций и т. Д. Каждая из них преобразует представление в представление, которое имеет эквивалентную функциональность, но выглядит существенно иначе.

Шагом после этого является генерация фактических машинных инструкций, которые могут включать в себя так называемую оптимизацию «глазок», которая создает оптимизированную версию общих шаблонов команд.

На каждом этапе вы теряете все больше и больше информации, пока, в конце концов, вы не потеряете столько, что станет невозможным восстановить что-либо похожее на исходный код.

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

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

chuckj
источник
5
Тот факт, что информация хранится, чтобы JIT мог работать лучше, является ключевым.
Btilly
Являются ли библиотеки C ++ легко декомпилируемыми?
Panzercrisis
1
Не во что я бы посчитал полезным.
chuckj
1
Метаданные не «позволяют компилировать один и тот же байт-код для нескольких целей», они предназначены для размышлений. Переносимое промежуточное представление не должно иметь никаких метаданных.
SK-logic
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-байт-код, гораздо проще декомпилировать, даже если отбросить метаданные.) Таким образом, на практике должна быть возможность декомпилировать определенные, хорошо себя ведущиепрограммы. Я думаю об отдельных, функциональных стилях, у которых нет побочных эффектов и четко определенных входных данных. Я думаю, что есть пара декомпиляторов, которые могут дать псевдокод для простых функций, но у меня нет большого опыта работы с такими инструментами.

JDM
источник
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и просто сказать, что вы предполагаете соглашение о вызовах Си.
Chuckj
3

Основными принципами здесь являются сопоставления "многие к одному" и отсутствие канонических представителей.

В качестве простого примера явления «многие к одному» вы можете подумать о том, что происходит, когда вы берете функцию с некоторыми локальными переменными и компилируете ее в машинный код. Вся информация о переменных теряется, потому что они просто становятся адресами памяти. Нечто подобное происходит с петлями. Вы можете взять цикл forили, whileи если они структурированы правильно, вы можете получить идентичный машинный код с jumpинструкциями.

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

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

davidk01
источник