Исследовательская заметка N.08 · 2026-10-15

Что выносит приговор инжектору? Шесть приманок, два контроля, один ответ.

Спросите десять инженеров, за что EDR убивает инъекцию, — получите десять уверенных фольклоров. Нам для GhostCrypt — нашего полиморфного in-memory криптора — был нужен настоящий ответ, поэтому мы сделали немодную вещь: контролируемый эксперимент. Шесть программ-приманок, два контроля, зажёсткоченная машина с SentinelOne + Defender, каждая строка воспроизведена. Приговор переворачивает фольклор.

ВРЕМЯ ЧТЕНИЯ ≈ 7 МИН АВТОР КОМАНДА AXIS

GhostCrypt — один из инструментов сборки в тулчейне AXIS: дайте ему любой нативный x64-исполняемый файл Windows, и он выдаст уникальный на каждый билд бинарь, который несёт пейлоад зашифрованным, расшифровывает его в памяти и запускает там же — в открытом виде файл никогда не касается диска. Каждый билд отличается: свежий ключ ChaCha-20, разбитый на три-шесть случайных XOR-долей, свежий сид для хешей имён API (чтобы константы хешей не становились IOC), свежие мусорные функции, свежее имя секции, благопристойный версионный ресурс. Статический слой этой задачи честно близок к решённому — уникальные байты, пейлоада на диске нет, таблица импорта читается как калькулятор. Трудный вопрос был поведенческим: что современный EDR на самом деле наказывает?

Сначала — обмануть эмулятор

Прежде чем поведенческий вопрос вообще имеет значение, образец должен пережить статико-эмуляционный слой — и именно на эмуляции умирает большинство пакеров. Defender и подобные движки исполняют неизвестные бинари в синтетическом CPU, ожидая, пока расшифрованный пейлоад материализуется в эмулируемой памяти, где сканер его увидит. Ответ GhostCrypt — часть инструмента, которой мы гордимся больше всего, потому что она превращает бюджет эмулятора в оружие против него самого: финальный ключ расшифровки не существует нигде в бинаре. Он пересчитывается при старте свёрткой proof-of-work в расписание ключа.

POW-СВЁРТКА КЛЮЧА — КАТЕГОРИЙНОна билд
key_eff = key(из 3–6 XOR-долей) XOR sha256-цепочка(seed, iters)

# iters: случайно на билд, 500 000–1 200 000
# реальный CPU:  ~0.2–0.5 с, один раз, при старте
# эмулятор: бюджет инструкций иссякает задолго до
#           конца цепочки — так что открытый текст
#           внутри эмулированной машины не возникает вообще
Рис. A — свёрткаБилдер пересчитывает цепочку и сверяет побайтово

Экономика жестока и асимметрична. Полсекунды работы реального CPU невидимы для пользователя; бюджет инструкций эмулятора на порядки меньше нужного для завершения цепочки, поэтому эмуляция обрывается до того, как хоть один байт открытого текста возникнет там, куда смотрит сканер. Остаётся статическое сканирование — под которое полиморфный стаб с благопристойными импортами и низкоэнтропийным хранением и построен. Билдер пересчитывает идентичную цепочку и сравнивает её с тем, что вычислит стаб, побайтово, — билд либо сворачивается правильно, либо не выпускается.

Эксперимент: одна переменная на приманку

Со статическим слоем разобрались — и мы построили серию приманок для допроса поведенческого слоя. Каждая программа-приманка безвредна — пишет файл-доказательство и выходит, — но несёт ровно один паттерн инъекции, так что серия изолирует переменные вместо их смешения. Ещё два образца послужили контролями. Всё прогонялось вживую на Windows 11 24H2 с SentinelOne и Defender, выкрученным на максимум (высокий облачный блок, образцы не уходят в облако вендора), с дифом событий детекта Defender на каждый прогон и проверкой консоли S1 на предмет приговоров со стороны управления. Каждая строка воспроизводилась многократно.

ОбразецЧто изолируетПриговор
Самоинъекция приманкаУпакованный пейлоад в собственном процессе — даже с RWX + CreateThreadЧИСТО
v1 — классическая цепочкаRWX + CreateRemoteThread в приостановленного ребёнка, максимум поверхности ETW-TIОСУЖДЁН
v2 — early-bird APCДисциплина W^X, без RWX, без CreateRemoteThread — но всё ещё APC в ребёнкаОСУЖДЁН
v3 — разделяемая секцияНоль WriteProcessMemory, ноль VirtualProtectEx — локальный RW-маппинг + удалённый RX, снова APCОСУЖДЁН
v4 — в стиле hollowingПеренаправление собственного потока ребёнка через Get/SetThreadContext — без CRT, без APCОСУЖДЁН
v5 — молчаливый ребёнокПеренаправленный поток крутит чистый цикл копирования байт: ноль сисколлов, вообщеОСУЖДЁН
v6 — стомпКод записан поверх собственного подписанного .text ребёнка, вход перенаправленЧИСТО
контроль 1Стейджинг идентичный v2 (аллокация, запись, перевод в RX у ребёнка) — но никогда не исполняетсяЧИСТО
контроль 2CreateRemoteThread с подписанной LoadLibraryA на легитимной user32.dll — вообще без чужого кодаОСУЖДЁН
Рис. B — доскаWin11 24H2 · SentinelOne + Defender (максимум) · каждая строка воспроизведена

Три вывода, и ни одного из фольклора

Первый: приговор основан на исполнении, а не на поведении. Молчаливый ребёнок — строка, которая должна закрыть споры, — перенаправленный поток, исполняющий цикл копирования байт буквально с нулём сисколлов, всё равно сгорел. Что бы движок ни инспектировал, это было не поведение кода. Между тем контроль один, выполняющий каждый подозрительный шаг стейджинга, но никогда не исполняющийся, вышел сухим.

Сенсору всё равно, что ваш код делает. Ему важно, где ваш код живёт.

Второй: скан ключуется на приватных неподписанных страницах. Каждый осуждённый вариант исполнялся из свежевыделенной приватной памяти. Единственный выживший — v6 — исполнялся из собственного подписанного .text ребёнка, с байтами, застомпленными поверх copy-on-write страниц образа. Код, работающий из региона, подкреплённого образом, — даже модифицированного — просто не входил в сканируемое множество.

Третий: стейджинг бесплатен; осуждает только исполнение. Аллокация, запись, смена защит — ничто не зарегистрировалось. Даже контроль два поучителен наоборот: вызов CreateRemoteThread погиб исполняя собственный подписанный код библиотеки Microsoft, с нулём чужих байт. Сам API-паттерн — старт удалённого потока — и есть то, что сенсор наказывает, независимо от того, что поток исполняет.

Что выжило — очертания

Выживший рецепт, на категорийном уровне: создать ребёнка приостановленным, найти его образ в памяти, записать свой код поверх его собственного .text, направить уже существующий поток на него через SetThreadContext, возобновить. Ноль новых страниц, ноль удалённых аллокаций, ноль смен защит, ноль вызовов создания потоков. После: восстановить исходные байты, держать видимое поведение ребёнка минимальным, завершить чисто. Одна производственная заметка, доставшаяся трудным путём: на CREATE_SUSPENDED замаплены только EXE и ntdll, так что главный модуль — единственная гарантированная мишень стомпа; ваша любимая DLL ещё не существует.

Рис. C — что выжилоКаждый осуждённый шаг убран по одному, затем проверено

Честные пределы

Доктрина, поставляемая с инструментом, говорит это прямо, потому что правило нашей лаборатории применимо к каждому утверждению на этой странице: результаты прибиты к движкам и ко дню теста, а собственное поведение пейлоада после исполнения — сеть, реестр, закрепление — вне контроля загрузчика, и именно его продвинутые EDR оценивают дальше. GhostCrypt отказывает .NET-сборкам на этапе сборки (хостинг CLR — второй майлстоун), а его раздел OPSEC начинается с двух правил, важнее которых нет: никогда не загружай образец в мульти-сканер — отправки сжигают билд в сигнатуры за считанные дни — и никогда не переиспользуй один упакованный бинарь на многих целях. Переиспользование — то, что загоняет билды в сигнатуры; уникальность на цель и есть смысл инструмента.

Угол защитника

Этот эксперимент — подарок другой стороне, и мы публикуем его, зная это. Если приговор ключуется на исполнении из приватных неподписанных страниц, то аудит VAD — а не сопоставление последовательностей API — и есть высокоточный сигнал: тревога, когда указатель инструкции потока приземляется в память образа, ушедшую в copy-on-write, или в любой исполняемый приватный регион. У выжившего паттерна всё ещё есть швы: процесс, созданный приостановленным и не делающий ничего видимого; SetThreadContext, переносящий RIP ребёнка в его собственный .text; страницы образа, перевернувшиеся в COW. Каждый тихий; скоррелированные — это история. И PoW-свёртка несёт урок для проектирования эмуляторов: фиксированный бюджет инструкций — стена, которую атакующий просто обходит, — бюджетьте по времени и сисколлам, а не по инструкциям, или примите, что эмуляция видит лишь артефакты, которые хотят быть увиденными. Мы предпочитаем, чтобы вы построили это правило, а не читали, что мы сказали «недетектируемо». Мы этого не говорили.

Следующая заметка: Шелл, которого не было — Phantom Shell, слой за слоем: билтины, которые не создают ничего, ленивый runspace, стабы return-clean с временем жизни одной команды и кэш политики, о котором никто не говорит.