Статья Malware разработка на Rust: стелс-агент для red team, обход EDR и сравнение с C++

Монитор с зелёным текстом компилятора Rust и фрагментом дизассемблера Ghidra с нечитаемыми символами. Янтарное свечение CRT в кромешной тьме.


Функционально идентичный шеллкод-лоадер весит 71.7 КБ на C и 151.5 КБ на Rust - бинарь вырос вдвое. При этом, по данным исследования Rochester Institute of Technology (2023, цитируется в блоге Bishop Fox), автоматизированные средства анализа дают куда больше false negatives на Rust-бинарях, а Ghidra и IDA Free декомпилируют их в откровенную кашу по сравнению с аналогами на C/C++. Последние два года я переписываю лоадеры и C2-агенты на Rust и вижу эту картину в каждом проекте: размер растёт, но детект падает. Не магия языка - конкретные свойства компилятора и рантайма ломают привычные паттерны статического анализа. Ниже - разбор полного пайплайна: от OPSEC-конфигурации проекта до конкретных ограничений против продакшн-EDR.

Место в цепочке атаки: зачем red team строит кастомные агенты на Rust​

Кастомный malware-агент - не самоцель, а звено цепочки: initial access -> лоадер -> имплант -> post-exploitation -> exfiltration. В терминах MITRE ATT&CK разработка агента - это Malware (T1587.001, Resource Development): подготовка инструментария до начала операции. Агент при выполнении дёргает Native API (T1106, Execution) для вызова Windows API, а в продвинутых сценариях - Process Hollowing (T1055.012, Defense Evasion / Privilege Escalation) для инъекции в легитимные процессы. Подробнее - в нашем материале про разработка red team инструментов.

Почему не взять готовый фреймворк? Sliver, Havoc, Mythic - рабочие решения, но на каждый из них есть публичные сигнатуры. CrowdStrike Falcon и SentinelOne обновляют детекты на публичные C2 в течение дней после выхода релиза. На внутреннем пентесте против зрелой инфраструктуры с EDR-покрытием публичный агент - это алерт в SOC ещё на этапе initial access.

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

Первое - отсутствие привязки к MSVC CRT. Rust статически линкует зависимости через LLVM, из import table исчезают характерные для C/C++ паттерны (msvcrt.dll, ucrtbase.dll). Тривиальная классификация бинаря по импортам усложняется.

Второе - сложность декомпиляции. Ownership-модель, pattern matching, монадические обработки Result<T, E> генерируют код, который Ghidra превращает в нечитаемое месиво. Аналитику в SOC потребуется заметно больше времени на triage. По данным аналитиков PT Expert Security Center (Habr, 2025), сложность реверса Rust-бинарей обусловлена агрессивным инлайнингом, системой владения и абстракциями (трейты, макросы, pattern matching).
инлайнинг - это оптимизация, при которой компилятор заменяет вызов функции непосредственно ее телом.

Третье - меньший overlap с известными семействами. Подавляющее большинство малвари по-прежнему написано на C/C++. Rust-бинарь не попадает в те же кластеры при машинном обучении, что снижает сигнатурный детект.

Контекст применения: внутренний пентест или red team engagement против инфраструктуры с EDR (CrowdStrike Falcon, SentinelOne, Elastic 8.x+). На внешнем пентесте без EDR-контроля оверинженеринг с кастомным Rust-агентом - стрельба из пушки по воробьям.

Rust vs C++ для malware: trade-off таблица​

1780470471778.webp

Прежде чем писать код - трезвая оценка. Rust offensive security - не серебряная пуля, и в ряде сценариев C++ остаётся предпочтительным выбором.

КритерийRustC/C++Комментарий
Размер бинаря (release)150–200 КБ (лоадер)50–80 КБRust статически линкует std; уменьшается через opt-level = "z" + LTO
Import tableМинимальная (kernel32)Включает CRT-зависимостиМеньше IoC при статическом анализе
Декомпиляция (Ghidra/IDA)Плохая: monomorphization раздувает кодХорошая: прямое соответствие исходникуОсновное преимущество для evasion
Сигнатурный детект (AV)Низкий: мало overlap с известными семействамиВысокий: совпадение с CRT-паттернамиАктуально для signature-based движков
Поведенческий детект (EDR)ОдинаковыйОдинаковыйVirtualAlloc + CreateThread детектируется независимо от языка
Прямые syscallsЧерез ntapi/windows-rsЧерез inline ASM / SysWhispersОба варианта рабочие
Sleep obfuscationТребует unsafe + ручная реализацияНативно через Ekko/FoliageC++ удобнее для продвинутых техник
Стабильность агентаMemory safety из коробкиРучное управление памятьюRust: меньше крашей в длительных операциях

Когда Rust лучше: кастомный C2-агент с нуля, лоадер первого этапа (stager), инструменты для initial access - всё, где критично минимизировать статический детект и усложнить triage. Применимо: внутренний пентест, инфраструктура с EDR-покрытием.

Когда C++ лучше: sleep obfuscation уровня Ekko/Foliage, работа с ROP-цепочками, интеграция с существующим C2-фреймворком на C (Cobalt Strike BOF). В этих сценариях Rust создаёт дополнительное трение без выигрыша в evasion.

Когда язык не имеет значения: если EDR использует kernel-level телеметрию (ETW-TI в Elastic 8.x+, kernel callbacks в SentinelOne) - поведенческий детект одинаков для любого языка. Хоть на ассемблере пиши.

OPSEC-конфигурация: от toolchain до чистого бинаря​

Требования к окружению​

  • ОС: Windows 10/11 или GNU/Linux (cross-компиляция через x86_64-pc-windows-gnu)
  • Rust: nightly channel (обязательно для флагов -Z). Установка: rustup default nightly
  • Target: rustup target add x86_64-pc-windows-msvc (или -gnu)
  • RAM: минимум 4 ГБ (Rust-компилятор с LTO жрёт заметно больше, чем gcc/clang), рекомендуется 8 ГБ
  • Зависимости: крейт windows для WinAPI, опционально ntapi для прямых syscalls
  • Режим работы: offline-сборка возможна после первого cargo build с кэшированным registry

Проект без следов: cargo.toml и флаги компилятора​

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
Результат: бинарь без путей к исходникам, без имён типов, без паник-строк. Прогоняешь через strings - минимум читаемого текста.

Строки в бинаре: шифрование и обход FLOSS​

1780473640157.webp

После очистки паник-строк остаются пользовательские: URL C2-сервера, пути, HTTP-заголовки. FLARE-FLOSS от Mandiant вытаскивает их за секунды. Решение - шифрование строк на этапе компиляции. Крейт str_crypter зашифровывает строку в compile-time и расшифровывает при вызове: let url = sc!("https://c2.example.com", 20).unwrap() - в бинаре строка будет XOR-зашифрована с ключом 20. По данным автора крейта, результат устойчив к FLARE-FLOSS.

Отдельная головная боль - сериализация. Крейт serde, стандартный для JSON в Rust, записывает имена полей структур в секцию .rdata. Если C2-протокол описан как struct Task { command: String }, строка command останется в бинаре в чём мать родила. Альтернатива - C-style enums с ручной десериализацией через transmute:
Код:
#[repr(u32)]
pub enum Command {
    Sleep = 1u32,
    Shell,
    Upload,
    Undefined, // catch-all для невалидных кодов
}
impl Command {
    pub fn from_u32(id: u32) -> Self {
        // SAFETY: u32 гарантирован сигнатурой функции
        unsafe { std::mem::transmute(id) }
    }
}
C2 отправляет u32-код команды, агент конвертирует через transmute - ни одного строкового литерала в release-бинаре. Для отладки: #[cfg(debug_assertions)] impl Display for Command { ... } - Display-реализация компилируется только в debug-сборке. Попытка вызвать println!("{}", command) в release без этого атрибута приведёт к ошибке компиляции. Компилятор сам становится страховкой от OPSEC-промахов - и вот это я считаю одним из самых недооценённых свойств Rust в offensive-разработке.

Rust shellcode loader: минимальный пример с разбором​

1780473781358.webp

Базовый паттерн загрузки шеллкода в память не зависит от языка: выделить RW-память, скопировать payload, сменить права на RWX, передать управление. На Rust через крейт windows (пример адаптирован из исследования Bishop Fox, 2025):
Код:
unsafe {
    let addr = VirtualAlloc(Some(ptr::null_mut()), buf.len(),
        MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    ptr::copy(buf.as_ptr(), addr as *mut u8, buf.len());
    VirtualProtect(addr, buf.len(), PAGE_EXECUTE_READWRITE,
        &mut PAGE_PROTECTION_FLAGS(0));
    let h = CreateThread(Some(ptr::null()), 0,
        Some(std::mem::transmute(addr)),
        Some(ptr::null()), THREAD_CREATION_FLAGS(0), Some(ptr::null_mut()));
    WaitForSingleObject(h.unwrap(), INFINITE);
}
Что видит EDR на каждом шаге:
  1. VirtualAlloc с PAGE_READWRITE - выделение RW-памяти. Ничего подозрительного: приложения регулярно аллоцируют память.
  2. ptr::copy - запись шеллкода в собственный процесс. Для user-mode хуков невидима: нет межпроцессного взаимодействия.
  3. VirtualProtect -> PAGE_EXECUTE_READWRITE - вот тут начинается самое интересное. CrowdStrike Falcon перехватывает NtProtectVirtualMemory через user-mode хук и помечает регион. SentinelOne дополнительно мониторит через kernel callbacks.
  4. CreateThread с адресом в аллоцированной памяти - второй триггер. EDR коррелирует NtCreateThreadEx с адресом, не принадлежащим загруженному модулю.
Размер скомпилированного бинаря с конфигурацией из предыдущего раздела: ~155 КБ. C-эквивалент - ~72 КБ. По данным Bishop Fox, после декомпиляции в Ghidra main-функция Rust-лоадера содержит десятки переменных и вызовов rust_dealloc / unwrap_failed, тогда как C-версия декомпилируется в почти читаемый код.

Предусловия: техника работает без модификаций на Windows 10/11 без EDR. С активным CrowdStrike Falcon (≥6.x) или SentinelOne бинарь будет задетектирован поведенчески на связке VirtualProtect + CreateThread. Обход требует indirect syscalls или unhooking ntdll.

Где засветится Rust-агент: ограничения по вендорам EDR​

Rust не защищает от поведенческого детекта. Язык меняет статику бинаря, но не последовательность API-вызовов. Разберём по конкретным продуктам - потому что обобщение «работает против EDR» без вендор-специфики это, мягко говоря, самообман.

CrowdStrike Falcon (user-mode hooks + kernel callbacks). Falcon перехватывает функции ntdll.dll через inline-хуки. Цепочка VirtualAlloc -> VirtualProtect(RWX) -> CreateThread триггерит практические методы независимо от языка. Обход: прямые или непрямые syscalls через крейт ntapi, минуя хукнутые функции. На Rust реализация direct syscalls через макрос asm! менее тривиальна, чем через SysWhispers3 на C, но функционально эквивалентна.

SentinelOne (комбинированный подход). Кроме user-mode хуков использует kernel-level мониторинг через minifilter-драйверы. Прямые syscalls обходят user-mode слой, но kernel callbacks на NtCreateThreadEx и NtMapViewOfSection остаются. Rust vs C++ здесь - без разницы.

Elastic 8.x+ (kernel ETW-TI). Elastic Endpoint полагается на Event Tracing for Windows - Threat Intelligence provider на уровне ядра. Даже прямые syscalls детектируются, потому что телеметрия идёт от ядра, а не от хукнутых DLL. Против ETW-TI ни Rust, ни C++ преимуществ не дают - нужны техники уровня Disable or Modify Tools (T1685): патчинг ETW-провайдера или BYOVD.

Kaspersky EDR Expert. Собственный kernel-драйвер с callback'ами на основные операции. Детектирует shellcode injection на уровне ядра. Статический анализ Rust-бинарей у Kaspersky менее зрелый, чем для C/C++, но поведенческий модуль одинаково эффективен.

Общий паттерн: Rust снижает детект на этапе статического анализа (Masquerading, T1036 - бинарь не похож на известные семейства) и усложняет ручной реверс (Debugger Evasion, T1622 - аналитику нужно больше времени на разбор). Но на этапе поведенческого анализа - runtime-мониторинг - язык роли не играет: EDR видит API-вызовы, а не исходный код.

Что Rust реально меняетЧто Rust не меняет
Сигнатурный match с известными семействами (снижает)Поведенческий детект VirtualAlloc/CreateThread
Время ручного triage аналитиком (увеличивает)Kernel-level телеметрию (ETW-TI, minifilters)
Overlap с CRT-паттернами в import table (убирает)Детект sandbox-чеков и anti-debug (T1497.001, T1622)
Точность автоматизированной классификации (снижает)Network-based детект C2-трафика

Вокруг Rust в offensive-комьюнити сложился своего рода культ: пересаживаем лоадеры с C на Rust и ждём, что детект исчезнет. На практике я вижу обратное - команды тратят недели на борьбу с borrow checker вместо того, чтобы потратить это время на исследование детект-механики конкретного EDR в целевой инфраструктуре.

Rust даёт реальный выигрыш ровно в двух точках: снижение сигнатурного совпадения и усложнение triage. Остальное - direct syscalls, sleep obfuscation, ETW patching - реализуется на любом языке с inline assembly. Если агент детектируется по поведению, переписывание на Rust ничего не изменит: VirtualProtect с RWX-правами триггерит одинаковый алерт на C, на Rust и на ассемблере.

Реальная ценность Rust - в возможности написать агент с чистой статикой за разумное время, не утонув в buffer overflow собственного кода. Memory safety - это не про защиту жертвы, это про стабильность вашего импланта на третью неделю операции. Агент, который крашится из-за use-after-free в четыре утра, обходится дороже пары дней на освоение lifetime annotations.

А пока комьюнити спорит о выборе языка, EDR-вендоры наращивают kernel-level телеметрию. И никакой компилятор от этого не спасёт. Если хочешь отработать эту связку руками - на WAPT разбирают цепочку от лоадера до обхода EDR в нескольких модулях с лабами.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab