Гонка, которую пробегает одна команда
Сначала — стек, потом трюк. На зажёсткоченном хосте одна команда PowerShell пересекает до шести записывающих слоёв — и у слоёв нет общей модели отказа, так что победа над одним не даёт ничего с остальными:
| Слой | Что он записывает |
|---|---|
| Создание процесса 4688 / Sysmon 1 | Рождение powershell.exe: командная строка, родитель, хеш, подписант |
| AMSI | Содержимое скрипта, просканированное в любом процессе-хосте — до исполнения |
| ETW · блоки скриптов 4104 | Каждый блок скрипта как исполненный — деобфусцированный; обфускация умирает здесь |
| ETW · логирование модулей 4103 | Детали исполнения конвейера, по каждому модулю |
| Транскрипция | Полные транскрипты сессии, записанные в файлы на диске |
| Загрузки сборок | Прибытие clr.dll, mscoree, System.Management.Automation в процесс — видят и CLR ETW, и ядерные колбэки image-load |
Управляемый 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, и детали — это и есть продукт:
; 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
Зачем запись через 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 КБ, выдача по требованию; ключи затираются, заголовок стомплен |
Честные пределы
Окно патча — 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-библиотеку, захощенную в-процессе, затёртую при выходе.