Сегодня хотелось бы поговорить про декомпиляцию приложений (все применительно к той же Java, да и любому языку с некоторыми допущениями и ограничениями, но поскольку сам я — .Net разработчик, примеры будут совсем немного MSIL'овизированы :) ).
Для вводной, перечислю текущие средства декомпиляции в мире .Net:
- JetBrains dotPeek (поддержка R# хоткеев, сервер символов)
- RedGate Reflector (аналог dotPeek, но платный. Изначально был основным в мире .Net, но пока был бесплатным)
- icsharpcode ILSpy (хороший, opensource. Полезен, когда вы сами пишете код, использующий Mono.Cecil, т.к. Это даст лучшее понимание его работы)
Для программной декомпиляции:
- Mono.Cecil (основной, самый крутой декомпилятор в мире .Net. На выходе получаете объектное «зеркало» содержимого сборки. Т.е. Максимально-упрощенно, без наворотов типа конвертации массива IL в DOM).
- ICSharpCode.Decompiler (надстройка над mono.cecil, переводящая array[MSIL] в DOM, где есть циклы, switches и if'ы. Является частью SharpDevelop/ILSpy)
- Harmony (аналогичное от меня, но сохраняющее информацию о символах. В среднем состоянии, не готова для прода, помощь приветствуется).
А теперь, хотелось бы описать как они работают (вам же интересно, как работает машинка от JetBrains?). Чтобы как минимум понять, насколько это сложно: написать свой декомпилятор .Net сборки обратно в код на C#.
Для начала, заложим в наш декомпилятор набор требований:
- Должен принимать на вход любую сборку: от CLR 1.* до 4.*
- Обязан поддерживать не только C# вывод, но и MSIL, VB.NET и вообще — на что фантазии и потребностей хватит.
- Возможность выбирать между различными версиями языка (например, C#), при этом не имея дублирования в реализации.
И теперь, когда требования определены, давайте подумаем, как устроена работа MSIL, и как это поможет нам в быстрой декомпиляции приложения.
В отличии от языка процессора, который вносит для нас некоторые сложности в процесс декомпиляции (регистры, оптимизации, возможность сделать одно действие несколькими способами), в MSIL все максимально просто. Если надо записать в локальную переменную нечто, то для этого есть всего одна команда. Другим способом записать в переменную значение не получится. Это свойство наделяет конечный компилятор (JITter) простотой в реализации с одной стороны… А с другой стороны наделяет простотой в реализации декомпилирующую сторону.
Второе свойство, каким обладает MSIL, это вычисления на стеке. Тут нет регистров. И единственная память, через которую идут все вычисления — это стек. Это абсолютно не значит что конечный процессор также все вычисляет через стек. Нет. Это значит что этой моделью для упрощения пользуется описание всех расчетов и вызовов на MSIL. Что это значит для нас? Это значит что сложить два числа можно только одной командой, которая вне зависимости от параметров — одна. Это команда, вытащив данные для сложения из стека, складывает их и сохраняет результат не куда-либо, а обратно в стек. Это важно, потому что для нас, как для людей, пишущих декомпилятор это не породит огромного ветвления кода.
Теперь мы подошли к самому главному: как происходит процесс декомпиляции.
Чтобы после
Ldind_i4 5
Ldind_i4 4
Add
Stloc.1
Получить
Sum = 5 + 4;
Первая трудность, которая приходит в голову: положение инструкций может быть различным. Т.е., например, чтобы код выполнился, совсем не обязательно что между ldind_i4 и add не будет других инструкций. Например, совершенно валиден следующий код:
Ldind_i4 5
Ldind_i4 4
Ldind_i4 10
Stloc.2 // sum2
Add
Stloc.1 // sum1
Что должно декомпилироваться, например, так:
Sum2 = 10
Sum1 = 5 + 4;
Во-вторых названия переменных в релизе могут отсутствовать. Т.е. без примесей, код будет таким:
= 10
= 5 + 4;
В третьих, что самое сложное, реализации if-else, while, do-while, switch могут отличаться. Этого касаются, в особенности, лямбды, yields, async/awaits и прочие языковые примочки, которые являются опциональными и на самом деле реализуются поверх обычных функций языка. Как все это учесть? На самом деле оба вопроса решаются всего двумя способами.
Стековая модель декомпиляции
Для декомпиляции пишется некий интерпретатор кода на MSIL, у которого есть свой стек и цикл интерпретации команд. На каждой итерации цикла, берется очередная не рассмотренная команда:
- Если это не инструкция перехода, то мы смотрим, сколько значений на стеке требуется исследуемой командой. Далее мы достаем со стека два вычислительных узла, которые мы положили туда, как результаты вычисления предыдущих команд и создаем новый узел, ветвями которого являются взятые со стека узлы. Для примера выше это будет выглядит так:
Т.е. Сначала у нас есть на входе 4 команды. Первые две ничего не берут на вход, а только отдают — число. Соответственно, мы кладем их на стек (ldind_i4 4, ldind_i4 5). После чего мы берем очередную команду — Add. Она принимает на вход два значения со стека. Поэтому мы считываем два узла с нашего стека и, схоранив их как параметры этой команды, сохраняем саму команду- на стек, поскольку у команды есть результат. А любой результат сохраняется на стеке.Далее результат может быть передан в метод, либо участвовать в других арифметических операциях, либо возвращен с помощью инструкции ret.
Соответственно, если бы выражение было бы посложнее:
Ldind_i4 5 LdInd_i4 4 LdInd_i4 10 Mul Add Stloc.1 // value = 5 + (4 * 10)
То процесс создания 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 и соответствующая нода. Дополнительно, необходимо считать количество пробелов, которые надо отступать с начала каждой строки. На этом этапе все достаточно просто.
Генерация имен переменных
Перед генерацией кода необходимо сгенерировать имена всех переменных, которые были потерты в процессе компиляции. Для этого также написаны алгоритмы матчинга. Для генерации имен переменных служат:
- Имена методов, которые не являются сгенерированными компилятором, в расчетах или результатах расчетов которых они используются. Пример: var ??? = this.Counterparty; -> ??? = counterparty.
- Данные, является ли переменная — переменной цикла. Т.е. Считается ли она только в теле цикла. Если она — целочисленная, то кандидаты на имя — index, i, j.
- Если переменная — в цикле foreach является элементом из итерируемой коллекции, то можно назвать ее Item либо просто item.
Для реализации этих и многих других алгоритмов служат матчеры, которые аналогичны матчерам, перестраивающим дерево под конкретный язык программирования.
Автор: sidristij