W^X везде: образ ядра, куча и каждый модуль
В nos нет страниц, доступных одновременно на запись и на исполнение. Как разделили образ, какая дыра была под каждым отображением и какой тест её стережёт.
W^X — это обещание про каждую страницу: в неё можно писать или её можно исполнять, но не то и другое сразу. Страница, где можно и то и другое, превращает случайную запись в работающий код; .text, открытый на запись, превращает дикий указатель в тихо пропатченное ядро. nos нужно обратное, потому что отказ должен быть громким: запись в код или переход в данные должны падать на месте и сообщать, где именно.
Чтобы сдержать это обещание, понадобились два месяца и обе архитектуры, а частью работы оказалось открытие, что документация давала это обещание раньше, чем его стал выполнять код.
Образ, разрезанный на три части
Первым шагом 17 июля (arm64 в 329a8e7, x86-64 в тот же день в 47ca40e) стал образ ядра. Оба скрипта линкера получили выровненные по странице символы между секциями: KernelText после .text и KernelRodata после .rodata. Ближе к концу загрузки, когда настоящая таблица страниц уже активна, ProtectRange обходит листовые записи образа по 4 КиБ и переписывает их права (main.cpp):
pt.ProtectRange(t0, t1 - t0, false, true); /* RX */
pt.ProtectRange(t1, r1 - t1, false, false); /* RO+NX */
pt.ProtectRange(r1, e - r1, true, false); /* RW+NX */
На arm64 это AP[2] для «только чтение» и PXN/UXN для запрета исполнения — процессор соблюдает их с самого начала. На x86-64 нужны два переключателя, которые щёлкает Hal::EnableWxSupport(): EFER.NXE, без которого бит NX ничего не значит, и CR0.WP, без которого кольцо 0 игнорирует бит «только чтение». W^X без WP оставил бы ядру возможность писать в собственный .text. Результат проверил параметр загрузки: wxprobe=on пишет в .text после разделения. x86-64 упал с page fault и кодом ошибки 0x3 (present, write), arm64 — с permission data abort (DFSC 0x0F), и ни один не дошёл до строки, которая сообщила бы, что запись удалась.
В тот же день ревью arm64-порта (7408805) закрыло лазейку, которая обходила всё это. Тождественное отображение на 1 ГиБ, которое boot.S ставит в TTBR0 на момент включения MMU, оставалось живым навсегда, и было оно RWX — так что текст ядра был доступен на запись и исполнение по своему физическому псевдониму, что бы ни говорило отображение в верхней половине. Теперь TCR.EPD0 выключает обход TTBR0, как только исполнение попадает в верхнюю половину. 29 августа (6ebed66) NX получила и каждая листовая запись MMIO на x86-64 (на arm64 они такими уже были): с устройства никогда ничего не исполняется, а на реальном железе спекулятивная выборка из памяти устройства может иметь побочные эффекты чтения (страничная память).
Дыра под обещанием
К сентябрю CLAUDE.md утверждал, что исполнение из буфера в куче вызывает исключение, и docs/paging.md утверждал то же. Это было неправдой. MapRangeLocked, который строит листовые записи для кучи, стеков задач, DMA-буферов и образов модулей, ставил в них W и P и никогда — NX; так же поступал и код, настраивающий окно TmpMap и PageArray. Всё, что ядро отображало после загрузки, было одновременно доступно на запись и на исполнение — на обеих архитектурах. NX несли только образ, MMIO и уже разложенные сегменты модулей.
Никто этого не заметил, потому что ничего не ломается. Отсутствующий бит NX невидим, пока кто-нибудь не исполнит данные, а ни одна smoke-загрузка нарочно этого не делает. Ровно такого отказа этот проект боится больше всего: отказа, который выглядит как успех.
Исправление (b54a189, 17 сентября) ставит NX на каждую листовую запись, которую ядро пишет во время работы (page_table.cpp):
page->Get();
l1Entry->SetAddress(page->GetPhyAddress());
l1Entry->SetWritable();
/* RW+NX is what every runtime mapping gets: the heap, stacks,
DMA buffers and a module image before it is laid out. The one
thing that runs from such a page is a module's text, and the
loader asks for it by name (SetRangeProtection) once the
segment is written -- so nothing is ever writable and
executable at the same time. */
l1Entry->SetNoExecute();
l1Entry->SetPresent();
SetupPage, которая строит таблицу при загрузке, теперь принимает аргумент executable, и true передаёт ровно один вызывающий: образ ядра, из которого ядро исполняется с момента переключения корня таблицы, пока ProtectRange его не разделит. Таблицы над листьями намеренно оставлены разрешающими. На обеих архитектурах бит NX в записи L4, L3 или L2 отнимает право исполнения у всего поддерева, и никакой лист вернуть его не может, — а текст модулей живёт как раз под этими таблицами. Исполнять страницу или нет, решает лист.
Бит 63 зарезервирован, пока не перестанет
На x86-64 в исправлении пряталась ловушка. Пока EFER.NXE не установлен, бит 63 записи таблицы страниц не означает «не исполнять»: это зарезервированный бит, и запись с ним вызывает исключение при первом же обращении. EnableWxSupport() раньше вызывался прямо перед ProtectRange, и это работало, пока до этого места NX никто не ставил. Теперь его ставят окно TmpMap и ранний разбор списка свободных страниц, а они работают гораздо раньше, — поэтому вызов переехал в самое начало Main2, раньше любой таблицы страниц:
/* Before any page table is built: every mapping the kernel makes at
runtime carries NX, and with EFER.NXE off bit 63 is a reserved bit --
the first touch of such a page would fault instead of reading. The APs
do the same before they load this table (ApMain2). */
Hal::EnableWxSupport();
У AP порядок уже был правильным: сначала NXE, потом настоящая таблица страниц, потом GDT и IDT, чтобы исключение превращалось в отчёт о панике, а не в перезагрузку, и только потом всё, что умеет печатать. На arm64 ничего включать не нужно: PXN и UXN соблюдаются всегда.
Тест, который падает, если убрать исправление
Проба текста эту дыру поймать не могла: образ проблемой никогда не был. Поэтому у wxprobe появился второй режим. wxprobe=heap берёт страницу, только что выданную аллокатором, пишет в неё ret и вызывает её; двойник на arm64 пишет 0xD65F03C0 — свой ret:
static void WxProbeHeap()
{
static const u8 RetOpcode = 0xC3;
ulong phys = 0;
void* va = Mm::AllocMapPages(1, &phys);
// ...
*reinterpret_cast<volatile u8*>(va) = RetOpcode;
Hal::SyncInstructionCache((ulong)va, sizeof(RetOpcode));
Trace(0, "W^X probe: calling into a heap page 0x%lX (expect page fault)", (ulong)va);
reinterpret_cast<void (*)()>(va)();
Trace(0, "W^X probe: heap execute SUCCEEDED (W^X broken!)");
}
wx-test.sh загружает ядро по разу на каждую пробу (на x86-64 — каждый раз со своего ISO, потому что командную строку передаёт GRUB, на arm64 — через -append) и проходит, только если каждая загрузка умирает с паникой, которая называет адрес, напечатанный пробой: cr2 на x86-64, far на arm64. Ядро, которое продолжает работать, печатает SUCCEEDED (W^X broken!), и скрипт падает. 17 сентября проба текста упала на базовом адресе образа. Проба кучи упала с кодом ошибки 0x11 (present, instruction fetch) и rip == cr2 на x86-64 и с классом исключения 0x21, instruction abort, и far == elr на arm64.
Затем проверили сам тест. Когда единственный SetNoExecute() в MapRangeLocked убрали обратно, проба кучи напечатала «SUCCEEDED (W^X broken!)», а wx-test завершился с кодом 1. Гейт, который проходит в любом случае, ничего не измеряет; этот измеряет исправление. Это гейт для каждого, кто трогает путь отображения, — на обеих архитектурах (тесты и гейты).
Модули: решает заголовок программы
Загружаемые модули (модули) — единственное место, где nos кладёт в память новый код во время работы, поэтому для них правило самое строгое. .ko линкуется с -z separate-loadable-segments -z max-page-size=4096, чтобы каждый сегмент начинался со своей страницы: права ведь назначаются постранично. Ещё до того, как что-либо отобразить, загрузчик отвергает сегмент, который просит и запись, и исполнение (module.cpp):
if ((ph.Flags & Elf::PfW) != 0 && (ph.Flags & Elf::PfX) != 0)
{
out.Printf("module: segment %lu is both writable and executable\n", i);
return MakeError(Stdlib::Error::InvalidValue);
}
Затем он отображает образ на запись — и, начиная с b54a189, без права исполнения, — копирует сегменты и применяет релокации. Только после этого каждый сегмент получает права, которые просит его заголовок программы, — только чтение, чтение и исполнение или чтение и запись, — через SetRangeProtection. TLB сбрасывается на всех CPU, а на arm64 новый код вычищается из кэша данных и инвалидируется в кэше инструкций каждого процессора. Ни в один момент загрузки ни одна страница модуля не бывает доступна одновременно на запись и на исполнение.
C++-фаззер держит загрузчик в этих рамках для каждого .ko, который он может собрать из своего входа, целого или испорченного (00631ab). Его заглушка SetRangeProtection считает запрос «на запись и на исполнение» нарушением инварианта, а после загрузки каждая страница образа должна нести ровно права своего сегмента — с синхронизированным кэшем инструкций и отправленным shootdown (фаззинг ядра на хосте).
Что осталось
Доступной одновременно на запись и на исполнение в nos намеренно остаётся только начальная карта, построенная до появления настоящей таблицы страниц, — и она перестаёт что-либо транслировать, как только эта таблица загружена. После этого момента под W^X находится всё, на x86-64 и на arm64 одинаково: образ, куча, стеки, DMA-буферы, MMIO и каждый модуль. А когда очередной путь отображения забудет про NX, это будет не фраза в документации, тихо расходящаяся с кодом. Это будет wx-test, завершившийся с кодом 1.