nos_
ENRU

Фаззинг ядра на хосте

Четыре фаззера, которые собирают исходники самого ядра в программы для хоста, — гипервизор, сеть, хранилище и C++ — и ошибки, которые они нашли.

11 мин чтения
ФаззингТестированиеБезопасностьRustГипервизорСетьХранилище
Read in English

Почти всё, что читает ядро, написано кем-то другим. Гость выбирает каждый байт своих virtio-колец, сеть — каждый кадр и каждый ответ DHCP, диск — собственную геометрию: хоть 520-байтные секторы, хоть 2^64 штук. Прошивка отдаёт дерево устройств, таблицы ACPI и карту памяти ещё до того, как у headless-машины появляется хоть какая-то консоль. Паника на любом из этих путей — это остановившаяся машина, а на двух серверах в Hetzner, где единственная консоль — сеть, она останавливается молча.

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

Исходники ядра, собранные для хоста

Большая часть того, что требовалось фаззить, уже была переносимой. Блочный, сетевой и файловый слои — это Rust-крейты поверх kcore и ffi (как они туда попали — отдельная история), и Cargo собирает их для хоста так же охотно, как для ядра. fuzz/fs зависит от них по пути и с тем же профилем, что у ядра с RUSTUB=1, — так что переполнение на хосте даёт ту же панику, а не тихий wrap:

[dependencies]
# The kernel's crates, built for the host as they are for the kernel.
ffi = { path = "../../src/rust/ffi" }
kcore = { path = "../../src/rust/kcore" }
block = { path = "../../src/rust/block" }
fs = { path = "../../src/rust/fs" }

# What a RUSTUB=1 kernel has on, in every crate: an overflow is a panic, not
# a wrap, and core's checks of its own unsafe preconditions are made.
[profile.release]
overflow-checks = true
debug-assertions = true
panic = "unwind"

Файлы гипервизора импортируют hvarch — расширение виртуализации процессора со всем его unsafe, — поэтому fuzz/hv компилирует их по одному и сам отзывается на имена ffi, hv, hvarch и kcore. Всё, до чего эти файлы дотягиваются снаружи, от часов до процессора, — заглушки в той же программе:

extern crate alloc;
extern crate self as ffi;
extern crate self as hv;
extern crate self as hvarch;
extern crate self as kcore;
// ...
#[path = "../../../src/rust/hv/src/acpi.rs"]
pub mod acpi;
// ...
/// The guests' DHCP server, from the module: what a guest's DHCP client's
/// frames reach.
#[path = "../../../src/rust/modules/hv/src/dhcp.rs"]
pub mod dhcp;

C++ получает то же самое другими средствами: fuzz/cpp/Makefile компилирует файлы ядра как есть, под ASan и UBSan, а первым в include-пути стоит HAL хоста вместо inline-HAL каждой архитектуры — никаких привилегированных инструкций, а хуки MMU видны той цели, которая его моделирует. Все четыре фаззера живут в fuzz/, а не в src/rust/: лежащий там .cargo/config.toml превращает любую сборку cargo ниже этого каталога в сборку ядра.

Машина, на которой они работают

Сетевому и файловому слоям нужно ядро под ними. fuzz/common, общий для сетевого и файлового фаззеров, — такое ядро, написанное специально: каждая задача — поток хоста, ход в каждый момент только у одной, и везде, где задача блокируется или отпускает блокировку, кто пойдёт следующим, решает вход. Ничто не спит по настоящим часам: когда ждут все задачи, часы перескакивают к ближайшему дедлайну, так что час аренды DHCP обходится ровно в стоимость её таймеров. А правила ядра проверяются в той строке, где их нарушают:

pub fn check_may_wait(what: &str) {
    match ctx() {
        Ctx::Softirq => panic!("invariant: a soft IRQ handler {}: every packet waits for it -- the receive path \
                                must never sleep", what),
        Ctx::Irq => panic!("invariant: an interrupt handler {}", what),
        Ctx::Task => {}
    }
    if irq_off() != 0 || preempt_off() != 0 {
        panic!("invariant: task {} {} with a spin lock held or interrupts off (irq {}, preempt {})", me(), what,
               irq_off(), preempt_off());
    }
}

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

Coverage-guided фаззинга здесь нет вовсе. Вход — это случайные байты из seed, которые генератор, знающий формат, читает как поток решений (input.rs). За концом входа каждое чтение даёт ноль, а ноль — всегда обычный выбор; и всё, что обрывает прогон раньше времени, должно быть редким, иначе прогоны никогда не уйдут вглубь:

/// True about once in 4096: for what ends a run, which has to be much
/// rarer than a run is long for the rest to be reached.
pub fn rare(&mut self) -> bool {
    self.u16() >= 0xFFF0
}

Четыре фаззера

Фаззер Из чего собран Целей С чем сверяется
fuzz/hv устройства гипервизора, local APIC и IO-APIC, декодер MMIO, обходчик таблиц страниц гостя, загрузчик Linux, таблицы ACPI, политика CPUID/MSR и цикл исполнения; коммутатор гостей, их DHCP-сервер и NAT 22 инварианты каждого устройства; модели коммутатора, NAT, политики и размещения гостей
fuzz/net крейты net, netwire, tls, fs и ssh и исходник модуля sshd 10 смоделированный мир, протокол за протоколом
fuzz/fs крейты block и fs 9 модель дерева, собственный e2fsck, отключения питания
fuzz/cpp C++, который читает то, что ему дают прошивка, загрузчик или диск; загрузчик модулей; printf; таблицы страниц и куча 11 модель того, что обещает каждый заголовок, под ASan и UBSan

Самая амбициозная цель hv, platform, собирает машину Linux-гостя так же, как это делает hv boot, и гоняет её на настоящем цикле исполнения LinuxGuest::run, по одному потоку vCPU за раз. Там, где цикл вошёл бы в гостя, сценарий говорит, что гость делает дальше, — порт, MSR, обращение к странице устройства вместе с вызвавшей его инструкцией, halt — и делает то, что драйверы делают за много выходов: от заполнения таблиц MSI-X до запуска AP через INIT и SIPI. Находкой считается и vCPU, входящий в гостя с захваченной блокировкой, и цикл, который миллион раз читает часы, ни разу не войдя в гостя и не уснув.

fuzz/net окружает машину миром (src/world): канал, который задерживает, переставляет, дублирует и теряет, TCP-пир, хранящий каждый байт обоих потоков, rustls-сервер с собственным CA фаззера, DHCP- и DNS-серверы, которые врут, полноценный SSH-клиент. fuzz/fs подсовывает слоям диски с энергозависимым кэшем записи, выключателем питания и запросами, которые завершаются ошибкой (disk.rs), а образы оценивает собственным e2fsck, сверенным с e2fsprogs на сотнях образов.

Гейты и кампании

Без аргументов каждый фаззер прогоняет фиксированное число входов от seed 1 — каждый раз одни и те же. Это делает его гейтом: CI гоняет все четыре на каждый push — hv и fs в x86-64-задании матрицы, net и C++ в arm64-задании, — от полуминуты до пары минут на каждый. Кампания — та же программа со своим seed:

python3 scripts/hv-fuzz.py                          # the gate
python3 scripts/hv-fuzz.py --seed 7 --seconds 300   # five minutes a target
python3 scripts/hv-fuzz.py --replay platform out/hv-fuzz/findings/X.hex

Находка приходит с seed и номером итерации, которые её воспроизводят, а вход сохраняется для --replay; код возврата — 0, если ничего не найдено, 1 при находке, 3 при зависании. Насколько глубоко заходят входы, измеряется, а не угадывается: HV_FUZZ_STATS=1 показывает, чем закончились прогоны всей машины, а в первый же день гейт hv покрыл 94% регионов компилируемых им исходников — по данным покрытия clang.

Что они нашли

Каждый фаззер влился вместе с исправлениями того, что нашёл. Вот избранное.

Гость, который мог стать другим гостем

guestnet соединяет коммутатор и NAT так же, как это делает модуль hv. На первом же входе он обнаружил, что гость может слать трафик от имени другого: коммутатор пропускал кадры с любым MAC и любым адресом, а NAT отвечает потоку на тот MAC, с которого пришёл его последний пакет. Значит, одного пакета с адресом другого гостя и портами одного из его потоков хватало, чтобы забрать поток себе, — и ответы внешнего мира уходили самозванцу.

Исправление (d1aeecf) — то, что делает виртуальный коммутатор в облаке: ingress проверяет каждый кадр гостя на входе — только IPv4 или ARP, только с MAC и адресом своего порта, а единственное исключение — DHCP-клиент, у которого адреса ещё нет, и ему коммутатор отвечает сам. Остальное отбрасывается и записывается на счёт гостя в hv list. Контрольный прогон с вырезанной проверкой однажды показал вред воочию: соединение hv forward к веб-серверу одного гостя попало к другому. Теперь hv-linux-test.py --net заставляет настоящий Linux-гость присвоить адрес соседа и проверяет, что обратно ничего не приходит.

Адрес за верхней границей памяти

Буфер virtio на самом верху адресного пространства гостя заставлял диск вычислять адрес записи статуса за пределами 2^64 — паника при RUSTUB=1 и тихий переход на страницу 0 в обычном ядре. Исправление — тип, а не проверка в каждом месте вызова: адрес сегмента приватный, и единственный путь к нему складывает с проверкой (virtio.rs):

pub struct Seg {
    addr: u64,
    pub len: u32,
    pub write: bool,
}

impl Seg {
    pub fn at(&self, offset: u64) -> Option<u64> {
        if offset < u64::from(self.len) { self.addr.checked_add(offset) } else { None }
    }
}

В тот же первый день (4ce4e77) нашлось ещё два случая. Остановленный командой halt vCPU крутил свой хостовый процессор: прерывание периодического APIC-таймера придерживали приоритеты гостя, а таймер сообщал уже прошедший дедлайн. А загрузчик Linux принимал предпочтительный адрес ядра ниже 1 МиБ — поверх нулевой страницы и таблиц страниц, которые сам туда пишет. После исправлений кампании на десяти seed прогнали по финальной обвязке 7,9 миллиарда входов и больше ничего не нашли.

Сон внутри спинлока

Задача netconsole, выгружающая лог, опрашивает устройство, пока у того не появится адрес (c68452d):

-            let nic = match self.log.lock().nic {
+            let attached = self.log.lock().nic;
+            let nic = match attached {
                 Some(nic) if nic.ip() != 0 => nic,
                 _ => {
                     kcore::task::sleep_ms(NO_LINK_POLL_MS);
                     continue;

Старая строка читается как «скопировать поле и отпустить блокировку». Но это не так: временное значение в выражении, по которому идёт match, живёт до конца match, поэтому IrqSpinLock лога удерживался все 200 мс сна — с выключенными прерываниями, и каждая строка, которую в это время трассировал любой процессор, крутилась на нём. Сон с удерживаемым спинлоком — ровно то, что правила машины делают находкой.

Отказы, похожие на успех

Мир сетевого фаззера знает, что отправил, поэтому отличает обрезанный ответ от целого — так нашлось семейство ошибок, в которых ядро рапортовало об успехе. HTTP-ответ, оборванный посреди заголовков, возвращался как тело, состоящее из его же строки статуса: «ok». TLS-клиент принимал поток, оборванный посреди записи, и истёкший таймаут приёма за конец потока, так что зависшее тело без длины возвращалось целым. DHCP-клиент после первого продления начинал заново с DISCOVER, оставаясь привязанным, и больше свою аренду не продлевал; теперь он держит её так, как велит RFC 2131.

Отключение питания между двумя записями

create_file в ext2 записывал новый inode раньше, чем бит в битовой карте, который помечает его занятым (07e7409):

-        if !self.write_inode(ino, &inode) || !self.flush_bitmap() || !self.commit_meta() {
+        if !self.flush_bitmap() || !self.write_inode(ino, &inode) || !self.commit_meta() {

Отключение питания между двумя записями оставляет используемый inode, который битовая карта считает свободным, — его заберёт следующий create; диск фаззера фиксирует ровно такой обрыв. В остальном хранилище: каталог, загрузка которого падала на полпути, при каждом обращении перечитывал свои записи заново, так что каждый ls навсегда увеличивал дерево; а на устройстве с 520-байтными секторами disk log — инструмент как раз для машин, которые умирают молча, — проскакивал полной пачкой за конец своего буфера: паника при загрузке с disklog=on.

Индекс −1 в первом, что читает загрузка arm64

В читателе дерева устройств END_NODE без открытого узла уводил глубину обхода ниже корня, и следующий узел записывал свои cell counts по индексу −1 в стеки обходчика — запись в .bss из первого, что читает загрузка arm64 (00631ab). Теперь свойства читаются через Fdt::Prop, и каждое чтение ограничено длиной свойства. Тот же раунд поймал пулы кучи, выделявшие страницу под своим спинлоком, — дедлок из поста о запрете аллокаций под спинлоком.

Второй IO-APIC на AX41

Цель acpi (516dc26) обнаружила, что каждая таблица из корневого списка навсегда оставалась отображённой в окне TmpMap — 21 из его 448 слотов на AX41, — а из двух перечисленных IO-APIC ядро оставляло последний. MADT у AX41 перечисляет сначала IO-APIC FCH (GSI 0), а за ним IO-APIC GNB, так что ядро управляло вторым: legacy-прерывания, включая тик HPET, настраивались на пинах, до которых не доходит ни одна из этих линий. Никто этого не замечал — тик нужен лишь до тех пор, пока каждый процессор не запустит собственный local APIC-таймер; ядро с исправлением, загруженное на той машине, показывает в irqstat тик HPET с положенными 100 Гц.

И printf: VsnPrintf читал каждое целочисленное преобразование как 64-битный слот, так что int-уровень трассировки в каждом Trace приходил вместе с тем, что лежало во второй половине регистра (3743fcf). Теперь форматы сишные, компилятор проверяет их через PRINTF_FORMAT и -Wformat=2, а цель format сверяет VsnPrintf с vsnprintf хоста байт в байт.

Как доказать, что обвязка кусается

Фаззер, который ничего не находит, — это либо хорошая новость, либо обвязка, которая ничего не видит. Поэтому каждый проверили и наоборот: ломаем код нарочно — цель обязана упасть. Для трёх читателей, от которых зависит загрузка x86, по одной вынимали каждую проверку — всего 26 — и каждая становилась находкой в пределах 20 000 входов. Три сначала проскочили: две — потому что генераторы не доходили до края (FADT, кончающаяся ровно на границе поля, карта памяти длиннее таблицы), одна — потому что эмулируемое окно было отравлено постранично и чтение за концом короткой таблицы оставалось внутри него. Чинили генераторы, а не ядро.

Чего они не видят

Процессор в hv-фаззере — заглушка, так что из hvarch — его unsafe, VMCB и VMCS, переключения миров — не фаззится ничего, а чередования задаёт сценарий: все пути между процессорами пройдены, но гонку так не найти. fuzz/cpp не дотягивается до PageTable::Setup, работающей на начальном линейном отображении, и до всего, что требует второго процессора. А fuzz/fs фиксирует одно известное ограничение: после ошибки устройства посреди create или rename каталог в памяти может расходиться с диском до следующего монтирования.

Фаззеры остаются гейтами, а целей становится больше — размещение гостей, политика CPUID и MSR (её сверяют с моделью, написанной по руководствам производителей, а не по коду), файл микрокода Intel. Какой гейт что покрывает — на странице о тестах и гейтах, а как их запускать — в отдельном руководстве.