Что такое флаг 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 со своим собственным балансом издержек, покупает намного больше и не ослабляет процесс. Беритесь за флаг, когда у вас есть один из конкретных симптомов выше, и оставьте рядом комментарий о том, какой именно.
Похожее
- Что такое Native AOT и чего он вам стоит?
- Native AOT против ReadyToRun против JIT в .NET 11: что выбрать для поставки?
- Что такое многоуровневая компиляция и как о ней рассуждать?
- Как профилировать приложение .NET с помощью dotnet-trace и читать вывод
- Исправление: mprotect failed: 13 (Permission denied) в отладочной сборке Flutter для iOS
Источники
- W^X support, dotnet/runtime PR #54954
- Enable W^X by default, dotnet/runtime PR #69672
- Enable caching of writeable W^X mappings, dotnet/runtime PR #74526
- Read EnableWriteXorExecute from runtimeConfig, dotnet/runtime PR #101490
- NativeAOT thunk page generation and mapping for iOS-like platforms, PR #82317
- clrconfigvalues.h, dotnet/runtime
- doublemapping.cpp, dotnet/runtime
- Announcing .NET 6, .NET Blog
- .NET Runtime config options, Microsoft Learn
- Native AOT support for iOS-like platforms, Microsoft Learn
- pthread_jit_write_protect_np(3), Apple
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.