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

Рост количества и сложности ВПО, написанного на Rust
Приведу неполный список публичных репортов, в которых упоминается ВПО на Rust:
2023–11–09 — Bitdefender — Hive Ransomware's Offspring: Hunters International Takes the Stage
2023–11–23 — Check Point — Israel‑Hamas War Spotlight: Shaking the Rust Off SysJoker
2023–11–27 — Intezer — WildCard: The APT Behind SysJoker Targets Critical Sectors in Israel
2024–09–19 — Discovering Splinter: A First Look at a New Post‑Exploitation Red Team Tool
В этих репортах прослеживается не только тенденция к увеличению частоты использования Rust различными threat actor в качестве языка для своих инструментов, но и тенденция к усложнению этих инструментов. Если сначала в основном использовались простые загрузчики, дропперы, инжекторы или просто вспомогательные инструменты для разведки в системе жертвы, в дальнейшем Rust стали использовать для написания шифровальщиков и C2-фреймворков не только с открытым исходным кодом, но и с закрытым — проприетарные.
Сложность реверса Rust
Прежде чем переходить к конкретным примерам исполняемых файлов, хотелось бы сначала описать, в чем заключается сложность реверса Rust с точки зрения реализации этого языка программирования по сравнению с другими. В этом мне отчасти поможет диаграмма из воркшопа Reversing Rust Binaries: One step beyond strings. Эта диаграмма и пересечения в ней легли в основу моего дальнейшего рассуждения о сложности реверса 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 — каталог, содержащий примеры использования проекта.

Чтобы собрать динамическую или статическую библиотеку, в Cargo.toml можно указать тип сборки.
crate-type = ["cdylib"]
crate-type = ["staticlib"]
Чтобы собрать проект с использованием определенного компоновщика, можно использовать параметр --target при сборке проекта.
cargo build --target x86_64-unknown-linux-musl
Также очень важную роль играют следующие параметры:
strip — параметр управляет флагом ‑C strip, который отвечает за удаление из двоичного файла либо символов, либо отладочной информации.
opt‑level — параметр управляет флагом ‑C opt‑level, который отвечает за уровень оптимизации.

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

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

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

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

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

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

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

Также стоит упомянуть другие возможности rustbinsign, информацию о которых можно получить с помощью параметра 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‑файл, с которым злоумышленники взаимодействовали по сети. Задача: идентифицировать ВПО и понять его функционал. Спустя несколько часов изучения ВПО я прихожу к выводу: понятно, что ничего не понятно.

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

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

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

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

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

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

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

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

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

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

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

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

После исправления ошибки структура контейнера 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 и их рантаймах нам не помогли.

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

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

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

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

Чтобы добраться от функции main до обработчика команд в отладочной сборке, нужно провалиться в 37 функций! В релизной сборке нужно провалиться в 10 функций — это облегчает нам задачу, но нужно понять, откуда столько кода, написанного не нами. Для этого нужно понимать две основных концепции Tokio:
-
Макрос #[tokio::main], преобразующий async fn main() в синхронную fn main(), которая инициализирует экземпляр среды выполнения и выполняет тело асинхронной функции. То есть код, который у нас находится в функции main, — рантайм Tokio.
Рисунок 31. Обертка для пользовательского кода -
Задачи Tokio — это асинхронные зеленые потоки. Они создаются путем передачи async‑блока в tokio::spawn(). Функция tokio::spawn возвращает JoinHandle, который вызывающая сторона может использовать для взаимодействия с созданной задачей, async‑блок может иметь возвращаемое значение. Вызывающая сторона может получить его с помощью.await на JoinHandle. То есть наша функция обработки команд tokio::main::{{closure}}::{{closure}}.
Рисунок 32. Структура RawTask и ее виртуальные функции
А так будет выглядеть задача в нашем файле (как и везде, виртуальные функции будут располагаться в секции.rdata).

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

Обратное проектирование исполняемых файлов, написанных на Rust, является трудоемким и затратным по времени из‑за сложностей, обусловленных особенностями этого языка. Однако использование доступных нам инструментов в связке со знанием основных структур языка, популярных крейтов и пониманием их рантайма значительно упрощает процесс.
Комментарии (8)
domix32
04.06.2025 11:45Рисунок 8. Строки вредоносного файла
не только вредоносного. Разворачивание паник генерит эти строки для практически любого бинаря, пока не указано иное.
что это не наш код,
странно было бы ожидать чего-то иного, учитывая что вы макрос навернули. Экспериментов ради предложил бы потыкать ещё всякие println/eprintln/debug/panic макросы, чтобы понимать что они генерируют
astypalaia
Может быть я душнила, но каждый раз читая в статье сокращения XYZ - очень хорошо знакомые авторам статьи, но не так сильно знакомые читателям статьи - я думаю, а почему нельзя при первом упоминании термина дать расшифровку? Например: ВПО (внеземной пилотируемый объект) - и сразу всем станет понятнее о чем идет речь.
noobxo Автор
Спасибо за замечание! Акрониму расшифровку сделал при первом упоминании.
Shaman_RSHU
Если проставлены теги "Информационная безопасность" и "Реверс-инжиниринг", то ВПО явно не относится к чему-то способному летать :)
Так мы в статьях спустимся до ГОСТовых оформлений, где в есть раздел с расшифровкой сокращений. Тут зависит от аудитории.