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

Шелл, которого не было.

Недавний публичный разбор показывает управляемый ответ стеку защиты PowerShell: переключить два внутренних поля через рефлексию, захостить собственный runspace, наслаждаться Full Language Mode. Изложение чистое — и счёт к нему чистый. Вы и есть новый процесс. CLR грузится при вашем рождении. А поля, которые вы переключаете, сами стали сигнатурами — собственный раздел защиты того разбора перечисляет их как цели для охоты. Phantom Shell, in-process шелл внутри модуля iShell от AXIS, — наш ответ тому же стеку. Он уходит на несколько слоёв глубже — и большую часть времени не делает вообще ничего. Эта заметка проходит всю конструкцию слой за слоем и заканчивается тем, что защитникам стоит скопировать.

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

Гонка, которую пробегает одна команда

Сначала — стек, потом трюк. На зажёсткоченном хосте одна команда PowerShell пересекает до шести записывающих слоёв — и у слоёв нет общей модели отказа, так что победа над одним не даёт ничего с остальными:

СлойЧто он записывает
Создание процесса 4688 / Sysmon 1Рождение powershell.exe: командная строка, родитель, хеш, подписант
AMSIСодержимое скрипта, просканированное в любом процессе-хосте — до исполнения
ETW · блоки скриптов 4104Каждый блок скрипта как исполненный — деобфусцированный; обфускация умирает здесь
ETW · логирование модулей 4103Детали исполнения конвейера, по каждому модулю
ТранскрипцияПолные транскрипты сессии, записанные в файлы на диске
Загрузки сборокПрибытие clr.dll, mscoree, System.Management.Automation в процесс — видят и CLR ETW, и ядерные колбэки image-load
Рис. A — стекШесть слоёв, шесть независимых моделей отказа

Управляемый runspace-бинарь отвечает слоям два и три и обходит constrained language mode — достойно. Но отвечает он изнутри процесса, который только что сработал на слое один, CLR зажёг слой шесть при его рождении (.NET-бинарь управляем с первой инструкции), а слои четыре и пять не тронуты вообще. Логирование групповой политики — это не ETW; транскрипция — это файлы на диске. Управляемый ответ покидает здание с двумя отключёнными сиренами и четырьмя орущими.

Слой 0: не существовать

Самый тихий обход — код, который не исполняется. Первый слой Phantom — не техника, а набор команд: 116 встроенных команд и алиасов (dir, cd, type, ps, whoami — повседневный словарь оператора), исполняемых самим агентом, в его собственном процессе, с токен-контекстом, который вы задали. Ни дочернего процесса. Ни CLR. Пяти из шести слоёв просто не от чего проснуться. В консоли эти сессии подсвечены фиолетовым — оператор всегда знает, на каком слое стоит, — но для хоста неделя работы в пределах билтинов неотличима от того, что агент вообще ничего не делал.

Нельзя залогировать процесс, который не был создан, — поэтому мы начинаем с этого.

И сам движок поставляется как on-demand модуль ~164 КБ (модель депо из заметки N.04): пока вы не заташковали интерактивный шелл, кода всего этого на хосте вообще нет.

Слой 1: рантайм, который приходит поздно

Когда команде действительно нужен PowerShell, Phantom хостит постоянный runspace в своём процессе — но лениво: CLR инициализируется на первой команде, которой он реально нужен, а не при старте сессии. Сессия, не выходившая за билтины, производит ноль телеметрии ClassifiedModuleLoad, ноль событий загрузки сборок, ноль присутствия CLR. Хостинг идёт через сырые смещения COM vtable и позднее связывание IDispatch — без Add-Type, без powershell.exe, ничего на диске и ноль байт к весу агента, потому что .NET Framework уже стоит на хосте, на котором вы находитесь. Сравните с управляемым бинарём, который платит всю эту цену при рождении: CLR — не то, что он загружает, а то, чем он является.

Слой 2: ворота

С резидентным runspace остаётся проблема инспекции содержимого — и здесь Phantom Gate, механизм взведения/снятия на каждую команду, оправдывает имя. Он касается четырёх нативных точек входа: AmsiScanBuffer и AmsiScanString (amsi.dll), EtwEventWrite и NtTraceEvent (ntdll). Стратегия — return-clean, и детали — это и есть продукт:

СТАБЫ RETURN-CLEAN — x64, КАК СОБРАНОна команду
; AmsiScanBuffer — 20 байт поверх точки входа
mov  rax, [rsp+0x30]   ; out-param вызывающего (слот вердикта)
test rax, rax
jz   skip
mov  dword [rax], 0    ; AMSI_RESULT_CLEAN — через их указатель
skip:
xor  eax, eax          ; S_OK
ret                    ; скан «успешен». ничего не сканировалось.

; EtwEventWrite / NtTraceEvent — 3 байта
xor  eax, eax          ; STATUS_SUCCESS
ret
Рис. B — стабыПроверено дизассемблером; x86-варианты отличаются слотом стека

Зачем запись через out-param? Потому что наивный обход — вернуть E_INVALIDARG из хука — это сгоревшая эвристика: движки матчат возврат ошибки как сигнатуру подделанного AMSI. Скан, который успешен и докладывает чисто, неотличим от скана, который состоялся. Хук не отказывает в сканировании; он сдаёт его на отлично.

Ещё три свойства важнее байтов. Патч транзиентен: ставится непрямыми сисколлами (NtProtectVirtualMemory + memcpy — без API-телеметрии VirtualProtect/WriteProcessMemory), исходная защита возвращается немедленно, так что изменённый код исполняется под RX, а исходные байты восстанавливаются при снятии. Окно — время жизни команды, обычно миллисекунды, а не процесса. Он процесс-глобален: конвейерные и job-потоки покрыты автоматически, там где дизайн на аппаратных брейкпоинтах привязан к потоку и Start-Job проходил мимо v1. И он обрабатывает загрузку посреди окна: amsi.dll часто не замаплена до инициализации CLR — колбэк LdrDllNotification патчит её по прибытии, закрывая гонку первого скана, которую аппаратные брейкпоинты молча проигрывали. Если непрямые сисколлы недоступны, ворота откатываются на VEH + аппаратные брейкпоинты вызывающего потока (DR0–DR3), а собственные отладочные регистры внесены в белый список анти-отладочных проверок агента, чтобы не сработать на свои же провода.

Слой, который разборы пропускают: кэш политики

Логирование блоков скриптов, модулей и транскрипция управляются групповой политикой — и движок кэширует эти настройки в статическом поле при старте. Ослепление ETW кэш не чистит: движок всё равно форматирует каждое событие и пытается его испустить, а транскрипция всё равно открывает свои файлы. Поэтому сразу после Runspace.Open() Phantom обнуляет закэшированные флаги Enable* — 4104, 4103, транскрипция — через ту же COM-рефлексию, которой захощен сам runspace. Ни один скрипт для этого не исполняется. Скрипт, отключающий логирование, — это скрипт, который залогирован; обход обязан быть в хосте, ниже движка, иначе он свидетельствует против себя сам.

Слой 3: когда процесс приходится одолжить

Некоторые команды не могут исполниться в-процессе — им нужно своё адресное пространство или другая сессия. Последний резерв Phantom — WMI: ребёнок порождается под wmiprvse.exe — системный процесс делает то, чем системные процессы занимаются, — а вывод возвращается через анонимный именованный канал \\.\pipe\<GUID>, а не временный файл. Ни артефактов на диске, случайные имена каналов, потоковое чтение, чтобы дедлок 64-КБ буфера канала не заклинил сессию. Третий слой честен о своей цене: это и есть создание процесса. Атрибутированное бинарю, который создаёт процессы профессионально.

Когда движок сопротивляется

Враждебный или просто взбесившийся скрипт может убить своего хоста — переполнение стека внутри Pipeline.Invoke как раз тот класс отказа, который забирает весь процесс, а с ним и вашу сессию. Runspace работает внутри аварийного каркаса: векторный обработчик плюс пара setjmp/longjmp вокруг вызова. Отказ разматывается в безопасность, runspace помечается сломанным, и исполнение до конца сессии откатывается на WMI-слой. Оператор видит строку ошибки. Агент держит сессию — отказ возвращает ошибку, никогда сессию — та же доктрина, что и у рантайма BOF.

Бок о бок

Управляемый runspace-бинарьPhantom Shell
Процесс созданСам собой — он и есть новый процессНет, слои 0–2; ребёнок WMI лишь когда неизбежно
Загрузка CLRПри рождении, безусловноЛениво — первая команда, которой реально нужно
AMSIПереключение управляемого поля, на жизнь процессаНативные стабы return-clean, окно на команду
ETWПереключение управляемого поляEtwEventWrite + NtTraceEvent возвращают успех, транзиентно
4104 / 4103 / транскрипцияНе адресованоКэш политики обнулён COM-рефлексией — скрипт не исполняется
Аварийная защитаОтказ забирает процессVEH + longjmp; runspace сломан, откат на WMI
Лестница откатаНетБилтины → CLR runspace → именованный канал WMI
След на хостеБинарь на дискеМодуль ~164 КБ, выдача по требованию; ключи затираются, заголовок стомплен
Рис. C — счёт, сравнённыйОба отвечают стеку; один отвечает всем шести слоям

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

Окно патча — fail-open: если процесс умрёт посреди команды, стабы останутся до выхода. Окно — миллисекунды, и падение само по себе будет историей, — но свойство реально, и плановые сверки целостности могут поймать остаток. Третий слой — создание процесса: атрибутировано, но не невидимо; аналитика родословной увидит wmiprvse.exe с консольным ребёнком. Билтины — конечное множество: как только работа становится интересной, CLR грузится, — а CLR, возникающий в процессе без управляемых дел, сам по себе сигнал, если кто-то коррелирует загрузки сборок; ленивая загрузка сужает окно, но не стирает его. И ворота слепят инспекцию содержимого, а не последствия: что вы запустили, то и исполняется, и громкий скрипт громок, даже когда никто не сканирует. Дисциплина оператора — не фича, которую мы можем отгрузить.

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

Всё вышесказанное — карта для охоты, и мы это имеем в виду как подарок. Хешируйте .text amsi.dll и ntdll в чувствительных процессах — патч транзиентен, но сверка, попавшая в окно или на fail-open остаток, — это приговор. Считайте отсутствие телеметрией: хост, обычно испускающий 4104-е, внезапно замолкает, а интерактивная активность продолжается, — это правило корреляции, ждущее написания. Следите за загрузками образов: clr.dll/mscoree, прибывающие в процессы без управляемых дел. Тревожьтесь на WmiPrvSE.exe, порождающий шеллы, и на каналы с GUID-именами. И держите сигнатуры на переключение управляемых полей — публичные инструменты, вдохновившие эту заметку, паттерн-сканируемы, и эти правила поймают подражателей даже там, где не поймают нас. Как всегда: предпочитаем быть пойманными хорошим правилом, чем услышанными из-за плохого прилагательного.

Следующая заметка: Mimikatz — магнит. Мы используем библиотеку. — DCSync, Kerberoast, ADCS и shadow credentials через защитническую .NET-библиотеку, захощенную в-процессе, затёртую при выходе.