Содержание

Всем привет! Я Влад Лунин, занимаюсь анализом сложных угроз в экспертном центре безопасности Positive Technologies. В последнее время часто приходится сталкиваться с вредоносным программным обеспечением (ВПО), написанном на Rust, поэтому хочу поделиться своим опытом реверса исполняемых файлов, написанном на этом языке. ВПО бывает как очень простое, так и очень сложное в своей реализации, и чаще всего эта сложность обусловлена особенностями Rust. Расскажу, какие подходы применяю для успешного реверса, а также про сложности, с которыми сталкивался, и как их преодолевал.

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

Рисунок 1. Начинаем!
Рисунок 1. Начинаем!

Рост количества и сложности ВПО, написанного на Rust

Приведу неполный список публичных репортов, в которых упоминается ВПО на Rust:

В этих репортах прослеживается не только тенденция к увеличению частоты использования Rust различными threat actor в качестве языка для своих инструментов, но и тенденция к усложнению этих инструментов. Если сначала в основном использовались простые загрузчики, дропперы, инжекторы или просто вспомогательные инструменты для разведки в системе жертвы, в дальнейшем Rust стали использовать для написания шифровальщиков и C2-фреймворков не только с открытым исходным кодом, но и с закрытым — проприетарные.

Сложность реверса Rust

Прежде чем переходить к конкретным примерам исполняемых файлов, хотелось бы сначала описать, в чем заключается сложность реверса Rust с точки зрения реализации этого языка программирования по сравнению с другими. В этом мне отчасти поможет диаграмма из воркшопа Reversing Rust Binaries: One step beyond strings. Эта диаграмма и пересечения в ней легли в основу моего дальнейшего рассуждения о сложности реверса Rust.

Рисунок 2. Основные сведения о Rust
Рисунок 2. Основные сведения о Rust

Вот несколько моментов, из‑за которых возникают сложности:

  • Уровень абстракции. Rust имеет высокий уровень абстракции с мощными конструкциями, такими как паттерн‑матчинг, шаблоны, макросы, трейты. Эти абстракции могут приводить к сложной и неочевидной генерации кода, затрудняя понимание связи между исходным кодом и машинным.

  • Управление памятью. Rust использует уникальную систему владения, которая исключает необходимость в сборщике мусора, но требует строгого контроля за временем жизни объектов. Это может привести к сложным структурам данных и взаимосвязям, что затрудняет анализ состояния объектов в исполняемом файле.

  • Оптимизация. Rust может компилироваться с очень агрессивной оптимизацией, которая приводит к инлайнингу функций и сложной кодогенерации, что затрудняет понимание потоков выполнения и структуры кода.

Базовая информация о Rust

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

  • Cargo — менеджер пакетов и инструмент для сборки Rust. Он используется для создания, сборки и управления зависимостями проектов Rust. Cargo позволяет создавать новые проекты, добавлять зависимости, собирать и запускать код.

  • Rustc — компилятор Rust, который используется для компиляции кода Rust в машинный код. Cargo использует rustc под капотом для сборки проектов.

  • Rustup — инструмент для установки и управления версиями Rust. Он позволяет легко переключаться между разными версиями Rust и устанавливать дополнительные инструменты, такие как Cargo.

Структура проекта:

  • Cargo.toml — файл конфигурации проекта, который содержит информацию о проекте: название, версию, автора и зависимости.

  • src — каталог, содержащий исходный код проекта.

  • src/main.rs — входная точка программы, где находится исходный код.

  • src/lib.rs — библиотека, которая содержит функции и типы, которые можно использовать в других частях проекта.

  • tests — каталог, содержащий тесты для проекта.

  • examples — каталог, содержащий примеры использования проекта.

Рисунок 3. Структура проекта
Рисунок 3. Структура проекта

Чтобы собрать динамическую или статическую библиотеку, в Cargo.toml можно указать тип сборки.

crate-type = ["cdylib"]
crate-type = ["staticlib"]

Чтобы собрать проект с использованием определенного компоновщика, можно использовать параметр --target при сборке проекта.

cargo build --target x86_64-unknown-linux-musl

Также очень важную роль играют следующие параметры:

  1. strip — параметр управляет флагом ‑C strip, который отвечает за удаление из двоичного файла либо символов, либо отладочной информации.

  2. opt‑level — параметр управляет флагом ‑C opt‑level, который отвечает за уровень оптимизации.

Рисунок 4. Выбор профиля
Рисунок 4. Выбор профиля

Известные подходы

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

Рисунок 5. Точка входа в пользовательский код Go
Рисунок 5. Точка входа в пользовательский код Go

Rust, как и Go, имеет открытый исходный код, и, недолго изучая его, можно найти файл /library/std/src/rt.rs, в котором реализована точка входа в пользовательский код.

Рисунок 6. Точка входа в пользовательский код Rust в исходном коде
Рисунок 6. Точка входа в пользовательский код Rust в исходном коде

В IDA Pro точка входа в пользовательский код будет выглядеть следующим образом.

Рисунок 7. Точка входа в пользовательский код Rust в IDA Pro
Рисунок 7. Точка входа в пользовательский код Rust в IDA Pro

Использование строк для определения функциональности ВПО

На одном из инцидентов был найден вредоносный файл. Наша задача — определить его функционал. Мы можем посмотреть строки, которые он содержит. И по ним можно предположить класс и функционал ВПО — загрузчик (loader).

Рисунок 8. Строки вредоносного файла
Рисунок 8. Строки вредоносного файла

Еще один индикатор того, что мы имеем дело с загрузчиком, это огромная секция данных, что уже сигнализирует о том, что ВПО что‑то содержит.

Рисунок 9. Секция данных загрузчика, содержащая полезную нагрузку
Рисунок 9. Секция данных загрузчика, содержащая полезную нагрузку

Использование утилит для определения функциональности ВПО

Стоит упомянуть, что IDA Pro имеет плагин для работы с Rust, но его основная и единственная задача — это деманглинг (от англ. demangling) имен функций. Однако все же есть одна утилита rustbinsign, которая сможет сделать за нас половину работы. Эта утилита может искать зависимости (крейты, от англ. crate) внутри скомпилированного исполняемого файла Rust, осуществлять их загрузку, а также сборку (при наличии установленного набора инструментов для Rust) для дальнейшего создания FLIRT‑сигнатур (при наличии у нас нужных инструментов из SDK IDA Pro). Ее установка и использование представлены ниже.

pip install rustbinsign
rustbinsign info <sample>

С помощью параметра info мы можем узнать, какие крейты используются в упомянутом ранее ВПО.

Рисунок 10. Вывод rustbinsign
Рисунок 10. Вывод rustbinsign

Теперь в совокупности с информацией, полученной нами ранее, и информацией об используемых крейтах мы не просто подтверждаем наш вывод о классе и функциональности ВПО (крейт memmap2), но и получаем дополнительную информацию о том, что данные зашифрованы с помощью алгоритма AES в режиме CFB, и при этом мы не потратили ни минуты на анализ кода!

Если все‑таки необходимо специально изучить функционал, можно использовать набор уже готовых FLIRT‑сигнатур из репозитория rust‑std‑sigs, а еще скрипт IDA_rust_metadata_finder. Этот скрипт переименовывает и типизирует все «паники» (сообщения об ошибках), найденные в целевом исполняемом файле.

Рисунок 11. Применение FLIRT-сигнатур и результат работы IDA_rust_metadata_finder
Рисунок 11. Применение FLIRT‑сигнатур и результат работы IDA_rust_metadata_finder

Также стоит упомянуть другие возможности rustbinsign, информацию о которых можно получить с помощью параметра help.

Рисунок 12. Команда help
Рисунок 12. Команда help

Забегая немного вперед, добавлю, что для генерации FLIRT‑сигнатур библиотечных функций для файлов, слинкованных с помощью musl, потребуется ее установка.

sudo apt install musl-tools
sudo snap install rustup --classic
rustup default stable
rustup target add x86_64-unknown-linux-musl

rustbinsign -l DEBUG get_std_lib 1.70.0-x86_64-unknown-linux-musl

Этой командой rustbinsign соберет для нас тестовый проект, используя библиотеку musl. Скомпилирован будет не только тестовый проект, но и все библиотеки в виде файлов.so, c которых можно будет получить FLIRT‑сигнатуры. Для этого воспользуемся скриптом idb2pat.py для получения файла.pat. Кто не знает, как генерировать FLIRT‑сигнатуры, можно почитать об этом здесь, а для тех, кто уже этим занимался и кому нужно автоматизировать исправление коллизий в файле.exc, прикладываю скрипт. Скрипт перезаписывает файл.exc, проставляя + для каждой первой коллизии. И помните — FLIRT‑сигнатуры не панацея!

import sys

with open(sys.argv[1], 'r') as f:
    d = f.read()

s = d.split('\n')

for i in range(len(s)):
    if s[i] == '' and (i + 1) < len(s):
        s[i+1] = '+' + s[i+1]
        s[i] += '\n'
    else:
        s[i] += '\n'

file1 = open(sys.argv[1], 'w')
file1.writelines(s)
file1.close()

История об одном исполняемом файле

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

Рисунок 13. Понятно, что ничего не понятно
Рисунок 13. Понятно, что ничего не понятно

Надо использовать Boogle!?

Единственный вариант, который не раз срабатывал (и в этот раз сработал), — это использование google for binary (aka Boogle). На наше счастье, в семпле присутствовал артефакт, который сразу бросился мне в глаза, и поиск по нему дал результат! Наш семпл — это исполняемый elf‑файл, написанный на Rust и слинкованный с помощью musl.

Рисунок 14. Идентификация Rust и musl
Рисунок 14. Идентификация Rust и musl

Запуская rustbinsign с параметром info, как делал это ранее, не ожидал увидеть ошибку, но она была.

Рисунок 15. Rustbinsign выдает ошибку
Рисунок 15. Rustbinsign выдает ошибку

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

Рисунок 16. Нулевой результат
Рисунок 16. Нулевой результат

Применение FLIRT‑сигнатур и скриптов не принесли результата. Я снова один на один…

Рисунок 17. Веселье только начинается
Рисунок 17. Веселье только начинается

Получаем основные структуры

Имея опыт обратного проектирования исполняемых файлов, написанных на C++ с обильным использованием STL, план был следующий: скомпилировать как можно больше исполняемых файлов с отладочной информацией, использующих различные основные типы в Rust, понять их рантайм и получить их структуры.

Рисунок 18. Распознавание рантайма stl
Рисунок 18. Распознавание рантайма stl

Для примера расскажу о двух самых распространенных контейнерах — string и vector. Для отладки использовал Visual code и расширение для него rust analyzer.

Рисунок 19. Тестовый код
Рисунок 19. Тестовый код

Расширение позволяет нам исследовать исходный код Rust, не используя для этого GitHub. При исследовании контейнеров выясняется, что string является vector с типом элемента u8.

Рисунок 20. Структура string
Рисунок 20. Структура string

Теперь после изучения структур в исходниках мы можем собрать отладочную сборку и вытащить все нужные нам структуры. Однако почему‑то IDA Pro не распознает тип string.

Рисунок 21. Отсутствие распознавания полей
Рисунок 21. Отсутствие распознавания полей

Для исправления ошибки откроем вкладку со структурами и найдем string. Почему‑то не распознается размер структуры, в этом и есть проблема. Как мы уже знаем, структура string имеет большую вложенность из других структур. Возможно, причина в ошибке, которая находится среди вложенных структур.

Рисунок 22. Отсутствие распознавания размера
Рисунок 22. Отсутствие распознавания размера

Изучив все вложенные структуры, понимаем, что нигде нет информации о следующих структурах.

struct alloc::alloc::Global
struct core::marker::PhantomData
Рисунок 23. Отсутствие структуры alloc::alloc::Global
Рисунок 23. Отсутствие структуры alloc::alloc::Global

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

Рисунок 24. Отсутствие значений marker и alloc
Рисунок 24. Отсутствие значений marker и alloc

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

Рисунок 25. Успешное распознавание полей
Рисунок 25. Успешное распознавание полей

После исправления ошибки структура контейнера string будет следующей.

struct alloc::string::String {
	alloc::vec::Vec vec;
};

struct alloc::vec::Vec {
	alloc::raw_vec::RawVec buf;
	unsigned __int64 len;
};

struct alloc::raw_vec::RawVec {
	alloc::raw_vec::RawVecInner inner;
};

struct alloc::raw_vec::RawVecInner {
	core::num::niche_types::UsizeNoHighBit cap;
	core::ptr::unique::Unique ptr;
};

struct core::num::niche_types::UsizeNoHighBit {
	unsigned __int64 __0;
};

struct core::ptr::unique::Unique {
	core::ptr::non_null::NonNull pointer;
};
struct core::ptr::non_null::NonNull {
	unsigned __int8* pointer;
};

Или же в упрощенном виде.

struct alloc::string::String {
	alloc::vec::Vec vec;
};

struct alloc::vec::Vec {
	unsigned __int64 cap; 
	unsigned __int8* pointer;
	unsigned __int64 len;
};

Перечислять все структуры и тем более их рантайм не будем: это довольно однотипная информация, ограничимся примером одного контейнера.

Tokio — крейт, который должен знать каждый

После исследования основных структур Rust и их рантайма возвращаемся к нашему семплу. Полученная мною информация принесла плоды в виде определения основных структур и их рантайма, но не позволяла окончательно понять функционал: я что‑то упускал. Вернувшись к boogle и проведя в нем какое‑то время, я нашел последний кусочек пазла. И этим кусочком был крейт Tokio. О нем чуть позже.

Возвращаюсь к вопросу: куда же делись строки? А они все были обфусцированы или вырезаны, что приводило к бесполезности утилиты rustbinsign и ручному поиску строк. При компиляции все «паники» были заменены на функцию abort, что приводило к бесполезности скрипта IDA_rust_metadata_finder.py. А еще данный семпл был скомпилирован с максимальной оптимизацией, что приводило к бесполезности FLIRT‑сигнатур. Все вышеперечисленное и мешало нам распознать крейт Tokio, рантайм которого огромный, так что даже полученные знания об основных структурах Rust и их рантаймах нам не помогли.

Рисунок 26. Инициализация структуры рантайма Tokio
Рисунок 26. Инициализация структуры рантайма Tokio

Tokio — это асинхронная среда выполнения кода Rust. Она предоставляет строительные блоки, необходимые для разработки сетевых приложений любого размера. Что делает ее идеальным крейтом при использовании различного ВПО, требующего сетевого соединения. Для изучения этого крейта напишем простой реверс‑шелл с одной командой.

Рисунок 27. Тестовый код
Рисунок 27. Тестовый код

Собрав отладочную сборку и открыв исполняемый файл в IDA Pro, видим, что это не наш код, но откуда же он взялся? Об этом позже.

Рисунок 28. Функция main
Рисунок 28. Функция main

Мы знаем, что первое, что выполнится в нашей программе, это вывод строки start_connect. Поэтому найдем ее в строках и перейдем по перекрестной ссылке в функцию, в которой она используется. Эта функция имеет название core::task::poll::Poll и имеет 11 перекрестных ссылок до функции main.

Рисунок 29. Начало пользовательского кода
Рисунок 29. Начало пользовательского кода

Так же сделаем и со строкой get_command и попадем в функцию tokio::main::{{closure}}::{{closure}}, которая имеет 26 перекрестных ссылок до функции core::task::poll::Poll.

Рисунок 30. Начало обработчика команд
Рисунок 30. Начало обработчика команд

Чтобы добраться от функции main до обработчика команд в отладочной сборке, нужно провалиться в 37 функций! В релизной сборке нужно провалиться в 10 функций — это облегчает нам задачу, но нужно понять, откуда столько кода, написанного не нами. Для этого нужно понимать две основных концепции Tokio:

  1. Макрос #[tokio::main], преобразующий async fn main() в синхронную fn main(), которая инициализирует экземпляр среды выполнения и выполняет тело асинхронной функции. То есть код, который у нас находится в функции main, — рантайм Tokio.

    Рисунок 31. Обертка для пользовательского кода
    Рисунок 31. Обертка для пользовательского кода
  2. Задачи Tokio — это асинхронные зеленые потоки. Они создаются путем передачи async‑блока в tokio::spawn(). Функция tokio::spawn возвращает JoinHandle, который вызывающая сторона может использовать для взаимодействия с созданной задачей, async‑блок может иметь возвращаемое значение. Вызывающая сторона может получить его с помощью.await на JoinHandle. То есть наша функция обработки команд tokio::main::{{closure}}::{{closure}}.

    Рисунок 32. Структура RawTask и ее виртуальные функции
    Рисунок 32. Структура RawTask и ее виртуальные функции

А так будет выглядеть задача в нашем файле (как и везде, виртуальные функции будут располагаться в секции.rdata).

Рисунок 33. Виртуальные функции класса RawTask
Рисунок 33. Виртуальные функции класса RawTask

Перечислять все структуры и тем более весь рантайм Tokio не будем, это довольно однотипная информация, ограничимся только этими двумя основными примерами.

Понимание этих двух концепций Tokio дает нам возможность быстро находить не только пользовательский код, но и начало асинхронных задач, в которых часто реализуется основная логика взаимодействия злоумышленников с C2.

Выводы

Обратное проектирование исполняемых файлов, написанных на Rust, является трудоемким и затратным по времени из‑за сложностей, обусловленных особенностями этого языка. Однако использование доступных нам инструментов в связке со знанием основных структур языка, популярных крейтов и пониманием их рантайма значительно упрощает процесс.

Комментарии (8)


  1. astypalaia
    04.06.2025 11:45

    Может быть я душнила, но каждый раз читая в статье сокращения XYZ - очень хорошо знакомые авторам статьи, но не так сильно знакомые читателям статьи - я думаю, а почему нельзя при первом упоминании термина дать расшифровку? Например: ВПО (внеземной пилотируемый объект) - и сразу всем станет понятнее о чем идет речь.


    1. noobxo Автор
      04.06.2025 11:45

      Спасибо за замечание! Акрониму расшифровку сделал при первом упоминании.


    1. Shaman_RSHU
      04.06.2025 11:45

      Если проставлены теги "Информационная безопасность" и "Реверс-инжиниринг", то ВПО явно не относится к чему-то способному летать :)

      Так мы в статьях спустимся до ГОСТовых оформлений, где в есть раздел с расшифровкой сокращений. Тут зависит от аудитории.


  1. kibb
    04.06.2025 11:45

    musl библиотека, а не компилятор


    1. noobxo Автор
      04.06.2025 11:45

      Спасибо за замечание! Исправил.


  1. keks93
    04.06.2025 11:45

    Boogle - это некий сервис?

    Можно ссылку?


  1. slonopotamus
    04.06.2025 11:45

    Нейросеть, уходи


  1. domix32
    04.06.2025 11:45

    Рисунок 8. Строки вредоносного файла

    не только вредоносного. Разворачивание паник генерит эти строки для практически любого бинаря, пока не указано иное.

    что это не наш код,

    странно было бы ожидать чего-то иного, учитывая что вы макрос навернули. Экспериментов ради предложил бы потыкать ещё всякие println/eprintln/debug/panic макросы, чтобы понимать что они генерируют