
Rust крейт для спуфинга стека вызовов ghost-frame, runtime indirect syscalls и APC injection на Windows x64. Предоставляет разрешение SSN, эмиссию JIT-заглушек и шифрование параметров на основе VEH для обхода EDR.
Спуфинг стекового кадра-призрака + косвенные системные вызовы во время выполнения для Windows x64 — портировано на Rust.
Портировано из LACUNA Chain (lacuna_chain.c) Мохамеда Альзхрани (0xmaz).
lacuna-rs — это переиспользуемый Rust-крейт, предоставляющий те же примитивы, что и оригинальная TTP на C, структурированный так, чтобы его можно было встроить в любой Rust-проект — как wsyscall-rs или syscalls-rs, но с разрешением SSN во время выполнения, нацеливанием на syscall;ret для каждой функции и спуфингом стекового кадра-призрака.
Функция
stack-spoofбудет молчаливо терпеть неудачу, если потребляющий крейт не скомпилирован с указателями кадров.
Примитивы затирания стека в chain.rs находят слот адреса возврата вызывающей функции через встроенный ассемблер mov rbp, {x}. Это требует, чтобы цепочка RBP была нетронутой. Rust (и большинство компиляторов в режиме релиза) по умолчанию опускают указатели кадров.
build.rs устанавливает force-frame-pointers=yes для собственной кодогенерации этого крейта, но Cargo не может распространять флаги компилятора на нижестоящие крейты. Вы должны добавить это в свой собственный проект:
# .cargo/config.toml (в ВАШЕМ крейте, не в lacuna-rs)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Без этого stomp_plant() прочитает мусор из RBP и либо ничего не сделает (лучший случай), либо повредит стек (худший случай). У крейта нет возможности определить во время выполнения, включены ли указатели кадров — он просто не будет работать.
Если вам нужны только примитивы сканирования, разрешения SSN или внедрения (без спуфинга стека), вы можете опустить функцию stack-spoof, и это требование не применяется.
[dependencies]
lacuna-rs = { version = "0.1", features = ["inject", "stack-spoof", "veh"] }
Если ни одна функция не включена, доступны только уровни сканирования/PE/NT.
cargo run --example scan
cargo run --example verify --features stack-spoof
Пример внедрения с реальной нагрузкой — C2 был офлайн, но шеллкод выполнился
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin>
Добавьте --verbose для включения диагностического вывода VEH (дампы стека, печать регистров):
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
Вместо постановки APC в очередь каждому потоку целевого процесса (что может привести к сбою, когда слишком много потоков одновременно оповещаются), inject_sapc использует алгоритм оценки для выбора лучших MAX_APC_THREADS (5) кандидатов:
NtQueryInformationThread(ThreadCycleTime) — основной индикатор активностиNtQueryInformationThread(ThreadTimes) — время ядра + пользователяNtQueryInformationThread(ThreadSuspendCount) — неприостановленные потоки получают +300 бонуса; приостановленные получают -150 за каждую приостановкуNtQueryInformationThread(ThreadBasicInformation) — потоки с приоритетом 8-10 получают +150 бонуса; приоритеты вне диапазона получают -100 штрафаПолностью бездействующие потоки (нулевые циклы и нулевое время ЦП) полностью пропускаются. Оставшиеся кандидаты сортируются по баллам (наивысший первый, при равенстве по циклам) и усекаются до 5 верхних.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// Сканирование областей-призраков
let mut ghosts = [scan::Ghost::default(); 512];
let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts);
println!("{} ghost regions in ntdll", n);
// Разрешение SSN + syscall;ret для конкретной функции
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtOpenProcess\0");
println!("NtOpenProcess: ssn={:#x}, syscall;ret={:#x}", ssn, syscall_ret);
use lacuna::{nt, stub, win::{get_module, HMODULE}};
let ntdll: HMODULE = get_module(b"ntdll.dll\0");
// Разрешение SSN + адреса собственного syscall;ret функции
let (ssn, syscall_ret) = nt::resolve(ntdll, b"NtAllocateVirtualMemory\0");
assert!(ssn != nt::SSN_INVALID && syscall_ret != 0);
// JIT-эмиссия заглушки: mov r10,rcx; mov eax,SSN; jmp [syscall;ret]
// (или jmp [ghost_gadget] -> JMP [RBX] -> syscall;ret, если была вызвана build_chain)
let stub = stub::make_stub(ssn, syscall_ret).expect("не удалось выделить заглушку");
// Приведение к соответствующему типу указателя на функцию и вызов
let alloc_vm: unsafe extern "system" fn(
win::HANDLE, *mut win::PVOID, usize, *mut usize, win::ULONG, win::ULONG,
) -> win::NTSTATUS = unsafe { core::mem::transmute(stub.as_fn()) };
// Сканирование ntdll/kernelbase/wow64/win32u на предмет областей-призраков и построение
// шестиуровневого поддельного стека вызовов. Устанавливает G_GHOST_GADGET, чтобы заглушки
// маршрутизировались через JMP [RBX] в подписанной DLL.
lacuna::chain::build_chain();
// Регистрация обработчиков VEH (шифрование параметров + защита цепочки)
let _veh = lacuna::veh::VehGuard::register().expect("Не удалось зарегистрировать VEH");
// Опционально: включение подробной диагностики во время выполнения
lacuna::veh::set_verbose(true);
use lacuna::win::HANDLE;
use core::ptr;
let mut h_proc: HANDLE = ptr::null_mut();
let key: u64 = 0xCAFE_1337;
// Размещение кадров-призраков (замена адресов возврата на призраки из подписанных DLL)
lacuna::chain::stomp_plant();
// Установка DR0 на syscall;ret — VEH будет XOR-расшифровывать параметры на границе
lacuna::veh::pcrypt_arm(key, syscall_ret, true);
// Вызов через косвенную заглушку — RIP оказывается внутри ntdll на входе в ядро
let _status = unsafe { open_proc(/* XOR-зашифрованные параметры */) };
// Всегда снимайте защиту перед любым незащищённым вызовом
lacuna::veh::pcrypt_disarm();
lacuna::chain::stomp_restore();
Не каждый вызов API требует полной обработки LACUNA. Ключевой принцип: прячьте те вызовы, которые EDR может связать с инъекцией или активностью после эксплуатации.
Это «жемчужины» NT-системных вызовов, которые EDR хукают и коррелируют:
Для каждого из них:
nt::resolve(ntdll, b"NtXxx\0")stub::make_stub(ssn, syscall_ret)veh::pcrypt_arm(key, syscall_ret, true)chain::stomp_plant() и chain::stomp_restore()nt::resolve(ntdll, b"NtXxx\0") — возвращает (SSN, syscall_ret_VA).stub::make_stub(ssn, syscall_ret) — возвращает вызываемый указатель на функцию.extern "system" fn(...) через core::mem::transmute.chain::build_chain() и вызывайте chain::stomp_plant() / chain::stomp_restore() вокруг вызова.veh::pcrypt_arm(key, syscall_ret, true) перед вызовом и veh::pcrypt_disarm() после него.Заглушка автоматически обрабатывает последовательность mov r10, rcx / mov eax, SSN / jmp [syscall;ret]. Если был зарегистрирован гаджет-призрак, заглушка маршрутизируется через JMP [RBX] для двойного перенаправления выполнения + мостового кадра без артефактов.
Все 9 ключевых вкладов из статьи о LACUNA Chain представлены в коде:
syscalls-rs?syscalls-rs и wsyscall-rs генерируют таблицу SSN во время сборки для конкретной сборки ntdll Windows. Если на целевой машине другая сборка, SSN будут неверными, и системные вызовы будут терпеть неудачу (или вызывать эвристики EDR).
lacuna-rs разрешает SSN во время выполнения, напрямую читая заглушки ntdll, и нацеливается на собственную инструкцию syscall;ret функции, так что RIP оказывается внутри ntdll на входе в ядро — обходя эвристики EDR «несоответствие SSN» и «косвенный системный вызов из нерезервной памяти».
Кроме того, lacuna-rs предоставляет цепочку спуфинга стекового кадра-призрака, которой нет в syscalls-rs.
x86_64-pc-windows-msvc (или x86_64-pc-windows-gnu)litcrypt2 (обфускация строк на этапе компиляции)stack-spoof)Функция stack-spoof требует указатели кадров. build.rs автоматически устанавливает force-frame-pointers=yes для этого крейта, но потребляющие крейты также должны установить его в своём .cargo/config.toml:
# .cargo/config.toml (в потребляющем крейте)
[build]
rustflags = ["-C", "force-frame-pointers=yes"]
Все строковые литералы в крейте и примерах обёрнуты в макросы lc!(), которые шифруют их на этапе компиляции и расшифровывают во время выполнения. Ключ шифрования читается из переменной окружения LITCRYPT_ENCRYPT_KEY; если она не установлена, litcrypt2 автоматически генерирует случайный ключ.
Чтобы зафиксировать ключ для воспроизводимых сборок:
set LITCRYPT_ENCRYPT_KEY=ваш-секретный-ключ
cargo build
lacuna-rs/
├── Cargo.toml
├── build.rs # Устанавливает force-frame-pointers для stack-spoof
├── src/
│ ├── lib.rs # Корень крейта + реэкспорт + scan_all() + настройка litcrypt2
│ ├── win.rs # Привязки FFI Win32/NT (совместимо с no_std)
│ ├── pe.rs # Парсинг PE-секций и экспорта
│ ├── scan.rs # Сканирование областей-призраков .pdata + гаджетов
│ ├── nt.rs # Разрешение SSN + нацеливание syscall;ret
│ ├── stub.rs # JIT-эмиссия заглушки косвенного системного вызова
│ ├── veh.rs # VEH + шифрование параметров аппаратной точкой останова
│ ├── chain.rs # Цепочка кадров-призраков LACUNA + затирание
│ └── inject.rs # Внедрение APC на основе секций
├── examples/
│ ├── scan.rs # lacuna.exe scan
│ ├── verify.rs # lacuna.exe verify
│ └── inject.rs # lacuna.exe inject <pid> <sc.bin>
└── .cargo/
└── config.toml # force-frame-pointers + цель windows-msvc
В этом проекте активно участвовал AI-чат-бот. У меня была возможность протестировать более новую, очень способную модель с открытым исходным кодом (GLM-5.2 от Z.ai (Zhipu AI)), и я хотел дать ей сложную задачу. Выводы: она работала хорошо, если человек знал, как должны выглядеть результаты. Если нет... только ерунда.
MIT
| Примитив | C-функция | Модуль Rust |
|---|
| Парсинг PE-секций и экспорта | pe_section(), pe_export() | pe |
Сканирование областей-призраков .pdata | scan_ghosts(), best_ghost() | scan |
Обнаружение гаджетов-призраков (jmp [rbx]) | scan_ghost_gadgets() | scan |
Поиск NOP-пробелов в win32u | win32u_nop_gap() | scan |
| Поиск якоря BYOUD-MF | find_mf_target() | scan |
| Разрешение SSN (Hell's Gate / Halo's Gate) | resolve_ssn() | nt |
Локатор syscall;ret для каждой функции | find_func_syscall() | nt |
| JIT-эмиссия заглушки косвенного системного вызова | alloc_stub() | stub |
| Перенаправление заглушки через гаджет-призрак | (в alloc_stub()) | stub |
| VEH + шифрование параметров аппаратной точкой останова | param_encrypt_veh(), pcrypt_arm() | veh |
| VEH-защита цепочки | chain_veh() | veh |
| Построение цепочки LACUNA | build_chain() | chain |
| Затирание стека (BYOUD-RT) | stomp_plant(), stomp_restore() | chain |
| Обходчик цепочки (проверка) | lacuna_walk_chain() | chain |
| Внедрение APC на основе секций | do_inject_sapc() | inject |
| Функция | Описание | Требует указатели кадров? |
|---|
syscalls (по умолчанию) | Разрешение SSN + JIT-эмиссия заглушек | Нет |
inject (по умолчанию) | Внедрение APC на основе секций (inject::inject_sapc) | Нет |
veh | VEH + шифрование параметров аппаратной точкой останова | Нет |
stack-spoof | Цепочка кадров-призраков LACUNA + затирание стека | Да |
no-std | Режим no_std (экспериментально) | Нет |
| Вызов | Почему чувствителен |
|---|
NtOpenProcess | Открывает дескриптор другого процесса — первый шаг инъекции |
NtCreateSection + NtMapViewOfSection (удалённо) | Признак инъекции на основе секций |
NtWriteVirtualMemory | Запись между процессами — классический индикатор инъекции |
NtCreateThreadEx | Создание удалённого потока — примитив инъекции с самым высоким сигналом |
NtQueueApcThread | APC-инъекция — высокий сигнал |
NtProtectVirtualMemory | Изменение прав на RWX — индикатор подготовки шеллкода |
NtAllocateVirtualMemory (удалённо) | Удалённое выделение — прелюдия к инъекции |
NtSetInformationThread | Сокрытие потока (HideFromDebugger) — индикатор сокрытия |
| Вызов | Почему безопасен |
|---|
NtQueryInformationThread | Только запрос, редко хукается, нет межпроцессной записи |
NtDelayExecution | Сон — безвреден, используется каждым приложением |
NtClose | Закрытие дескриптора — безвредно, крайне распространено |
GetModuleHandleA / GetProcAddress | Разрешение модуля — не системный вызов, не хукается пользовательскими хуками EDR |
CreateToolhelp32Snapshot / Thread32First / Thread32Next | Перечисление потоков — kernel32, не syscall ntdll |
OpenThread / CloseHandle | Стандартные операции с дескрипторами — kernel32 |
GetThreadContext / SetThreadContext | Необходимы для DR0 — kernel32, только для своего процесса |
lc!() — крейт это требует, а диагностика VEH скрыта за set_verbose(true), которое по умолчанию выключено.| # | Концепция статьи | Расположение в коде | Описание |
|---|
| 1 | BYOUD-Gap (нулевая модификация .pdata) | chain.rs — построение цепочки кадров-призраков | Эксплуатирует промежутки между записями RUNTIME_FUNCTION; обработчик размотки считает их листовыми кадрами (RSP += 8) |
| 2 | ETW-Ti APC Window Attack | inject.rs — NtDelayExecution прерываемый слив | Контролирует момент срабатывания снимка стека APC ETW-Ti, манипулируя прерываемым состоянием потока |
| 3 | Шифрование параметров в контексте BYOUD | veh.rs — pcrypt_arm(), param_encrypt_veh() | XOR-шифрование параметров системного вызова на этапе подготовки; расшифровка внутри VEH аппаратной точки останова на инструкции syscall |
| 4 | Цепочка NOP-пробелов Win32u + гаджет-призрак | scan.rs — win32u_nop_gap(), scan_ghost_gadgets() | 1242 NOP-пробела в win32u.dll предоставляют легитимные листовые кадры; гаджет-призрак JMP [RBX] по адресу ntdll+0xFC47B |
| 5 | Семантическая близость призраков kernelbase | chain.rs — слой L2_kbase рядом с VirtualProtect | Призрак длиной 238 байт, заканчивающийся на точке входа VirtualProtect — неотличим от реального места возврата VP |
| 6 | BYOUD-MF (Телепорт RSP машинного кадра) | chain.rs — структура MachFrame, scan.rs — find_mf_target() | Эксплуатирует UWOP_PUSH_MACHFRAME (код операции 10) в диспетчерах KiUser* для произвольного телепорта RSP в одном кадре |
| 7 | BYOUD-RT (Вычисление RSP во время выполнения) | chain.rs — teb_stack_base(), teb_stack_limit() | Читает TEB.StackBase (GS:[0x08]) во время вызова для точного вычисления расстояния кадра — предварительная калибровка не нужна |
| 8 | Близость призраков wow64.dll | chain.rs — слой L1_wow64, scan.rs — цели Wow64PrepareForException | Призрак длиной 91 байт, заканчивающийся на входе Wow64PrepareForException — четвёртый семантический слой цепочки |
| 9 | Шестиуровневая цепочка LACUNA | chain.rs — структура LacunaStack (L1-L5 + MachFrame) | Полная цепочка: KiUserExceptionDispatcher -> wow64 -> kernelbase -> ntdll -> win32u -> RtlUserThreadStart |
| Уровень обнаружения | Статус | Реализация |
|---|
| Проверка модуля происхождения | ОБХОД | Все кадры в ntdll / kernelbase / wow64 / win32u |
| Корректность обхода размотки | ОБХОД | Все кадры lacuna листовые -> корректно RSP+8 |
Криминалистическое сканирование .pdata | ОБХОД | Нулевая модификация; пробелы уже существуют |
| Теневая стопка CET | ОБХОД | Чисто листовая цепочка; теневая стопка не используется |
| Семантический анализ кадров | ОБХОД | WoW64 исключение + соседство VirtualProtect |
| Исключение из правил Win32u | ОБХОД | Слой 4 явно исключён всеми правилами |
| ETW-Ti STACKWALK | ОБХОД | Атака через окно APC контролирует момент снимка |
| Проверка параметров | ОБХОД | Расшифровка через VEH аппаратной точки останова |
| Callbacks ядра | ЧАСТИЧНО | Операции с дескрипторами всё ещё вызывают ObRegisterCallbacks |