
Rust крейт для спуфинга стека вызовов ghost-frame, runtime indirect syscalls и APC injection на Windows x64. Предоставляет разрешение SSN, эмиссию JIT-заглушек и шифрование параметров на основе VEH для обхода EDR.
Спуфинг стека вызовов через ghost-frame + runtime indirect syscalls для Windows x64 — портировано на Rust.
Портировано из LACUNA Chain (lacuna_chain.c) авторства Mohamed Alzhrani (0xmaz).
lacuna-rs — это переиспользуемый крейт Rust, предоставляющий те же примитивы, что и оригинальный C TTP, структурированный так, чтобы его можно было подключить к любому проекту на Rust — как wsyscall-rs или syscalls-rs, но с runtime-разрешением SSN, таргетингом syscall;ret для каждой функции и спуфингом стека через ghost-frame.
Функция
stack-spoofбудет молча отказывать, если потребляющий крейт не скомпилирован с frame pointers.
Примитивы затирания стека в chain.rs находят слот адреса возврата вызывающего через inline asm mov rbp, {x}. Это требует целостности цепочки RBP. Rust (и большинство компиляторов в release-режиме) по умолчанию опускают frame pointers.
build.rs устанавливает force-frame-pointers=yes для кодогенерации самого этого крейта, но Cargo не может распространить флаги компилятора на зависимые крейты. Вы должны добавить это в свой собственный проект:```toml
[build] rustflags = ["-C", "force-frame-pointers=yes"]
Без этого `stomp_plant()` будет читать мусор из RBP и либо ничего не сделает (в лучшем случае), либо повредит стек (в худшем случае). У крейта нет способа определить во время выполнения, включены ли указатели кадра — он просто не будет работать.
Если вам нужны только примитивы сканирования, разрешения SSN или инъекции (без подмены стека), вы можете опустить функцию `stack-spoof`, и это требование не применяется.
---
## Что он делает
| Примитив | Функция 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` |
---
## Флаги функций```toml
[dependencies]
lacuna-rs = { version = "0.4", features = ["inject", "stack-spoof", "veh"] }
| Возможность | Описание | Требуются frame pointers? |
|---|---|---|
syscalls (по умолчанию) | Разрешение SSN + генерация JIT-заглушек | Нет |
inject (по умолчанию) | Секционная APC-инъекция (inject::inject_sapc) | Нет |
veh | VEH + шифрование параметров через аппаратные точки останова | Нет |
stack-spoof | Цепочка призрачных кадров LACUNA + stack stomp | Да |
no-std | Режим no_std (экспериментальный) | Нет |
Когда ни одна возможность не включена, доступны только слои сканирования/PE/NT.
cargo run --example scan
### Сборка и проверка цепочки ghost-frame```sh
cargo run --example verify --features stack-spoof
Доказательство внедрения -- полезная нагрузка выполнилась внутри целевого процесса.```sh
cargo run --example inject --features inject,stack-spoof,veh -- <sc.bin>
Добавьте `--verbose`, чтобы включить диагностический вывод VEH (дампы стека, вывод регистров):```sh
cargo run --example inject --features inject,stack-spoof,veh -- <pid> <sc.bin> --verbose
inject_sapc ранжирует потоки цели. Самый сильный сигнал ранжирования —
владеет ли поток окном:
EnumWindows +
GetWindowThreadProcessId. GUI-поток находится в alertable ожидании в своём
цикле обработки сообщений, поэтому поставленный в очередь APC выполняется там
первым. +1000.NtQueryInformationThread(ThreadCycleTime); при
недоступности класса на текущей сборке используется ThreadTimes (см.
examples/tprobe.rs). Нормализуется и добавляется.NtQueryInformationThread(ThreadTimes) — время ядра + пользователя.NtQueryInformationThread(ThreadSuspendCount). Приостановленный
поток не может выполнить APC: −2000 за каждую приостановку.NtQueryInformationThread(ThreadBasicInformation). Приоритет
8–10 даёт +150; приоритет вне диапазона даёт −300.Фильтрация. Приостановленные потоки и потоки, находящиеся в не-alertable
ожидании ядра — WrDelayExecution (Sleep/NtDelayExecution) или WrSuspended,
считываемые из NtQuerySystemInformation(SystemProcessInformation) — отбрасываются
сразу (они никогда не смогут принять APC). В остальном причина ожидания носит
исключительно диагностический характер: она логируется для каждого кандидата
как State: / Reason: / Alert:, но не влияет на оценку, поскольку
KWAIT_REASON не кодирует alertability (alertable и не-alertable ожидание объекта
сообщают одно и то же значение — проверено зондированием в стиле
examples/tprobe.rs).
Кандидаты сортируются (наибольшая оценка, при равенстве — сначала GUI, затем циклы)
и усекаются до MAX_APC_CANDIDATES (10) для перечисления/логирования.
Перечисление ≠ инъекция. Количество перечисленных потоков отделено от количества инъецированных.
MAX_APC_CANDIDATES(10) управляет тем, сколько потоков ранжируется и логируется;APC_INJECT_THREADS(5) управляет тем, сколько фактически получают APC.Почему инъекция в несколько потоков, а затем повтор: пользовательский режим не может определить, какой поток находится в alertable-ожидании, а единственный выбранный поток часто так и не входит в него, поэтому APC никогда бы не сработал. Поэтому
inject_sapcставит APC в очередь для нескольких потоков, а затем повторно ставит в очередь в коротком цикле (~80 мс), пока общее слово-шлюз не переключится с 0 на 1 — то есть пока какой-либо целевой поток действительно не выполнит полезную нагрузку. Он логирует, произошло ли это:payload claimed the gate(выполнено) противgate NOT claimed.Почему выполняется один раз: один и тот же шелл-код/контекст ставится в очередь для каждого потока, поэтому без защиты нереентерабельная полезная нагрузка выполнялась бы параллельно и обрушила бы хост (измерено: 3 одновременных выполнения). Шлюз реентерабельности (
USE_REENTRANCY_GATE, по умолчанию включён) заставляет тело полезной нагрузки выполняться ровно один раз независимо от того, сколько потоков (или сколько раундов повторов) принимают APC (измерено: ровно 1). Оставляйте шлюз включённым, если ваша полезная нагрузка явно не безопасна для параллельного выполнения.
use lacuna::{scan, nt, win::get_module};
let ntdll = get_module(b"ntdll.dll\0");
// Scan for ghost regions let mut ghosts = [scan::Ghost::default(); 512]; let n = scan::scan_ghosts(ntdll, &[b"NtAllocateVirtualMemory\0"], &mut ghosts); println!("{} ghost regions in ntdll", n);