Start Debugging

Что такое флаг W^X в .NET и нужен ли он Native AOT?

W^X (write xor execute) - правило, по которому ни одна страница памяти не бывает одновременно записываемой и исполняемой. В .NET это переключатель DOTNET_EnableWriteXorExecute, включённый по умолчанию с .NET 7, и существует он исключительно ради JIT. Native AOT его никогда не читает. Разбираем, как среда выполнения его реализует, во что он обходится и когда его отключение - законное решение.

W^X (“write xor execute”) - это политика защиты памяти: любая страница памяти может быть либо записываемой, либо исполняемой, но никогда одновременно. В .NET она предоставляется как переключатель DOTNET_EnableWriteXorExecute, и его значение по умолчанию равно 1, начиная с .NET 7. Предпосылка, скрытая в обычной формулировке этого вопроса, перевёрнута, поэтому исправим её сразу: Native AOT не нуждается во флаге W^X и не читает его. Флаг настраивает аллокатор исполняемой памяти в CoreCLR, который существует ради JIT. У Native AOT нет ни JIT, ни аллокатора исполняемой памяти. Настоящая связь идёт в обратную сторону: платформы, которые навязывают W^X без исключений (iOS, tvOS), делают JIT-компиляцию невозможной, и Native AOT - это ответ на такое ограничение, а не потребитель флага.

Всё изложенное ниже нацелено на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11, но механика стабильна с .NET 7. Там, где поведение зависит от конкретной версии, я это отмечаю.

Почему страница, одновременно записываемая и исполняемая, - это проблема

У классического эксплойта на повреждении памяти две половины: доставить в процесс байты, контролируемые атакующим, и затем заставить процессор перейти на них. Если каждая страница процесса либо записываемая, либо исполняемая, вторая половина перестаёт работать. Записанные байты лежат на странице, которую процессор отказывается исполнять, а страницы, которые процессор исполнит, недоступны для записи. Политика вышла из OpenBSD в 2003 году и сегодня является обязательным минимумом: в Windows её версия называется DEP, Linux опирается на бит NX и права страниц, выставленные загрузчиком, а Apple silicon навязывает её на уровне ядра для каждого процесса.

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

Почему JIT - неудобный случай

JIT-компилятор записывает байты машинного кода в память, а затем вызывает их. Наивная реализация выделяет страницу RWX, пишет в неё и переходит на неё. Это ровно та схема, которую W^X призвана запретить, и она вручает атакующему страницу, гарантированно записываемую и исполняемую по более или менее стабильному адресу.

Очевидное решение - выделить страницу как чтение-запись, сгенерировать код, а затем перевести её в чтение-исполнение через mprotect. Для CoreCLR этого недостаточно по двум причинам. Во-первых, есть окно, в котором страница уже записываема, а её адрес уже известен. Во-вторых, и это важнее, среда выполнения пишет код не один раз. Она патчит его непрерывно: заглушки подсчёта вызовов переписываются, когда метод пересекает порог уровней, многоуровневая компиляция заменяет код уровня 0 кодом уровня 1, а ячейки диспетчеризации виртуальных заглушек перепатчиваются по мере разрешения мономорфных точек вызова. Переключать страницу между RW и RX на каждом патче медленно и подвержено состояниям гонки между потоками.

Как CoreCLR это реализует на самом деле: двойное отображение

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

Вся механика - это ExecutableAllocator и RAII-помощник ExecutableWriterHolder в src/coreclr/inc/executableallocator.h. Каждое место в виртуальной машине, желающее изменить код, берёт writer holder, пишет через holder.GetRW() и позволяет деструктору убрать записываемое представление. Подложка создаётся в src/coreclr/minipal/Unix/doublemapping.cpp, что на Linux выглядит так:

// dotnet/runtime, src/coreclr/minipal/Unix/doublemapping.cpp
int fd = memfd_create("doublemapper", MFD_CLOEXEC);

На FreeBSD используется shm_open(SHM_ANON, ...), а на прочих Unix-системах происходит откат к объекту разделяемой памяти POSIX с именем /shm-dotnet-<pid>, к которому сразу же применяется shm_unlink. Именно этот memfd можно реально наблюдать снаружи процесса:

# Linux, .NET 11. Count the double mappings in a running .NET process.
grep -c doublemapper /proc/$(pgrep -n MyApp)/maps

Платформы Apple идут другим путём. CreateDoubleMemoryMapper на Apple возвращается рано, вообще не создавая файлового дескриптора, потому что macOS на arm64 предлагает взамен механизм уровня потока: страницы, выделенные с MAP_JIT, можно переключать между записываемыми и исполняемыми только для вызывающего потока через pthread_jit_write_protect_np. Среда выполнения оборачивает это как PAL_JitWriteProtect, и при HOST_APPLE && HOST_ARM64 writer holder просто возвращает тот же самый адрес вместо второго отображения:

// dotnet/runtime, executableallocator.h, Apple arm64 path
m_addressRW = addressRX;
PAL_JitWriteProtect(true);

Именно эту привязку к потоку часто упускают: на Apple silicon право записи принадлежит потоку, а не странице, поэтому нельзя допускать, чтобы один поток писал в область, пока другой её исполняет.

Сам флаг и как его выставить

Переключатель объявлен ровно один раз, в src/coreclr/inc/clrconfigvalues.h:

// dotnet/runtime, src/coreclr/inc/clrconfigvalues.h
RETAIL_CONFIG_DWORD_INFO(EXTERNAL_EnableWriteXorExecute, W("EnableWriteXorExecute"), 1,
                         "Enable W^X for executable memory.");

По умолчанию 1 на всех архитектурах, кроме TARGET_RISCV64, где то же объявление поставляет значение по умолчанию 0. Значением по умолчанию он стал в PR #69672, влитом в мае 2022 года для .NET 7. До этого .NET 6 поставлял его включённым по умолчанию только для macOS arm64 (где операционная система не оставляет выбора), а везде остальное - как opt-in, ровно так, как обещал анонс .NET 6.

Выставить его можно двумя способами. Переменная окружения работает везде:

# Disables W^X for this process only. .NET 7 and later.
DOTNET_EnableWriteXorExecute=0 ./MyApp

Начиная с .NET 9 его можно также положить в runtimeconfig.json благодаря PR #101490:

{
  "configProperties": {
    "System.Runtime.EnableWriteXorExecute": 0
  }
}

В проекте в стиле SDK выразите это элементом MSBuild, чтобы настройка пережила пересборку:

<!-- .NET 9 and later. Ignored by .NET 8 and earlier, which need the env var. -->
<ItemGroup>
  <RuntimeHostConfigurationOption Include="System.Runtime.EnableWriteXorExecute" Value="0" />
</ItemGroup>

Путь через runtimeconfig так и не был перенесён в .NET 8; запрос в issue #103340 закрыли как незапланированный. В .NET 8 переменная окружения - ваш единственный вариант. И учтите изменение приоритетов в .NET 9: переменные окружения теперь побеждают runtimeconfig.json, поэтому забытый DOTNET_EnableWriteXorExecute в образе контейнера молча переопределит настройку вашего проекта.

Во что он обходится

Эта мера не бесплатна, и команда среды выполнения измерила её до того, как включить по умолчанию. Цифры из PR #69672 по бенчмаркам ASP.NET plaintext, json, fortunes и orchard на x64 Windows, x64 Linux и arm64 Linux дали регрессию запуска от 5 до 10 процентов, а последующий анализ оценил время до первого запроса примерно на 10 процентов хуже. В установившемся режиме измеримой разницы не обнаружилось, и это логично: как только горячие методы скомпилированы JIT и пропатчены, аллокатор исполняемой памяти перестаёт находиться на сколько-нибудь значимом пути.

Первая выпущенная версия была хуже этого на нагрузках с интенсивной JIT-компиляцией. PR #74526 отслеживал регрессию в тестах регулярных выражений, которая, как выяснилось, была вызвана компиляцией примерно 50 000 методов, каждый из которых выделял и освобождал новое записываемое отображение. Кеширование последнего использованного записываемого отображения вместо его немедленного освобождения устранило проблему полностью и вышло в .NET 7 вместе со сменой значения по умолчанию. Если вы измеряете запуск на .NET 7 или новее, это исправление у вас уже есть.

Практический вывод: W^X стоит вам времени запуска, а не пропускной способности. Это важно для короткоживущих процессов и холодных стартов и куда менее важно для долгоживущего сервера. Это та же ось, по которой идёт выбор между Native AOT, ReadyToRun и чистым JIT.

Где на самом деле находится Native AOT

Теперь та часть, которую вопрос переворачивает. Native AOT публикует двоичный файл, код которого полностью скомпилирован во время сборки и отображается загрузчиком операционной системы как чтение-исполнение, ровно как у программы на C. Нет ни JIT, ни уровней, ни перепатчивания заглушек, а значит, нет и ExecutableAllocator. Поищите по среде выполнения Native AOT в src/coreclr/nativeaot/Runtime, и вы нигде не найдёте EnableWriteXorExecute. Выставление флага для двоичного файла Native AOT не делает ровным счётом ничего: этот переключатель - значение конфигурации виртуальной машины CoreCLR, а среда выполнения Native AOT - другая, куда меньшая среда, которая конфигурацию CLR не читает никогда.

Отсутствие генерации кода во время выполнения можно подтвердить из управляемого кода:

// .NET 11, C# 14. Prints False under Native AOT, True under CoreCLR.
using System.Runtime.CompilerServices;

Console.WriteLine(RuntimeFeature.IsDynamicCodeCompiled);

Это не совсем то же самое, что сказать, будто Native AOT вообще не выделяет исполняемую память во время работы. Немного выделяет, по одной конкретной причине: маршалируемые делегаты. Когда вы передаёте управляемый делегат экземпляра в нативный код как указатель на функцию, целевой адрес должен закодировать, какой именно экземпляр делегата вызывать, а это нельзя запечь в образ, потому что во время сборки экземпляра ещё не существует. Среда выполнения материализует небольшой thunk на каждый делегат:

// .NET 11, C# 14. This is the call that forces a runtime-allocated thunk.
using System.Runtime.InteropServices;

Action<int> callback = Console.WriteLine;
nint fnPtr = Marshal.GetFunctionPointerForDelegate(callback);
// fnPtr points at a thunk allocated from a thunk pool, not at compiled image code.
GC.KeepAlive(callback);

Эти thunk-и берутся из PalAllocateThunksFromTemplate, сигнатура которой в src/coreclr/nativeaot/Runtime/unix/PalUnix.cpp такова:

UInt32_BOOL PalAllocateThunksFromTemplate(HANDLE hTemplateModule, uint32_t templateRva,
                                          size_t templateSize, void** newThunksOut);

Эта схема, добавленная для платформ вроде iOS в PR #82317, никогда не создаёт страницу RWX. На целевых платформах Apple она резервирует два соседних диапазона через vm_allocate, а затем использует vm_remap с VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, чтобы отобразить уже скомпилированную страницу шаблонного кода из загруженного образа в исполняемую половину, тогда как записываемая половина хранит только данные каждого thunk-а (целевой адрес и дескриптор делегата). Код никогда не пишется во время работы, на него лишь ссылаются. Это соблюдение W^X по построению, а не по политике, и именно поэтому оно работает на платформе, которая не предлагает никаких лазеек.

PalVirtualAlloc в том же файле всё-таки передаёт MAP_JIT при выделении исполняемой памяти на macOS arm64, поскольку ядро там этого требует.

В какую сторону на самом деле идёт причинность

Apple не позволяет стороннему приложению из App Store ни отображать память RWX, ни переводить страницу в исполняемую после записи в неё. Никакого entitlement, который менял бы это для выпускаемых приложений, не существует. Одно это ограничение исключает JIT-компиляцию, а вместе с ней режим JIT в Mono, уровни CoreCLR и горячую перезагрузку скомпилированного кода. В ту же стену упирается Flutter, и поэтому отладочная сборка Flutter для iOS падает с mprotect permission denied на свежих версиях iOS, тогда как релизные сборки, полностью скомпилированные AOT, не затронуты.

Точная формулировка звучит так: iOS навязывает W^X, W^X запрещает JIT, а Native AOT - это способ, которым .NET доставляет код на платформу, запрещающую JIT. Native AOT поддерживает платформы вроде iOS начиная с .NET 9 и является режимом компиляции по умолчанию для релизных сборок .NET MAUI на iOS и Mac Catalyst. Ничто в этой цепочке не задействует флаг EnableWriteXorExecute, который всегда управлял только тем, как JIT в CoreCLR доставляет свои байты в память на платформах, которые иначе позволили бы ему халтурить.

Когда отключение - законное решение

W^X - это мера эшелонированной защиты. Её отключение реально ослабляет защищённость вашего процесса, поэтому относитесь к DOTNET_EnableWriteXorExecute=0 сначала как к диагностическому инструменту и лишь при наличии причины как к постоянной настройке. Вот причины, которые выдерживают проверку:

Профилирование JIT-скомпилированных кадров с помощью perf в Linux. Среда выполнения пишет свою perf-карту по адресу RW-отображения, а не RX-отображения, которое процессор действительно исполняет, поэтому JIT-кадры разрешаются в неверные символы или ни во что. Это открыто с июля 2022 года как issue #71786 и до сих пор стоит в вехе Future. Если вам нужен пригодный профиль perf по JIT-скомпилированному коду, отключите W^X для этого запуска. Для повседневного профилирования лучше берите dotnet-trace, который читает собственные события rundown и не затронут проблемой.

Растущее число записей /memfd:doublemapper (deleted). Issue #89776 сообщает о накоплении этих отображений в Linux (на macOS они освобождаются, на Linux нет), что в долгоживущем сервисе проявляется как растущее число отображений и растущая виртуальная память. На ARM32 тот же механизм описан как полноценная утечка памяти, приводящая к убийствам по OOM, в issue #121455. Если ваш /proc/<pid>/maps забит doublemapper, вы смотрите именно на это.

SIGXFSZ при ограничении размера файла. Для ядра memfd - это файл, поэтому ulimit -f ниже размера, запрошенного маппером, убивает процесс сигналом SIGXFSZ. Это была issue #117819.

Нативные отладчики, ставящие точки останова. Запись int3 через RX-отображение вместо RW приводила к нарушениям доступа, что отслеживалось в issue #107444. Если вы подключаете lldb или gdb к процессу .NET и видите сбои при установке точек останова, отключите W^X на время этой сессии отладки.

Rosetta. Здесь делать ничего не нужно. Двойное отображение никогда не работало корректно под эмуляцией Rosetta (issue #70910), и среда выполнения сама определяет Rosetta и отключает W^X за вас.

Чего в этом списке нет, так это “моё приложение медленно стартует”. Если ваша проблема - холодный старт, флаг покупает вам 5-10 процентов, тогда как настоящее решение, ReadyToRun или Native AOT со своим собственным балансом издержек, покупает намного больше и не ослабляет процесс. Беритесь за флаг, когда у вас есть один из конкретных симптомов выше, и оставьте рядом комментарий о том, какой именно.

Похожее

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад