Благодаря языкам виртуальных машин на основе байт-кода, таким как 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, не делает намного больше, чем просто рандомизирует имена переменных и функций-членов), все равно выглядело, как если бы вы могли просто начать изолировать определенные переменные и функции в меньших классах, узнайте, как они используются, присвойте им свои собственные имена и работайте оттуда.
Для того, чтобы это имело большое значение, машинный код должен был бы потерять намного больше информации, чем это, и некоторые из ответов действительно идут в это.
источник
Ответы:
На каждом этапе компиляции вы теряете информацию, которую невозможно восстановить. Чем больше информации вы потеряете из первоисточника, тем сложнее будет ее декомпилировать.
Вы можете создать полезный декомпилятор для байт-кода, поскольку из исходного источника сохраняется гораздо больше информации, чем при создании конечного целевого машинного кода.
Первым шагом компилятора является превращение источника в некоторый для промежуточного представления, часто представляемого в виде дерева. Традиционно это дерево не содержит несемантической информации, такой как комментарии, пробелы и т. Д. После того, как это выброшено, вы не можете восстановить исходный источник из этого дерева.
Следующим шагом является преобразование дерева в некую форму промежуточного языка, которая облегчает оптимизацию. Здесь есть довольно много вариантов, и каждая инфраструктура компилятора имеет свою собственную. Однако, как правило, такая информация, как имена локальных переменных, большие структуры потока управления (например, используется ли цикл for или while), теряется. Здесь обычно происходят некоторые важные оптимизации: постоянное распространение, движение инвариантного кода, вставка функций и т. Д. Каждая из них преобразует представление в представление, которое имеет эквивалентную функциональность, но выглядит существенно иначе.
Шагом после этого является генерация фактических машинных инструкций, которые могут включать в себя так называемую оптимизацию «глазок», которая создает оптимизированную версию общих шаблонов команд.
На каждом этапе вы теряете все больше и больше информации, пока, в конце концов, вы не потеряете столько, что станет невозможным восстановить что-либо похожее на исходный код.
Байт-код, с другой стороны, обычно сохраняет интересные и преобразующие оптимизации до фазы JIT (компилятор точно в срок), когда создается целевой машинный код. Байт-код содержит много метаданных, таких как типы локальных переменных, структура классов, чтобы позволить одному и тому же байт-коду быть скомпилированным в несколько целевых машинных кодов. Вся эта информация не требуется в программе на C ++ и отбрасывается в процессе компиляции.
Существуют декомпиляторы для различных целевых машинных кодов, но они часто не дают полезных результатов (что-то, что вы можете изменить, а затем перекомпилировать), так как слишком большая часть исходного кода потеряна. Если у вас есть отладочная информация для исполняемого файла, вы можете сделать еще лучшую работу; но, если у вас есть отладочная информация, возможно, у вас есть и исходный код.
источник
Потеря информации, как указано в других ответах, является одним из пунктов, но она не является нарушителем. В конце концов, вы не ожидаете оригинальную программу обратно, вы просто хотите любой представление на языке высокого уровня. Если код встроен, вы можете просто оставить его или автоматически исключить общие вычисления. Вы можете в принципе отменить много оптимизаций. Но есть некоторые операции, которые в принципе необратимы (по крайней мере без бесконечного количества вычислений).
Например, ветви могут стать вычисленными переходами. Код как это:
может быть скомпилирован (извините, что это не настоящий ассемблер):
Теперь, если вы знаете, что х может быть 1 или 2, вы можете посмотреть на прыжки и легко изменить это. Но как насчет адреса 0x1012? Если вы создадите
case 3
для этого тоже? Вам нужно будет проследить всю программу в худшем случае, чтобы выяснить, какие значения допустимы. Хуже того, вам, возможно, придется учесть все возможные пользовательские входы! Суть проблемы в том, что вы не можете разделить данные и инструкции.При этом я не был бы полностью пессимистичен. Как вы могли заметить в вышеприведенном «ассемблере», если x приходит извне и не гарантирует, что он равен 1 или 2, у вас, по сути, есть плохая ошибка, которая позволяет вам прыгать куда угодно. Но если ваша программа свободна от такого рода ошибок, об этом гораздо легче рассуждать. (Не случайно, что «безопасные» промежуточные языки, такие как CLR IL или Java-байт-код, гораздо проще декомпилировать, даже если отбросить метаданные.) Таким образом, на практике должна быть возможность декомпилировать определенные, хорошо себя ведущиепрограммы. Я думаю об отдельных, функциональных стилях, у которых нет побочных эффектов и четко определенных входных данных. Я думаю, что есть пара декомпиляторов, которые могут дать псевдокод для простых функций, но у меня нет большого опыта работы с такими инструментами.
источник
Причина, по которой машинный код не может быть легко преобразован обратно в исходный исходный код, заключается в том, что во время компиляции теряется много информации. Методы и неэкспортированные классы могут быть встроены, имена локальных переменных теряются, имена файлов и структуры полностью теряются, компиляторы могут делать неочевидные оптимизации. Другая причина в том, что несколько разных исходных файлов могут создавать одну и ту же сборку.
Например:
Может быть скомпилировано в:
Моя сборка довольно ржавая, но если компилятор может убедиться, что оптимизация может быть выполнена точно, он это сделает. Это связано с скомпилированный двоичный не нужно знать имена
DoSomething
иAdd
, а также тот факт , чтоAdd
метод имеет два именованных параметров компилятор знает , чтоDoSomething
метод по существу возвращает константу, и он может встраивать как вызов метода и сам метод.Цель компилятора - создать сборку, а не способ объединения исходных файлов.
источник
ret
и просто сказать, что вы предполагаете соглашение о вызовах Си.Основными принципами здесь являются сопоставления "многие к одному" и отсутствие канонических представителей.
В качестве простого примера явления «многие к одному» вы можете подумать о том, что происходит, когда вы берете функцию с некоторыми локальными переменными и компилируете ее в машинный код. Вся информация о переменных теряется, потому что они просто становятся адресами памяти. Нечто подобное происходит с петлями. Вы можете взять цикл
for
или,while
и если они структурированы правильно, вы можете получить идентичный машинный код сjump
инструкциями.Это также приводит к отсутствию канонических представителей из исходного исходного кода для инструкций машинного кода. Когда вы пытаетесь декомпилировать циклы, как вы отображаете
jump
инструкции обратно в циклические конструкции? Вы делаете ихfor
петлями илиwhile
петлями.Проблема еще более усугубляется тем фактом, что современные компиляторы выполняют различные формы свертывания и вставки. Таким образом, к тому времени, когда вы дойдете до машинного кода, почти невозможно сказать, из каких высокоуровневых конструкций получился машинный код низкого уровня.
источник