Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?

Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?

Всем привет! Мы команда сопровождения GlowByte, занимаемся решением багов в различных системах крупного бизнеса. Большая часть продуктов, которые мы поддерживаем, – это маркетинговые комплексы банков, телекома и ритейла. Такие системы работают с огромным количеством данных заказчика: сегментируют их для создания программ лояльности и политики персонализированных предложений, делают рассылку по разным каналам коммуникации, принимают поступающие из разных источников данные в реальном времени и тут же их обрабатывают. Часто ядро таких систем разработано определённым вендором, а на стороне заказчика внедрены кастомизированные процессы.

Под нагрузками бизнеса в системе могут возникать инциденты, причём как в самом ядре, так и в кастомизированных процессах. И если код кастомизированных процессов нам виден, то код ядра скрыт, что затрудняет самостоятельный разбор багов. Ранее мы описали историю о том, как стали сопровождать систему, которая была похожа на “чёрный ящик”: баг случился в ядре системы, и мы не могли обратиться к разработчику и получить ответ. В этой статье хотим разобрать техническую сторону того, как мы выходили из ситуации и какой вид reverse-инжиниринга мы применили.

Немного предыстории о проекте

Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?

Однажды мы пришли на проект, который ранее сопровождала команда N. Заказчик не имел доступа к кастомизированному коду, ничего не знал о технических деталях устройства своей системы и не имел документации. Вся экспертиза была сконцентрирована на этой команде N, которая в череде недавних событий покинула российский рынок, фактически бросив заказчика наедине с проблемами. Придя к нам, заказчик знал только о бизнес-логике системы и имел инфраструктурную карту. Нашей задачей было нарастить экспертизу по проекту и начать быстро и эффективно исправлять инциденты.

Первым делом мы получили доступ к серверам и провели ресёрч директорий, запущенных процессов и логов. Сюрпризом оказалось то, что от логов были только access.log, то есть разработчик намеренно скрывал всю логику работы ПО, не выводя ничего даже в логи. В процессах мы тоже не нашли хоть чего-то полезного, а структура директорий и содержимое дали понять, что мы имеем дело с микросервисами на Java.

В процессе анализа мы записали все пункты, которые вызвали вопросы, составили карту исследования. Что делают микросервисы и как они взаимодействуют в кластере? С какими данными и с какими источниками работают? Какой логикой руководстсвуются? Всё это предстояло узнать. Мы решили использовать метод “снежного кома”: всякий раз, когда появляется новая деталь, записываются 3-4 следующих вопроса или шага к проверкам. Оценивая такой подход спустя время, стало очевидно, что именно он не дал нам зайти в тупик.

Итак, получив пока не понятно как связанные JAR микросервисов, мы решили, что было бы неплохо посмотреть на их исходный код и понять хотя бы верхнеуровнево, на каких технологиях они работают.

Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?

Мы начали с байт-кода. Он описывает стек и работу с ним, содержится в 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."<init>":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // "<init>":()V #4 = Utf8 java/lang/Object #5 = Utf8 <init> #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."<init>":()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, нагрузку на диски и др.), а также отследить показатели в БД, логи и метрики внутри интерфейсов приложений. Это была очень скудная информация без глубины истории: она показывала метрику в моменте без анализа периодичности. Тем не менее так мы составили некоторое представление о том, как все должно быть.

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

Также мы исследовали область интеграции приложения с данными. Если баг случается на уровне интеграции с процедурами в БД/с типами данных в таблицах/с самими данными, то легче поправить их. Смотря на ситуацию из будущего, оказалось, что это было полезно и многие правки на уровне данных (хотя они и были нетривиальными) помогли проекту.

Reverse-инжиниринг “чёрного ящика”: зачем поддержке исходный код?

На протяжении всей исследовательской работы и выстраивания фундамента для сопровождения полностью закрытого проекта, мы всё записывали. В конечном счёте наша работа стала похожа на детектив, в котором мы шаг за шагом создали технический портрет проекта.

Оглядываясь назад, подытожим шаги, которые мы предприняли. Попробовали ручной просмотр байт-кода. Это было непонятно, вместе с тем очень интересно, однако на нашей практике такой метод показал себя неэффективным.

Мы применили декомпиляцию с помощью CFR. Инструмент для нас оказался очень полезным и нужным: он дал знания о том, как работает система и чего от неё можно ожидать, а также ключи для поиска обходных решений инцидентов. В то же время декомпиляция в нашем случае оказалась применима только в качестве метода получения новых знаний: для обратной сборки проекта она не подошла в масштабах проекта.

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

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

Начать дискуссию