Найти тему
Солар

Сравнительный анализ некоторых Java-декомпиляторов

Оглавление

В этой статье будут рассмотрены четыре декомпилятора — Fernflower, CFR, Procyon и jadx — и произведено их сравнение по нескольким параметрам.

Дисклеймер: сравнение неформальное и не претендует на научность. Скорее, это просто обзор всех актуальных (на осень 2019) декомпиляторов Java-байткода.

Предыстория

Наш инструмент — Solar appScreener — предназначен для поиска уязвимостей в коде. Среди прочих языков он может анализировать и Java-байткод. Но самого по себе анализа мало: нужно показать результаты пользователю так, чтобы он мог интегрировать их в процесс разработки. Для этого недостаточно просто сказать "посмотрите на 147-ую байткод-инструкцию в методе таком-то". Чтобы эта информация была полезна программисту, нужно как-то сопоставить эти ошибки с исходным кодом.

Сразу же возникает проблема: а что делать, если исходники недоступны? Решение: можно декомпилировать байткод, найти в нем строки, соответствующие выявленным уязвимостям, и показывать пользователю сообщения об ошибках, привязанные к строкам в декомпилированном коде.

Итого, нам нужно уметь делать две вещи:

  • декомпилировать байткод;
  • строить соответствие между инструкциями в байткоде и строками исходного кода.

Спойлер: ни в одном из известных декомпиляторов (UPD: кроме Fernflower'а) нету инструментария для того, чтобы осуществить второе. Так что соответствие между ошибками и строками декомпилированного кода мы строим отдельным этапом, уже после декомпиляции. О том, как это делается, рассказано в статье моего коллеги.

А сейчас я расскажу вам о первом пункте: собственно, декомпиляции.

Что нам нужно от декомпилятора

Разные декомпиляторы заточены под разные задачи. Например, заявлено, что Fernflower — аналитический (analytical) декомпилятор. Что это значит, нигде толком не объясняется, но по идее этот компилятор акцентирует внимание на более глубоком анализе и деобфускации кода. Для нас эта функциональность не очень важна (во всяком случае, при отображении результатов анализа). В целом, приоритетом для нас является понятность и читаемость получающегося кода.

Так что основные требования к инструментам таковы:

  • читаемый и (по возможности) корректный код в результате;
  • поддержка синтаксического сахара (foreach, try-with-resources, etc).

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

Инструменты

Для сравнения были выбраны четыре опенсорсных проекта (были взяты самые актуальные версии на момент написания этого текста — осень 2019 года). Остальные были забракованы еще на предварительном этапе и подробно не анализировались.

Полный список декомпиляторов, которые были рассмотрены, но не освещены подробно,

спрятан здесь.

Краткая техническая информация о декомпиляторах:

  • Лицензия: Fernflower - Apache 2.0; CFR - MIT; Procyon - Apache 2.0; jadx -Apache 2.0.
  • Библиотека: Fernflower - неофициальное зеркало на гитхабе; CFR - Maven: org.benf.cfr; Procyon - Maven: org.bitbucket.mstrobel; jadx - Bintray
  • Какие версии Java поддерживает: Fernflower - не указано; CFR - 8, частично 9; Procyon - большая часть 8; jadx - частично 8
  • Написан на: Fernflower - Java 8; CFR - Java 6; Procyon - Java 7; jadx - Java 8
  • Документация: Fernflower - нет; CFR - есть!; Procyon - немножко; jadx - README на гитхабе

Важно не забывать, что jadx в первую очередь предназначен для проектов под Android. И чтобы анализировать код, написанный под jvm, декомпилятор сначала конвертирует его с помощью инструмента dx. Поскольку эта конвертация сама по себе бывает некорректна, адекватное сравнение jadx с другими инструментами провести невозможно, поэтому в большинстве случаев функционал jadx рассматривается отдельно.

Также jadx поддерживает DEX только до 37 версии, из-за чего у него возникают проблемы, например, с обработкой лямбд.

Сравнение

В сравнении участвовали Fernflower (версия с Гитхаба за 16.09.19), CFR (0.146), Procyon (0.5.36) и jadx (1.0.0). При этом сравнение с jadx проводилось не по всем параметрам.

Проект, на котором производилось сравнение, — сам Fernflower, так как у него относительно большая кодовая база, написанная целиком на Java 8. Причем в коде активно используются разные фичи языка. Код на более актуальной версии Java использовать было нельзя — Procyon не поддерживает Java 9 вообще, а CFR гарантирует поддержку только некоторых фич (про Fernflower ничего официально не сказано).

Строки запуска спрятаны тут

Если вам неинтересны детали и вы хотите сразу посмотреть результаты, это можно сделать в этом разделе.

Метрики

  • Поддержка и активность проекта.
  • Количество ошибок при сборке результата декомпиляции.
  • Скорость.
  • Обработка некоторых фич языка.

Поддержка и активность проекта

Fernflower

С одной стороны, этот декомпилятор используется в Intellij IDEA, что гарантирует жизнь и поддержку этого проекта.

С другой стороны, Fernflower — часть проекта Intellij IDEA. У самого декомпилятора нет даже отдельного репозитория на гитхабе (только упомянутое выше неофициальное зеркало, ссылка на которое — единственный способ подключить Fernflower к своему проекту как зависимость).

Если судить по репозиторию на гитхабе, активного добавления новых фич в этот проект не наблюдается. Последний коммит в master случился 3 месяца назад (состояние на осень 2019). Точнее понять, что происходит с этим проектом, трудно, так как кодовая база является частью репозитория Intellij IDEA.

CFR

Код пишется одним человеком, но релизы происходят регулярно (по нескольку раз в год). На все замечания, отправленные мной автору на почту, он ответил в течение нескольких дней и исправил ошибки в течение недели-двух. Только за время написания этой статьи вышел новый релиз (0.147), в котором починена одна из упомянутых ошибок.

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

Procyon

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

jadx

Этот декомпилятор постоянно развивается, репозиторий и ишью-трекер на гитхабе очень живые и активные. 20 июня 2019 произошел релиз версии 1.0.0. Новые фичи и поддержка более актуальных версий DVM добавляются.

Количество ошибок при сборке результата декомпиляции

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

В этой секции jadx не рассматривается, так как он бросает 39 исключений при декомпиляции fernflower.jar и, следовательно, в принципе не декомпилирует большое количество кода.

Для начала заметим, что есть три класса ошибок: синтаксические (их не выявлено ни одной, хотя еще несколько версий назад в CFR их было несколько); семантические ошибки, связанные с типами (неправильно выведенные параметры у дженериков, ненайденные методы, некорректные приведения типов), и все остальные семантические ошибки.

Причины, по которым ошибки, связанные с выведением типов, выделены в отдельную категорию:

  • декомпиляторы теоретически не способны полностью корректно восстановить типы;
  • эти ошибки встречаются чаще, чем все остальные вместе взятые;
  • они относительно мало влияют на читаемость кода.

К тому же, количество ошибок, связанных с выводом типов, примерно одинаково (хотя CFR все-таки проигрывает соперникам).

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

  • Синтаксические: Fernflower 0; CFR 0; Procyon 0;
  • Все семантические: Fernflower 101; CFR 82; Procyon 79;
  • Связанные с типами: Fernflower 65; CFR 80; Procyon 61;
  • Остальные: Fernflower 36; CFR 2; Procyon 16;

В коде, сгенерированном с помощью Fernflower, таких ошибок больше всего, причем 34 из 36 — это ошибки вида variable <var> is already defined. Две ошибки у CFR тоже связаны с переопределением переменных. В случае Procyon'а большинство (10 из 16) ошибок происходят из-за того, что переменная типа boolean используется в качестве индекса массива. Это происходит из-за некорректной обработки тернарных операторов (подробнее этот случай рассмотрен в секции ниже).

Отдельно стоит заметить, что CFR — единственный из трех декомпиляторов, улучшивший свои показатели за последние 4 месяца. Раньше у него было 10 ошибок, не связанных с типами и 72 — про типы. Из этого можно предположить, что большое количество "типовых" ошибок у CFR связано с тем, что остальных ошибок у него меньше и, следовательно, больше пространства для неправильного вывода типов.

Скорость

Дисклеймер: еще раз замечаю, что это исследование не претендует на какую-либо научность.

Сравнение скорости работы было проведено достаточно топорно: проекты запускались на среднего размера джарниках по несколько раз, после чего выяснялось минимальное время работы.

Здесь приведены результаты для 100 итераций на JAR-файле размером 5.2M (JAR-файл, естественно, состоит только из .class файлов).

Fernflower 74 сек

CFR 43 сек

Procyon 74 сек

Результаты для 15 запусков на JAR-файле в 14M.

Fernflower 939 сек

CFR 128 сек

Procyon 573 сек

По результатам можно предположить, что в этих декомпиляторах используются алгоритмы с разной асимптотикой. При этом CFR работает стабильно быстрее конкурентов, а на больших входных файлах Fernflower начинает довольно сильно тормозить. Впрочем, 14M — это очень много памяти для архива .class файлов и в реальности такие проекты попадаются довольно редко.

Обработка конкретных фич языка

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

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

-2

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

for-each

FullInstructionSequence.java

for (ExceptionHandler handler : handlers) {
handler.from_instr = this.getPointerByAbsOffset(handler.from);
handler.to_instr = this.getPointerByAbsOffset(handler.to);
handler.handler_instr = this.getPointerByAbsOffset(handler.handler);
}

Fernflower всегда раскрывает for-each конструкции через итераторы. Причем делает он это не вполне корректно.

Например, здесь handler засоряет внешнюю область видимости, из-за чего может происходить переопределение переменной. К тому же, у итератора var3 не указан параметр типа, что приводит к unchecked cast в четвертой строке:

ExceptionHandler handler;
for (Iterator var3 = handlers.iterator(); var3.hasNext(); handler.handler_instr = this.getPointerByAbsOffset(handler.handler)) {
handler = (ExceptionHandler)var3.next();
handler.from_instr = this.getPointerByAbsOffset(handler.from);
handler.to_instr = this.getPointerByAbsOffset(handler.to);
}

Тернарный оператор при индексации массива

SSAConstructorSparseEx.java

varmaparr[varmaparr[1] == null ? 0 : 1]

Стандартная и очень неприятная ошибка Procyon'а. Разобраться, что хотел сказать автор, не имея исходного кода под рукой, — задача не очень тривиальная, особенно в более сложных случаях:

varmaparr[varmaparr[1] != null];

Статическое поле в интерфейсе

IFernflowerPreferences.java

public interface IFernflowerPreferences {
Map<String, Object> DEFAULTS = getDefaults();

static Map<String, Object> getDefaults() { ... }
}

Загадочная ошибка, воспроизводящаяся только при использовании Procyon. Атрибут default, указанный вместо static в определении getDefaults(), порождает ошибку:

public interface IFernflowerPreferences {
public static final Map<String, Object> DEFAULTS = getDefaults();

// Error: non-static method getDefaults()
// cannot be referenced from a static context.
default Map<String, Object> getDefaults() { return ... }
}

Остальные ошибки

Дальше рассмотрено некоторое количество более сложных случаев, с которыми не справляется уже большее количество инструментов.

Чтобы не пугать народ, они спрятаны под спойлером.

Работа jadx на dex файле

Дополнительно я посмотрела, как работает jadx на настоящем Android проекте — AntennaPod (приложение для прослушивания подкастов).

Самые одиозные ошибки и странности тут.

В общем, можно подытожить, что код, декомпилированный jadx, не очень стабилен в плане читаемости, хотя при этом довольно неплох со стороны корректности и разнообразия обрабатываемых конструкций. При этом редкие, но кошмарные ситуации, когда jadx добавляет в код 15 ненужных переменных или раскрывает простейший switch-case через if-else с тремя уровнями вложенности, очень портят впечатление от получающегося в результате кода.

Результаты

По результатам сравнения можно сказать следующее:

CFR

Обгоняет конкурентов и по читаемости кода (лучше обрабатывает синтаксический сахар типа for-each, try-with-resources и другие, при этом результат содержит меньшее количество семантических ошибок), и по скорости (особенно это заметно на файлах большого размера). Также CFR стабильно развивается и поддерживается разработчиком.

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

Procyon

Более надежный и стабильный, но почти не развивается. Из-за этого начал отставать от CFR в смысле поддержки фич Java 9 и старше. Также Procyon до сих пор содержит довольно маргинальные баги (обработка некоторых тернарных операторов и статических полей в интерфейсах).

Fernflower

Не очень подходит для наших задач. Проигрывает конкурентам по скорости и качеству результата (во всяком случае на необфусцированных данных). С другой стороны, Fernflower используется в Intellij IDEA, что дает некоторые гарантии того, что проект не умрет в ближайшем будущем.

jadx

Единственный достойный (если вообще не единственный) декомпилятор, предназначенный для Android. Дает неплохие результаты, но работает нестабильно (иногда декомпилирует байткод в корректный, но абсолютно нечитаемый код). Не поддерживает некоторые фичи языка (например, try-with-resources) и некоторые инструкции DVM старше 37 версии. Для декомпиляции JAR файлов не подходит в принципе.

P.S. уже после написания этого текста, нашлась вот такая статья: очень подробное сравнение декомпиляторов. Статья формальная, научная, но оценивает декомпиляторы в основном со стороны корректности получающегося кода, не рассматривая такие метрики, как читаемость кода и скорость работы декомпилятора.