Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?
Всем привет! Мы команда сопровождения GlowByte, занимаемся решением багов в различных системах крупного бизнеса. Большая часть продуктов, которые мы поддерживаем, — это маркетинговые комплексы банков, телекома и ритейла. Такие системы работают с огромным количеством данных заказчика: сегментируют их для создания программ лояльности и политики персонализированных предложений, делают рассылку по разным каналам коммуникации, принимают поступающие из разных источников данные в реальном времени и тут же их обрабатывают. Часто ядро таких систем разработано определённым вендором, а на стороне заказчика внедрены кастомизированные процессы.
Под нагрузками бизнеса в системе могут возникать инциденты, причём как в самом ядре, так и в кастомизированных процессах. И если код кастомизированных процессов нам виден, то код ядра скрыт, что затрудняет самостоятельный разбор багов. Ранее мы описали историю о том, как стали сопровождать систему, которая была похожа на «чёрный ящик»: баг случился в ядре системы, и мы не могли обратиться к разработчику и получить ответ. В этой статье хотим разобрать техническую сторону того, как мы выходили из ситуации и какой вид reverse-инжиниринга мы применили.
Немного предыстории о проекте
Однажды мы пришли на проект, который ранее сопровождала команда N. Заказчик не имел доступа к кастомизированному коду, ничего не знал о технических деталях устройства своей системы и не имел документации. Вся экспертиза была сконцентрирована на этой команде N, которая в череде недавних событий покинула российский рынок, фактически бросив заказчика наедине с проблемами. Придя к нам, заказчик знал только о бизнес-логике системы и имел инфраструктурную карту. Нашей задачей было нарастить экспертизу по проекту и начать быстро и эффективно исправлять инциденты.
Первым делом мы получили доступ к серверам и провели ресёрч директорий, запущенных процессов и логов. Сюрпризом оказалось то, что от логов были только access.log, то есть разработчик намеренно скрывал всю логику работы ПО, не выводя ничего даже в логи. В процессах мы тоже не нашли хоть чего-то полезного, а структура директорий и содержимое дали понять, что мы имеем дело с микросервисами на Java.
В процессе анализа мы записали все пункты, которые вызвали вопросы, составили карту исследования. Что делают микросервисы и как они взаимодействуют в кластере? С какими данными и с какими источниками работают? Какой логикой руководствуются? Всё это предстояло узнать. Мы решили использовать метод «снежного кома»: всякий раз, когда появляется новая деталь, записываются 3–4 следующих вопроса или шага к проверкам. Оценивая такой подход спустя время, стало очевидно, что именно он не дал нам зайти в тупик.
Итак, получив пока не понятно как связанные JAR микросервисов, мы решили, что было бы неплохо посмотреть на их исходный код и понять хотя бы верхнеуровнево, на каких технологиях они работают.
Мы начали с байт-кода. Он описывает стек и работу с ним, содержится в class-файлах, которые, в свою очередь, находятся в обнаруженных нами JAR. Это набор инструкций, который, подобно ассемблеру, содержит указания на то, как что-то положить в кусок памяти, как забрать и как переместить. Мы открыли документацию о структуре байт-кода и на самых простых примерах изучили его работу. Чтобы объяснить, как именно мы это делали, приведём простой пример Java-программы, которую можно собрать в единственный class-файл, запустить и получить вывод строчки «Hello World!» в консоль.
Исходный код:
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Если собрать этот код Javac HelloWorld.java, а затем к полученному байт-коду применить команду:
javap -c -p -v HelloWorld
мы получим следующие строки байт-кода в читабельном виде:
Classfile /Dev/demo/HelloWorld.class
Last modified 24 окт. 2022 г.; size 427 bytes
SHA-256 checksum e81c98f6672f78896481d924f3f4de59dd872fc595cd1dbb031b4ecd863ba11e
Compiled from "HelloWorld.java"
class HelloWorld
minor version: 0
major version: 63
flags: (0x0020) ACC_SUPER
this_class: #21 // HelloWorld
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "":()V
#4 = Utf8 java/lang/Object
#5 = Utf8
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello, World!
#14 = Utf8 Hello, World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // HelloWorld
#22 = Utf8 HelloWorld
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 HelloWorld.java
{
HelloWorld();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello, World!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "HelloWorld.java"
Изначально идут метаданные байт-кода.
Путь класса: Classfile /Dev/demo/HelloWorld.class
Дата последнего изменения и размер: Last modified 24 окт. 2022 г.; size 427 bytes
Чексумма: SHA-256 checksum e81c98f6672f78896481d924f3f4de59dd872fc595cd1dbb031b4ecd863ba11e
Название исходного файла с кодом Java: Compiled from «HelloWorld.java»
Версии класса: minor version: 0 major version: 63
ACC_SUPER — это указание на битовую маску для свойства модификатора.
Далее идёт описание используемых классов:
this_class: #21 // HelloWorld
super_class: #2 // java/lang/Object
Здесь HelloWorld — наш класс, а Object — это суперкласс, от которого наследуются все другие классы.
Далее идут счётчики интерфейсов, полей, методов и атрибутов в собранном классе:
interfaces: 0, fields: 0, methods: 2, attributes: 1
Далее — список констант, которые используются либо прямо в коде (заданы разработчиком), либо для поддержки этого кода. Теги всех Constant pool можно найти в официальной документации.
Следом для каждого объекта идут строки инструкций. Общая структура выглядит следующим образом:
descriptor: …
flags: …
Code:
stack=1, locals=1, args_size=1
LineNumberTable:
Descriptor — это адрес на дескриптор объекта. Указывает на тип возвращаемого объекта и тип сигнатуры.
Например,
([Ljava/lang/String;)V
означает, что метод принимает на вход ссылку на класс java/lang/String и возвращает пустоту V (void).
В общем виде формула выглядит так:
( ParameterDescriptor* ) ReturnDescriptor
В поле flags указываются маски модификаторов. Code содержит набор инструкций со стеком.
LineNumberTable содержит перечисления вида:
line 4: 8,
где первое число — смещение в байт-коде, а второе — номер строки.
Разбирая таким образом строчку за строчкой, мы пришли к мнению, что это очень долгий и неэффективный ручной способ для такого количества кода, которое у нас есть. Поэтому решили поискать готовые инструменты, которые могут анализировать байт-код и выдавать результат в виде исходного кода на Java.
Погуглив, нашли несколько онлайн-декомпиляторов байт-кода Java, изучили логику их работы, обфускацию кода при сборке, версии и виды декомпиляторов. Попробовали множество их видов с GitHub. Они позволяли подгружать только по одному .class за раз и часто выдавали исходный код с высокой неточностью декомпиляции: были явные синтаксические ошибки, нечитабельные названия объектов (по всей видимости, код был ещё обфусцирован), отсутствовало форматирование кода и т. д. Словом, всё было не то. Идея с онлайн-декомпилятором не дала того результата, который мог бы дополнить общую картину по проекту.
У нас даже была мысль написать свой декомпилятор. Затем на очередной странице обзоров (которые мы уже стали читать по кругу) натолкнулись на CFR.
CFR — это декомпилятор с открытым исходным кодом (а значит бесплатный), который активно развивается и в нашем случае выдаёт прекрасный результат.
Мы скачали CFR:
wget http://www.benf.org/other/cfr/cfr_0_115.jar
И затем декомпилировали все JAR-файлы командой вида:
java -jar cfr_0_115.jar javacontainer.jar --outputdir ./javacontainer.
В OutputDir появились красивые Java-файлы, дающие нам ключ к ответам на многие вопросы. Мы проанализировали библиотеки и фреймворки, которые используются в проекте, попытались понять общую логику и архитектуру, выявить паттерны построения проекта. Как мы упомянули выше, каждое наше действие сопровождалось записыванием вопросов, которые мы постепенно закрыли и задокументировали. У нас появилось техническое понимание внутренностей проекта. Этого отчасти хватило для того, чтобы понять, куда смотреть, если какой-то элемент не работает, но всё-таки было недостаточно, чтобы найти причину или исправить.
Сразу скажу, что среди команды сопровождения экспертиза по Java наклонена в пользу решения багов, а не разработки новых продуктов, сборок и поставок Java-кода. Исходя из этого, при декомпиляции мы преимущественно искали то, что нужно нам для решения какой-либо проблемы. Декомпилировав всё, мы обнаружили, что кода в этом проекте было настолько много, что изучить его комплексно одному человеку было не под силу, и мы пошли от обратного: начали изучать кусками по мере необходимости. Когда нам заводили заявку, мы уже по составленной логической схеме понимали, в какую часть микросервисов нужно смотреть, декомпилировали и описывали логику именно этой части. Такой подход значительно увеличил скорость решения инцидентов, привёл к ясности, почему случилась та или иная авария, но всё же оставил один открытый вопрос: как решать баг, если он является ошибкой в самом коде.
Первая идея — потратить кучу времени команды, разобрать весь код и затем собрать его обратно. Такой подход явно имел много рисков. Во-первых, декомпиляция всё ещё оставалась неточной, требовала внесения ручных правок (с последующим тестированием). Во-вторых, нужно было самостоятельно выяснять в огромном количестве кода, какие имеются зависимости, и решать проблемы сборки. В-третьих, мы работали над масштабным ритейл-проектом, в котором было критично важно, чтобы ПО не простаивало и чтобы наш новый подход не сделал хуже: риски были высокими, а сроков на тестирование почти не оставалось. Поэтому мы отмели идею с «разборкой» кода и начали работать в направлении поиска альтернативного пути. Обходное решение должно было быть таким, чтобы не требовалось вносить правки в код микросервисов.
В поисках универсальных обходных решений мы первым делом попробовали найти показатели, на которые можем ориентироваться: стали следить за метриками, выяснять, какие значения и каких показателей являются нормой, а какие нет. В системе мы нашли Zabbix, который содержал множество бесполезных метрик. Они перегружали систему, давали крайне мало полезной информации. Позднее мы провели работы по оптимизации дашбордов и собираемых данных, но на момент анализа пришлось работать с тем, что есть.
По Zabbix получилось оценить только метрики железа (CPU, RAM, нагрузку на диски и др.), а также отследить показатели в БД, логи и метрики внутри интерфейсов приложений. Это была очень скудная информация без глубины истории: она показывала метрику в моменте без анализа периодичности. Тем не менее так мы составили некоторое представление о том, как все должно быть.
Затем мы попробовали все виды перезагрузок (кстати, о том как правильно их выполнять, мы тоже узнали методом проб и ошибок): перезагрузки всех серверов сразу и по отдельности, выводы и вводы нод из балансировки, перезагрузки микросервисов в комплексе и по отдельности, перезагрузки отдельных компонент и т. д.
Также мы исследовали область интеграции приложения с данными. Если баг случается на уровне интеграции с процедурами в БД/с типами данных в таблицах/с самими данными, то легче поправить их. Смотря на ситуацию из будущего, оказалось, что это было полезно и многие правки на уровне данных (хотя они и были нетривиальными) помогли проекту.
На протяжении всей исследовательской работы и выстраивания фундамента для сопровождения полностью закрытого проекта, мы всё записывали. В конечном счёте наша работа стала похожа на детектив, в котором мы шаг за шагом создали технический портрет проекта.
Оглядываясь назад, подытожим шаги, которые мы предприняли. Попробовали ручной просмотр байт-кода. Это было непонятно, вместе с тем очень интересно, однако на нашей практике такой метод показал себя неэффективным.
Мы применили декомпиляцию с помощью CFR. Инструмент для нас оказался очень полезным и нужным: он дал знания о том, как работает система и чего от неё можно ожидать, а также ключи для поиска обходных решений инцидентов. В то же время декомпиляция в нашем случае оказалась применима только в качестве метода получения новых знаний: для обратной сборки проекта она не подошла в масштабах проекта.
Разобрав историю нашего заказчика по частям, мы стали готовы сопровождать проект без помощи разработчика. У нас появилась собственная экспертиза, которой мы делимся с заказчиком.
Сейчас наша команда продолжает также точечно декомпилировать части системы и делать новые открытия, но общий подход и инструменты уже выработаны.