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

07e0f90ae0b63a5395d433eee1bf75c6.png

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

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

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

e65412a71dfa02876c36d062f462039a.png

Однажды мы пришли на проект, который ранее сопровождала команда 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-кода. Исходя из этого, при декомпиляции мы преимущественно искали то, что нужно нам для решения какой-либо проблемы. Декомпилировав всё, мы обнаружили, что кода в этом проекте было настолько много, что изучить его комплексно одному человеку было не под силу, и мы пошли от обратного: начали изучать кусками по мере необходимости. Когда нам заводили заявку, мы уже по составленной логической схеме понимали, в какую часть микросервисов нужно смотреть, декомпилировали и описывали логику именно этой части. Такой подход значительно увеличил скорость решения инцидентов, привёл к ясности, почему случилась та или иная авария, но всё же оставил один открытый вопрос: как решать баг, если он является ошибкой в самом коде. 

Первая идея — потратить кучу времени команды, разобрать весь код и затем собрать его обратно. Такой подход явно имел много рисков. Во-первых, декомпиляция всё ещё оставалась неточной, требовала внесения ручных правок (с последующим тестированием). Во-вторых, нужно было самостоятельно выяснять в огромном количестве кода, какие имеются зависимости, и решать проблемы сборки. В-третьих, мы работали над масштабным ритейл-проектом, в котором было критично важно, чтобы ПО не простаивало и чтобы наш новый подход не сделал хуже: риски были высокими, а сроков на тестирование почти не оставалось. Поэтому мы отмели идею с «разборкой» кода и начали работать в направлении поиска альтернативного пути. Обходное решение должно было быть таким, чтобы не требовалось вносить правки в код микросервисов.

1740032384c243c3cc83b195c5e740fb.jpeg

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

По Zabbix получилось оценить только метрики железа (CPU, RAM, нагрузку на диски и др.), а также отследить показатели в БД, логи и метрики внутри интерфейсов приложений. Это была очень скудная информация без глубины истории: она показывала метрику в моменте без анализа периодичности. Тем не менее так мы составили некоторое представление о том, как все должно быть. 

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

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

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

412bea72ee174a79994f39a4ef05dd5f.jpeg

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

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

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

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

© Habrahabr.ru