Как работает декомпиляция в .Net или Java на примере .Net
Сегодня хотелось бы поговорить про декомпиляцию приложений (все применительно к той же Java, да и любому языку с некоторыми допущениями и ограничениями, но поскольку сам я — .Net разработчик, примеры будут совсем немного MSIL'овизированы :) ).
-
(поддержка R# хоткеев, сервер символов) (также не плохой, множество хоткеев) (аналог dotPeek, но платный. Изначально был основным в мире .Net, но пока был бесплатным) (хороший, opensource. Полезен, когда вы сами пишете код, использующий Mono.Cecil, т.к. Это даст лучшее понимание его работы) с функцией inplace editor
-
(основной, самый крутой декомпилятор в мире .Net. На выходе получаете объектное «зеркало» содержимого сборки. Т.е. Максимально-упрощенно, без наворотов типа конвертации массива IL в DOM). (надстройка над mono.cecil, переводящая array[MSIL] в DOM, где есть циклы, switches и if'ы. Является частью SharpDevelop/ILSpy) (аналогичное от меня, но сохраняющее информацию о символах. В среднем состоянии, не готова для прода, помощь приветствуется).
А теперь, хотелось бы описать как они работают (вам же интересно, как работает машинка от JetBrains?). Чтобы как минимум понять, насколько это сложно: написать свой декомпилятор .Net сборки обратно в код на C#.
- Должен принимать на вход любую сборку: от CLR 1.* до 4.*
- Обязан поддерживать не только C# вывод, но и MSIL, VB.NET и вообще — на что фантазии и потребностей хватит.
- Возможность выбирать между различными версиями языка (например, C#), при этом не имея дублирования в реализации.
И теперь, когда требования определены, давайте подумаем, как устроена работа MSIL, и как это поможет нам в быстрой декомпиляции приложения.
В отличии от языка процессора, который вносит для нас некоторые сложности в процесс декомпиляции (регистры, оптимизации, возможность сделать одно действие несколькими способами), в MSIL все максимально просто. Если надо записать в локальную переменную нечто, то для этого есть всего одна команда. Другим способом записать в переменную значение не получится. Это свойство наделяет конечный компилятор (JITter) простотой в реализации с одной стороны… А с другой стороны наделяет простотой в реализации декомпилирующую сторону.
Второе свойство, каким обладает MSIL, это вычисления на стеке. Тут нет регистров. И единственная память, через которую идут все вычисления — это стек. Это абсолютно не значит что конечный процессор также все вычисляет через стек. Нет. Это значит что этой моделью для упрощения пользуется описание всех расчетов и вызовов на MSIL. Что это значит для нас? Это значит что сложить два числа можно только одной командой, которая вне зависимости от параметров — одна. Это команда, вытащив данные для сложения из стека, складывает их и сохраняет результат не куда-либо, а обратно в стек. Это важно, потому что для нас, как для людей, пишущих декомпилятор это не породит огромного ветвления кода.
Теперь мы подошли к самому главному: как происходит процесс декомпиляции.
Первая трудность, которая приходит в голову: положение инструкций может быть различным. Т.е., например, чтобы код выполнился, совсем не обязательно что между ldind_i4 и add не будет других инструкций. Например, совершенно валиден следующий код:
Что должно декомпилироваться, например, так:
Во-вторых названия переменных в релизе могут отсутствовать. Т.е. без примесей, код будет таким:
В третьих, что самое сложное, реализации if-else, while, do-while, switch могут отличаться. Этого касаются, в особенности, лямбды, yields, async/awaits и прочие языковые примочки, которые являются опциональными и на самом деле реализуются поверх обычных функций языка. Как все это учесть? На самом деле оба вопроса решаются всего двумя способами.
Стековая модель декомпиляции
-
Если это не инструкция перехода, то мы смотрим, сколько значений на стеке требуется исследуемой командой. Далее мы достаем со стека два вычислительных узла, которые мы положили туда, как результаты вычисления предыдущих команд и создаем новый узел, ветвями которого являются взятые со стека узлы. Для примера выше это будет выглядит так:
Т.е. Сначала у нас есть на входе 4 команды. Первые две ничего не берут на вход, а только отдают — число. Соответственно, мы кладем их на стек (ldind_i4 4, ldind_i4 5). После чего мы берем очередную команду — Add. Она принимает на вход два значения со стека. Поэтому мы считываем два узла с нашего стека и, схоранив их как параметры этой команды, сохраняем саму команду- на стек, поскольку у команды есть результат. А любой результат сохраняется на стеке.
Далее результат может быть передан в метод, либо участвовать в других арифметических операциях, либо возвращен с помощью инструкции ret.
Соответственно, если бы выражение было бы посложнее:
То процесс создания DOM выглядел бы следующим образом:
После чего осуществляется окончательная сборка дерева:
Таким же образом конструируются вызовы методов. Только в случае методов, со стека будет забираться требуемое под вызов количество параметров и сохраняться в классе ноды вызова метода. Если метод возвращает значение, то нода вызова метода будет сложена в стек. Если нет — добавлена к группе готовых выражений.
Сборка дерева
Это все были подготовительные этапы. Далее, для модульности, создаются классы, которые распознают какую-либо одну конструкцию в дереве и переводят ее в другую. Например, если это if-else, то матчится наличие условного перехода такого, чтобы переход осуществлялся вперед. Тогда узел преобразуется в if-else ноду, код за переходом помечается как else (negative if) нода, а код между условием и else нодой — как positive if нода. Если матчится как условный переход с переходом на прошлые инструкции, то это матчится как while цикл и дерево также перестраивается. Соответственно, в зависимости от чистоты исполнения матчеров, на выходе мы получем преобразованное дерево под конкретный язык программирования. Далее, у каждого из языков программирования мы задаем множество матчеров, которые ему подходят. Например, циклы и условия подойдут всем, потому они будут присутствовать почти во всех пакетах. А вот, например, async/await — он только для C#. Потому, будет присутствовать тольк в его пакете.
Для ясности картины, как собираются if-else и while/do-while, рассмотрим примеры:
Сборка IF-ELSE блока
Сборка WHILE блокаГенерация кода
Последний этап матчинга — генерация кода по дереву. Тут не должно быть каких-то сложностей. Идеально, конечно, было бы круто подсасывать правила от R# или StyleCop. Благо, они в XML. Но в простейшем случае, мы пишем генератор, который принимает на вход дерево описания класса. Он сперва обязан проверить все дерево: содержит ли оно не поддерживаемые типы нод. Если все в порядке, то обходится все дерево и для каждого узла вызывается соответствующий метод по шаблону проектирования Visitor, которому передается StringBuilder и соответствующая нода. Дополнительно, необходимо считать количество пробелов, которые надо отступать с начала каждой строки. На этом этапе все достаточно просто.