Вступление
Язык программирования Go (так же известный как Golang) с каждым днем все больше и больше программистов хотят на нем писать программы. Для хакеров этот язык программирования становится еще более привлекательным за счет кросс-компиляции для различных платформ - Windows, Linux, MacOS. Например, хакеры могут написать загрузчик на Go, внутри может быть ВПО, созданное с использованием более распространенного языка.
На Go также пишутся так называемые дропперы, использующиеся для декодирования, загрузки и установки ВПО. Все идет к тому, что Go, вероятно, будет еще чаще использоваться злоумышленниками. Уже появлялись и шифровальщики использующие Go для шифрования основного вредоносного модуля. Одним из самых примитивных примеров использования Go - написание кроссплатформенного ReverseShell . Отсюда и вытекает необходимость реверс-инжиниринга бинарных файлов написанных на язке Go.
Некоторые особенности языка программирования Go усложняют задачу реверс-инженерам при исследовании бинарных файлов Go. Инструменты обратного проектирования (например, дизассемблеры) могут отлично справляться с анализом двоичных файлов, написанных на более популярных языках (например, C, C++, .NET), но Go создает новые проблемы, которые делают анализ более громоздким.
Бинарные файлы Go обычно статически связаны, что означает, что все необходимые библиотеки включены в скомпилированный бинарный файл. Это приводит к большим размерам файла, что затрудняет распространение вредоносного ПО для злоумышленников. С другой стороны, некоторые продукты безопасности также имеют проблемы с обработкой больших файлов. Это означает, что большие двоичные файлы могут помочь вредоносным программам избежать обнаружения. Другое преимущество статически связанных двоичных файлов для злоумышленников заключается в том, что вредоносное ПО может работать на целевых системах без проблем с зависимостями.
Поскольку наблюдается непрерывный рост вредоносных программ, написанных на Go, и ожидается появление новых семейств вредоносных программ, было принято решение глубже погрузиться в язык программирования Go и расширить набор инструментов, чтобы повысить эффективность расследования вредоносных программ Go.
В этой статье будет рассказано о двух трудностях, с которыми сталкиваются реверс-инженеры при анализе бинарных файлов написанных на Go, и будут показаны решения этих проблем.
Дисклеймер: Все данные, предоставленные в данной статье, взяты из открытых источников, не призывают к действию и являются только лишь данными для ознакомления, и изучения механизмов используемых технологий.
Для реверс-инжиниринга был выбрал дизассемблер Ghidra с открытым исходным кода. Его хорошо использовать для статического анализа вредоносного ПО. Для Ghidra можно создавать собственные скрипты и плагины, чтобы предоставлять определенные функции, которые нужны исследователям. Пользователи создают скрипты, чтобы помочь бинарному анализу, материалы представлены в репозитории GitHub.
Потерянные имена функций
Первая проблема касается не только бинарных файлов Go, но и бинарных файлов в целом. Скомпилированные исполняемые файлы могут содержать символы отладки, упрощающие отладку и анализ. Когда аналитики реконструируют программу, скомпилированную с отладочной информацией, они могут видеть не только адреса памяти, но и имена подпрограмм и переменных. Однако авторы вредоносного ПО обычно компилируют файлы без этой информации, создавая так называемые зачищенные бинарники. Они делают это, чтобы уменьшить размер файла и затруднить обратный инжиниринг. При работе с разделенными двоичными файлами аналитики не могут полагаться на имена функций, которые помогут им ориентироваться в коде. Со статически скомпонованными бинарниками Go, куда включены все необходимые библиотеки, анализ может значительно замедлиться.
Чтобы проиллюстрировать эту проблему, мы использовали для сравнения простые примеры «Hello Hacktivity», написанные на C и Go, и скомпилировали их в бинарные файлы. Обратите внимание на разницу в размерах между двумя исполняемыми файлами.
В окне «Функции» Ghidra перечислены все функции, определенные в двоичных файлах. В неразделенных версиях имена функций хорошо видны и очень помогают реверс-инженерам.
Списки функций для двоичных файлов выглядят следующим образом:
Эти примеры четко показывают, что даже простой двоичный файл «hello world» Go огромен, имея более тысячи функций.
Примечание. Из-за удаления исчезли не только имена функций, но Ghidra также распознала только 1139 функций из 1790 определенных функций.
Нас интересовало, есть ли способ восстановить имена функций в удаленных двоичных файлах. Во-первых, мы запустили простой поиск строк, чтобы проверить, доступны ли имена функций в двоичных файлах. В примере C мы искали функцию main
, а в примере Go — main.main
Утилита strings
не смогла найти имя функции в удаленном двоичном файле C, но «main.main» все еще был доступен в версии Go. Это открытие вселило в нас некоторую надежду на то, что восстановление имен функций возможно в удаленных бинарных файлах Go.
Загрузка двоичного файла в Ghidra и поиск строки main.main
покажет его точное местоположение. Как видно на изображении ниже, строка имени функции находится в разделе .gopclnta
Структура pclntab
доступна, начиная с версии Go 1.2, и хорошо документирована. Структура начинается с магического значения, за которым следует информация об архитектуре. Затем таблица символов функций содержит информацию о функциях внутри двоичного файла. За адресом точки входа каждой функции следует таблица метаданных.
Таблица метаданных функции, помимо другой важной информации, хранит смещение имени функции.
Используя эту информацию, можно восстановить имена функций. Наша команда создала скрипт (go_func.py) для Ghidra, чтобы восстановить имена функций в удаленных файлах Go ELF, выполнив следующие шаги:
Находит структуру pclntab
Извлекает адреса функций
Находит смещения имени функции
Выполнение нашего скрипта не только восстанавливает имена функций, но также определяет ранее нераспознанные функции.
Чтобы увидеть реальный пример, давайте посмотрим на образец программы-вымогателя eCh0raix:
Этот пример показывает, насколько полезным может быть сценарий восстановления имени функции при обратном инжиниринге. Аналитики могут предположить, что имеют дело с программами-вымогателями, просто взглянув на названия функций.
Примечание. В двоичных файлах Windows Go нет специального раздела для структуры pclntab, и исследователям необходимо явно искать поля этой структуры (например, магическое значение, возможные значения полей). Для macOS доступен раздел _gopclntab, аналогичный .gopclntab в бинарных файлах Linux.
Проблемы: неопределенные строки имени функции
Если строка имени функции не определена Ghidra, то сценарий восстановления имени функции не сможет переименовать эту конкретную функцию, поскольку он не может найти строку имени функции в заданном месте. Чтобы решить эту проблему, наш скрипт всегда проверяет, находится ли определенный тип данных по адресу имени функции, и, если нет, пытается определить строковый тип данных по заданному адресу, прежде чем переименовывать функцию.
В приведенном ниже примере строка имени функции log.New
не определена в образце программы-вымогателя eCh0raix, поэтому соответствующую функцию нельзя переименовать без предварительного создания строки.
Следующие строки в нашем скрипте решают эту проблему:
Нераспознанные строки в двоичных файлах Go
Вторая проблема, которую решают наши скрипты, связана со строками в двоичных файлах Go. Давайте вернемся к примерам «Hello Hacktivity» и посмотрим на определенные строки в Ghidra.
70 строк определены в двоичном коде C с фразой «Hello, Hacktivity!» среди них. Между тем, бинарник Go включает 6540 строк, но поиск по «hacktivity» не дает результата. Такое большое количество строк уже затрудняет поиск, но в этом случае строка, которую мы ожидали найти, была не распознана Ghidra.
Чтобы понять эту проблему, вам нужно знать, что такое строка в Go. В отличие от C-подобных языков, где строки представляют собой последовательности символов, заканчивающиеся нулевым символом, строки в Go представляют собой последовательности байтов фиксированной длины. Строки — это специфичные для Go структуры, состоящие из указателя на расположение строки и целого числа, представляющего собой длину строки.
Эти строки хранятся в двоичных файлах Go в виде больших строковых двоичных объектов, состоящих из конкатенации строк без нулевых символов между ними. Таким образом, поиск «Hacktivity» с использованием строк и grep дает ожидаемый результат в C, в Go он возвращает огромный строковый объект, содержащий «hacktivity».
Поскольку строки в Go определяются по-другому, а результаты, ссылающиеся на них в ассемблерном коде, также отличаются от обычных C-подобных решений, у Ghidra возникают трудности со строками в двоичных файлах Go.
Строковая структура может размещаться разными способами, она может создаваться статически или динамически во время выполнения, она различается в разных архитектурах и даже может иметь несколько решений в одной архитектуре. Чтобы решить эту проблему, наша команда создала два скрипта, помогающих идентифицировать строки.
Динамическое размещение строковых структур
В первом случае строковые структуры создаются во время выполнения. Последовательность инструкций по сборке отвечает за настройку структуры перед строковой операцией. Из-за разных наборов инструкций структура различается в зависимости от архитектуры. Давайте рассмотрим пару вариантов использования и покажем последовательности инструкций, которые ищет наш скрипт (find_dynamic_strings.py).
Динамическое размещение строковых структур для x86
Во-первых, давайте начнем с примера «Hello Hacktivity».
После запуска скрипта код выглядит так:
Строка определена:
Так же эту строку можно найти в поле Defined Strings:
Скрипт ищет следующие последовательности инструкций в 32-разрядных и 64-разрядных двоичных файлах x86:
ARM и динамическое размещение строк
Для 32-битной архитектуры ARM я использую пример программы-вымогателя eCh0raix, чтобы проиллюстрировать восстановление строки.
После выполнения скрипта код выглядит так:
Указатель переименовывается, а строка определяется:
Скрипт ищет следующую последовательность инструкций в 32-битных двоичных файлах ARM:
Для 64-битной архитектуры ARM давайте воспользуемся образцом Kaiji, чтобы проиллюстрировать восстановление строки. Здесь код использует две последовательности инструкций, которые различаются только в одной последовательности.
После выполнения скрипта код выглядит так:
Строки определены:
Скрипт ищет следующие последовательности инструкций в 64-битных двоичных файлах ARM:
Как видите, скрипт может восстанавливать динамически размещенные строковые структуры. Это помогает реинжинирингу читать ассемблерный код или искать интересные строки в представлении Defined String в Ghidra.
Проблемы для этого подхода
Самый большой недостаток этого подхода заключается в том, что каждая архитектура (и даже разные решения в рамках одной архитектуры) требует добавления в скрипт новой ветки. Кроме того, очень легко обойти эти предопределенные наборы инструкций. В приведенном ниже примере, где длина строки перемещается в более ранний регистр в образце вредоносного ПО Kaiji для 64-разрядных ARM, сценарий этого не ожидает и поэтому пропустит эту строку.
Статически размещенные строковые структуры
В следующем случае наш скрипт (find_static_strings.py) ищет статически выделенные строковые структуры. Это означает, что за указателем строки следует длина строки в разделе данных кода.
Вот как это выглядит в образце программы-вымогателя x86 eCh0raix.
На изображении выше за строковыми указателями следуют значения длины строки, однако Ghidra не может распознавать адреса или целочисленные типы данных, за исключением первого указателя, на который прямо ссылается код.
Неопределенные строки можно найти, следуя адресам строк.
После выполнения скрипта будут определены адреса строк, а также значения длины строк и сами строки.
Проблемы: устранение ложных срабатываний и отсутствующих строк
Мы хотим исключить ложные срабатывания, поэтому мы:
Ограничить длину строки
Поиск печатных символов
Поиск в разделах данных бинарного файла
Очевидно, что строки могут легко проскользнуть из-за этих ограничений. Если вы используете сценарий, не стесняйтесь экспериментировать, изменять значения и находить лучшие настройки для вашего анализа. Следующие строки в коде отвечают за ограничения длины и набора символов:
Дальнейшие проблемы в восстановлении строк
Автоматический анализ Ghidra может ошибочно идентифицировать определенные типы данных. Если это произойдет, наш скрипт не сможет создать правильные данные в этом конкретном месте. Чтобы решить эту проблему, необходимо сначала удалить неправильный тип данных, а затем создать новый.
Например, давайте взглянем на программу-вымогатель eCh0riax со статически выделенными строковыми структурами.
Здесь адреса идентифицируются правильно, однако значения длины строки (предполагаемые целочисленными типами данных) ошибочно определяются как значения undefined4.
Следующие строки в нашем скрипте отвечают за удаление некорректных типов данных:
После выполнения сценария все типы данных идентифицируются правильно, а строки определяются.
Другая проблема возникает из-за того, что строки объединяются и хранятся в виде больших строковых двоичных объектов в двоичных файлах Go. В некоторых случаях Ghidra определяет весь большой двоичный объект как одну строку. Их можно определить по большому количеству ссылок на обрезки. Внешние ссылки — это ссылки на определенные части определенной строки, а не на адрес начала строки, а скорее на место внутри строки.
Пример ниже взят из образца ARM Kaiji.
Чтобы найти ложно определенные строки, можно использовать окно «Defined Strings» в Ghidra и отсортировать строки по количеству ссылок на ответвления. Большие строки с многочисленными ответвлениями можно отменить вручную перед выполнением сценариев восстановления строк. Таким образом сценарии могут успешно создавать правильные строковые типы данных.
Наконец, мы покажем проблему в представлении декомпиляции Ghidra. Как только строка будет успешно определена либо вручную, либо с помощью одного из наших скриптов, она будет хорошо видна в представлении листинга Ghidra, помогая обратному инженеру читать ассемблерный код. Однако представление декомпилятора в Ghidra не может правильно обрабатывать строки фиксированной длины и, независимо от длины строки, будет отображать все до тех пор, пока не найдет нулевой символ. К счастью, эта проблема будет решена в следующем выпуске Ghidra (9.2).
Так проблема выглядит на образце eCh0raix:
Что ждет реверсера Go
В этой статье основное внимание уделялось решениям двух проблем в бинарных файлах Go, чтобы помочь обратному инжинирингу использовать Ghidra и статически анализировать вредоносное ПО, написанное на Go. Мы обсудили, как восстановить имена функций в удаленных двоичных файлах Go, и предложили несколько решений для определения строк в Ghidra. Сценарии, которые мы создали, и файлы, которые мы использовали для примеров в этой статье, общедоступны, а ссылки можно найти ниже.
Это только верхушка айсберга, когда речь заходит о возможностях реверс-инжиниринга Go. В качестве следующего шага мы планируем углубиться в соглашения о вызовах функций Go и систему типов.
В двоичных файлах Go аргументы и возвращаемые значения передаются функциям с использованием стека, а не регистров. В настоящее время Ghidra с трудом может правильно их обнаружить. Помощь Ghidra в поддержке соглашения о вызовах Go поможет обратному инжинирингу понять назначение анализируемых функций.
Еще одна интересная тема — типы в бинарных файлах Go. Как мы показали, извлекая имена функций из исследованных файлов, двоичные файлы Go также хранят информацию об используемых типах. Восстановление этих типов может быть большим подспорьем для реверс-инжиниринга. В приведенном ниже примере мы восстановили структуру main.Info
в образце программы-вымогателя eCh0raix. Эта структура сообщает нам, какую информацию вредоносное ПО ожидает от сервера C2.
Как видите, в бинарных файлах Go есть еще много интересных областей, которые можно обнаружить с точки зрения реверс-инжиниринга.
Репозиторий Github со скриптами и дополнительными материалами
https://github.com/getCUJO/ThreatIntel/tree/master/Scripts/Ghidra
https://github.com/getCUJO/ThreatIntel/tree/master/Research_materials/Golang_reversing
Ссылки и дополнительная литература
https://rednaga.io/2016/09/21/reversing_go_binaries_like_a_pro/
https://2016.zeronights.ru/wp-content/uploads/2016/12/GO_Zaytsev.pdf
https://carvesystems.com/news/reverse-engineering-go-binaries-using-radare-2-and-python/
https://www.pnfsoftware.com/blog/analyzing-golang-executables/
https://github.com/strazzere/golang_loader_assist/blob/master/Bsides-GO-Forth-And-Reverse.pdf
https://github.com/radareorg/r2con2020/blob/master/day2/r2_Gophers-AnalysisOfGoBinariesWithRadare2.pdf
Решения других исследователей для различных инструментов
IDA Pro
https://github.com/sibears/IDAGolangHelper
https://github.com/strazzere/golang_loader_assist
radare2 / Cutter
https://github.com/f0rki/r2-go-helpers
https://github.com/JacobPimental/r2-gohelper/blob/master/golang_helper.py
https://github.com/CarveSystems/gostringsr2
Binary Ninja
https://github.com/f0rki/bn-goloader
Ghidra
https://github.com/felberj/gotools
https://github.com/ghidraninja/ghidra_scripts/blob/master/golang_renamer.py