25 ноября 2014 в 10:42

Как работает декомпиляция в .Net или Java на примере .Net tutorial


Сегодня хотелось бы поговорить про декомпиляцию приложений (все применительно к той же Java, да и любому языку с некоторыми допущениями и ограничениями, но поскольку сам я — .Net разработчик, примеры будут совсем немного MSIL'овизированы :) ).

Для вводной, перечислю текущие средства декомпиляции в мире .Net:
  • JetBrains dotPeek (поддержка R# хоткеев, сервер символов)
  • Telerik JustDecompile (также не плохой, множество хоткеев)
  • RedGate Reflector (аналог dotPeek, но платный. Изначально был основным в мире .Net, но пока был бесплатным)
  • icsharpcode ILSpy (хороший, opensource. Полезен, когда вы сами пишете код, использующий Mono.Cecil, т.к. Это даст лучшее понимание его работы)
  • 9rays Spices .Net Decompiler
  • Dis# с функцией inplace editor

Для программной декомпиляции:
  • Mono.Cecil (основной, самый крутой декомпилятор в мире .Net. На выходе получаете объектное «зеркало» содержимого сборки. Т.е. Максимально-упрощенно, без наворотов типа конвертации массива IL в DOM).
  • ICSharpCode.Decompiler (надстройка над mono.cecil, переводящая array[MSIL] в DOM, где есть циклы, switches и if'ы. Является частью SharpDevelop/ILSpy)
  • Harmony Core (аналогичное от меня, но сохраняющее информацию о символах. В среднем состоянии, не готова для прода, помощь приветствуется).


А теперь, хотелось бы описать как они работают (вам же интересно, как работает машинка от JetBrains?). Чтобы как минимум понять, насколько это сложно: написать свой декомпилятор .Net сборки обратно в код на C#.




Для начала, заложим в наш декомпилятор набор требований:
  1. Должен принимать на вход любую сборку: от CLR 1.* до 4.*
  2. Обязан поддерживать не только C# вывод, но и MSIL, VB.NET и вообще — на что фантазии и потребностей хватит.
  3. Возможность выбирать между различными версиями языка (например, C#), при этом не имея дублирования в реализации.


И теперь, когда требования определены, давайте подумаем, как устроена работа MSIL, и как это поможет нам в быстрой декомпиляции приложения.

В отличии от языка процессора, который вносит для нас некоторые сложности в процесс декомпиляции (регистры, оптимизации, возможность сделать одно действие несколькими способами), в MSIL все максимально просто. Если надо записать в локальную переменную нечто, то для этого есть всего одна команда. Другим способом записать в переменную значение не получится. Это свойство наделяет конечный компилятор (JITter) простотой в реализации с одной стороны… А с другой стороны наделяет простотой в реализации декомпилирующую сторону.

Второе свойство, каким обладает MSIL, это вычисления на стеке. Тут нет регистров. И единственная память, через которую идут все вычисления — это стек. Это абсолютно не значит что конечный процессор также все вычисляет через стек. Нет. Это значит что этой моделью для упрощения пользуется описание всех расчетов и вызовов на MSIL. Что это значит для нас? Это значит что сложить два числа можно только одной командой, которая вне зависимости от параметров — одна. Это команда, вытащив данные для сложения из стека, складывает их и сохраняет результат не куда-либо, а обратно в стек. Это важно, потому что для нас, как для людей, пишущих декомпилятор это не породит огромного ветвления кода.

Теперь мы подошли к самому главному: как происходит процесс декомпиляции.

Чтобы после
	Ldc_i4 5
	Ldc_i4 4
	Add
	Stloc.1

Получить
	Sum = 5 + 4;

Первая трудность, которая приходит в голову: положение инструкций может быть различным. Т.е., например, чтобы код выполнился, совсем не обязательно что между ldind_i4 и add не будет других инструкций. Например, совершенно валиден следующий код:
	Ldc_i4 5
	Ldc_i4 4
	Ldc_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, у которого есть свой стек и цикл интерпретации команд. На каждой итерации цикла, берется очередная не рассмотренная команда:
  1. Если это не инструкция перехода, то мы смотрим, сколько значений на стеке требуется исследуемой командой. Далее мы достаем со стека два вычислительных узла, которые мы положили туда, как результаты вычисления предыдущих команд и создаем новый узел, ветвями которого являются взятые со стека узлы. Для примера выше это будет выглядит так:

    Т.е. Сначала у нас есть на входе 4 команды. Первые две ничего не берут на вход, а только отдают — число. Соответственно, мы кладем их на стек (ldind_i4 4, ldind_i4 5). После чего мы берем очередную команду — Add. Она принимает на вход два значения со стека. Поэтому мы считываем два узла с нашего стека и, схоранив их как параметры этой команды, сохраняем саму команду- на стек, поскольку у команды есть результат. А любой результат сохраняется на стеке.

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

    Соответственно, если бы выражение было бы посложнее:
    	Ldc_i4 5
    	Ldc_i4 4
    	Ldc_i4 10
    	Mul
    	Add
    	Stloc.1
           // value = 5 + (4 * 10)
    


    То процесс создания DOM выглядел бы следующим образом:



    После чего осуществляется окончательная сборка дерева:



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

  2. Если встречается инструкция перехода (условного или безусловного), то создается группирующий узел, который содержит узел расчета условия перехода и узлы, которые будут исполнены в случае соблюдения и не соблюдения этого условия. На данном этапе рассмотрим, как выглядят эти шаблоны «с высоты птичьего полета»:



Сборка дерева


Это все были подготовительные этапы. Далее, для модульности, создаются классы, которые распознают какую-либо одну конструкцию в дереве и переводят ее в другую. Например, если это 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 является элементом из итерируемой коллекции, то можно назвать ее [collectionName]Item либо просто item.

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

Что дальше?


Далее я разберу конкретные декомпиляторы. dotPeek dotPeek'ом, Cecil и свой. Но это — чуть позже =)
Автор: @sidristij
Luxoft
рейтинг 80,90

Комментарии (28)

  • +2
    Тоже пользуюсь dotPeek. Добавлю, что от Telerik тоже есть бесплатный декомпилятор JustDecompile.
    • 0
      Добавил =) Не пользуюсь и пропустил. У него свое ядро или Cecil?
      • 0
        Cecil похоже, но я им тоже не пользуюсь. Субъективно код от dotPeek лучше.
  • 0
    While и Do-While циклы перепутаны.
    • 0
      точно, спасибо -)
      • 0
        на КДПВ тоже перепутано ;)
        • +2
          У меня возникла мысль создать пост с множеством графиков и текстов так, чтобы содержимое не имело никакого смысла, но выглядело бы круто. Мне интересно, вышел бы такой пост в топ Хабра? -))) Я про то что только вы заметили это =)))
          • 0
            много ли мОлодежи помнит Блок-Схимника?
  • 0
    Заниматься декомпиляцией не приходилось, после прочтения аж руки зачесались попробовать. Спасибо.
    • 0
      Милости просим в harmony core =) Там как раз все в полу-рабочем состоянии, есть над чем потрудиться =)
      • 0
        Спасибо, за приглашение. Как будет свободное время гляну.
    • 0
      Я лично хотел бы поиграться с декомпиляцией на языке Nemerle. Мне кажется, на нем процесс пошел бы гораздо приятнее. Язык же как раз для таких вещей создан
  • 0
    Спасибо, очень познавательная статья! Вроде бы и ничего сложного, но работу по созданию декомпилятора нужно проделать немалую.
  • +1
    Интересно, спасибо.
    Только, если не ошибаюсь, Ldind_i4 загружает значение на стек с указанного в параметре адреса. Константы загружают инструкции Ldc.
    Кстати, всегда было интересно, есть ли разница в скорости инструкций ldc.i4.5, ldc.i4.s 5 и ldc.i4 5. Или разница только в размере кода. (Пробовал измерять, короткие формы вроде чуть быстрее, но не уверен в правильности измерения)
    • 0
      Точно =) Писал по памяти =)
  • 0
    De4dot будете описывать? :)
    • 0
      Я бы почитал конкретно про dnlib — она больше подходит по теме (и, в отличие от cecil, куда более устойчива к обфускации) :)
      • +2
        Посмотрел на оба продукта. Да, в теме больше dnlib. Но разбор полетов в De4Dot мне также нравится. Как работает деобфускация кода — не менее интересная тематика.
        • 0
          Плюсую, про то как работает de4dot я тоже с удовольствием бы почитал.
  • +1
    Отличная статья. Писал декомпилятор 1С-опкода примерно по такому же принципу. Все работает идеально, пока не применены специальные средства защиты.
    Как обстоят дела в вашем случае, если .Net-сборка обфусцирована?
    • +2
      С обфускацией не работал, но тут необходимо применять своего рода пре-процессинговые матчеры. Т.е. подготавливать код к основным алгоритмам:
      1. Выявить переменные, которые сеттятся один раз, и от которых зависит flow.
        	var x = false;
        	if(x) { DoSomeStrangeWork(); }
      2. Переход к следующей инструкции организовать так, чтобы пропускать безусловные переходы (т.е. чтобы след инструкция была = инструкции по адресу безусл. перехода). Искать вызовы методов, которые вызываются только из одного места и при этом private, после чего инлайнить их. Ну и т.д. =)
      • +2
        Ну и потом, опять же, можно перетянуть алгоритмы с того же De4dot, который упоминался немного выше
  • 0
    Ждем следующей статьи! А то эта часть так неожиданно закончилась, я только-только вчитался и вник, как уже конец…
  • +1
    Интересно, что в списке текущих средств, кроме Reflector-а — остальные, ну можно назвать «новички».
    А как же 9Rays-овский Spices.Net Decompiler (представляющий visual decompiler, не то что бы ух, но для представления интересен), а как же Dis# (с интересной функцией inplace) — российского происхождения? Подревнее будут. Reflector тут вообще должен идти как №1 — ветеран, экс-супер-мега популярный декомпилер.
    Почему упомянул «новички» — дело тут в базе кейсов кода. Понятно, что в данной статье описаны, ну просто совсем базовые вещи, однако даже разница продуцируемого компиляторами MSIL версий от .Net 1.1 и 2.0 и далее — есть. Плюс кейсы от различных экзотических и не очень конструкций кода.
    Далее — MSIL продуцируемый VB-компилятором и c# (и уж про managed c++ можно что-то сказать) — это серьезное испытание для декомпилятора, то есть конверсии c#->c#, VB->VB неплохи будут, но вот c#->VB уже будет вызывать сложности (это также — множество кейсов).
    Ну и вообще — вот очередной девелопер взялся за декомпилятор(а то как-то мне уже кажется здесь история про yet another cd-ejector. Не ищите здесь ворчливости, просто вопрос декомпиляции — это вопрос тысяч и тысяч кейсов. Базово конечно будет работать, а дальше? Откуда кейсы брать?). Какие задачи он ставит перед собой (ну the best decompiler in the world, отринем)? Попыток-то много было, все-таки история вопроса — а ж с 2003 года(это для .Net decompiers конечно), у того же телерика — это попытка, а мне кажется сначала для собственных нужд они его делали, а вот RedGate купившее Reflector уже и так признавалось, что покупка себя не отбила и не отобьет (Лутц Ройдер просто продал им, и ушел в консальтинг, а они еще и за миллион бакинских купили неплохой обфускатор из базовых — SmartАssembly в попытке рынок забрать, сейчас явно проблемы у ребят). Даже на RSDN разрабатывали коллективно свой декомпилер.
    Ну да, и при ответе на вопрос, действительно стоит в список задач поставить пункт «как быть с обфусцированными сборками?»/ Он тут одновременно превращает декомпилятор в инструмент для не очень моральных, с точки зрения производителя, персон. Да, декомпилятор — инструмент двойного назначения. Однако и многие производители обфускаторов обозначают в списке фичей — «защита от Reflector», что тоже важно для защищающих свой код компаний.
    • 0
      Да, я даже более развернуто задал бы вопрос:
      Не боитесь ли вы исков от компаний-вендоров (или от банкстеров — у них полно .Net кода), когда они узнают, что их know-how код был reverse engineered при помощи вашего декомпилятора?
    • 0
      Спасибо за развернутый комментарий =) Статья прежде всего о «как», а не «чем», ну и не про все декомпиляторы знаю, на самом деле. Inplace — офигенная вещь, спасибо за наводку =)

      Telerik и JB — ответ на платный Reflector. Ответ на вопрос «насколько сложно сделать декомпилятор». Они сделали его очень быстро. Исков не боюсь: я уже задавал вопрос в JB. Они были не против если без большой критики буду описывать некоторые части. Ну и большинство вещей я все-таки опишу с open-source решений. Благо они практически полностью присутствуют в закрытых.

      Разработка своего — just for fun. Потому что интересно и это — хобби. Не более того.
  • 0
    Telerik и JB — ответ на платный Reflector.

    По Telerik-у — насколько я знаю, не совсем так. Они давно на это заглядывались, в 2007-8-х годах, казалось, что это эльдорадо, и в те года они хотели купить сторонний (опять же по примеру RedGate, RG признались о своей неудачной покупке по-моему в 2010-м).
    Разработка своего — just for fun. Потому что интересно и это — хобби. Не более того.

    На мой взгляд лучше позиционироваться на роли инструмента восстановления кода (утраченного), конвертера кода из одного языка в другой — в этом основная польза этого инструмента. В таком случае и целеполагание будет точным.
    Ну, удачи. Будем посмотреть :)
  • –1
    ildasm использовать не пробовали?

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка