Как гипервизор nos не пускает спекулятивное исполнение гостя к хосту и соседям
Очистка регистров, заполнение RSB, IBPB, IBRS, BHI_DIS_S, VERW: что гипервизор nos делает вокруг каждого VM exit, сколько это стоит и что ещё открыто.
Цель, к которой движется nos, — чужие Linux-гости на одной машине. На уровне архитектуры гость не дотянется ни до чего, что ему не выдали: его вложенная таблица страниц отображает только страницы, которыми владеет его собственная память, и это инвариант типа, а не обещание в комментарии. Но VM exit передаёт процессор от кода, который выбрал гость, коду хоста — на том же ядре, с теми же предсказателями переходов, тем же стеком возвратов и теми же внутренними буферами, — и то, что гость там оставил, никакая таблица страниц не закрывает. До 12 октября 2026 года world switch ничего с этим не делал. Коммит, который это изменил, a8fcec3, начинается с описи:
Ничего из этого не было: после выхода значения гостя оставались в caller-saved регистрах, с которыми возвращался стаб; в стеке возвратов лежали адреса, оставленные гостем, — и по ним предсказывались возвраты хоста; между тем, как два гостя на одном CPU обучали предсказатели косвенных переходов, не стояло ничего; и косвенные переходы самого хоста тоже были открыты для гостя.
Гостя к тому же вводили в заблуждение: политика CPUID пропускала EBX листа 0x80000008 почти целиком и сообщала гостю на AMD, что у него есть IBPB, IBRS, STIBP и SSBD, а любой MSR, не названный политикой, читался как 0 и молча глотал запись. Гость писал PRED_CMD и SPEC_CTRL в никуда, а его sysfs уверял, что всё закрыто, — сбой, который проходит молча. 61256c8 превратил политику в белый список: средства управления спекуляцией гостю не предлагаются, пока соответствующий MSR не переключается вместе с ним, а hv-fuzz сверяет каждый ответ с моделью того, что обещает каждый бит CPUID.
Дальше — world switch в его нынешнем виде: hvarch::x86::spec и два стаба входа в гостя, написанные с оглядкой на KVM.
После каждого выхода: регистры и стек возвратов
Оба стаба — naked-функции. После выхода, сохранив регистры гостя, стаб обнуляет все регистры, которые вызывающий код не получит обратно из его собственных pop: регистр, в котором ещё лежит значение гостя, — это значение, которое хост может использовать за ветвлением, ещё не разрешённым процессором. Затем, до первого ret хоста, стаб заново заполняет return stack buffer. RSB предсказывает, куда вернётся ret, по недавним местам вызова; после работы гостя это места вызова гостя, и возвраты хоста предсказывались бы по адресам, которые выбрал гость. Заполнение — это FILL_RETURN_BUFFER из KVM: 32 собственных вызова стаба, за каждым — int3, на который попадёт предсказанный туда возврат; указатель стека возвращается на место после каждых двух, а lfence закрывает последний переход цикла:
"xor ecx, ecx",
"xor edx, edx",
// ...
"mov ecx, {rsb_pairs}",
"2:",
"call 3f",
"int3",
"3:",
"call 4f",
"int3",
"4:",
"add rsp, 16",
"dec ecx",
"jnz 2b",
"lfence",
В vmrun_stub для AMD-V это выполняется при всё ещё сброшенном GIF, так что между #vmexit и заполнением не выполняется вообще ничего — ни прерывание, ни NMI. vmx_launch_stub для VT-x делает то же самое с выключенными прерываниями — в таком состоянии их оставляет выход.
Между двумя гостями: IBPB
Indirect branch prediction barrier — запись бита 0 в IA32_PRED_CMD — заставляет предсказатели забыть, чему их научил предыдущий код. nos выдаёт его перед входом в гостя, который не был последним вошедшим на этом CPU. Гостей различают по идентификатору вложенной таблицы, общему для всех vCPU одного гостя, так что между собственными vCPU барьер не ставится:
pub(super) fn entering(&mut self, table: u64) {
if self.last != Some(table) {
SWITCHES.fetch_add(1, Ordering::Relaxed);
if IBPB.load(Ordering::Relaxed) {
/* PRED_CMD exists where CPUID offers IBPB, and IBPB is all
* a write of this bit does. */
unsafe { cpu::wrmsr(MSR_PRED_CMD, PRED_CMD_IBPB) };
BARRIERS.fetch_add(1, Ordering::Relaxed);
}
self.last = Some(table);
}
}
unsafe здесь корректен, потому что IBPB выставляется только по ответу CPUID, полученному один раз при включении расширения: спрашивать CPUID при каждом входе под вложенным гипервизором означало бы ещё один выход. hv-test.py требует от счётчиков hv минимум двух переключений на каждого встроенного гостя и IBPB при каждом из них, если процессор его поддерживает; на кремнии EX44 за одну сессию было выдано 159 барьеров.
Косвенные переходы самого хоста: IBRS и родственники
Заполнение RSB и барьер всё ещё оставляют косвенные переходы хоста предсказуемыми по тому, чему их научил гость. На AMD начиная с Zen 4 это закрывает automatic IBRS (бит 21 в EFER), и nos держит его включённым, пока включено расширение; у Zen 2 в AX41 его нет. Половина для Intel (86deabd, влита в 48bfaf5) решается для каждого CPU в момент, когда на нём включается расширение, — по CPUID и IA32_ARCH_CAPABILITIES:
let enhanced = caps & CAP_IBRS_ALL != 0;
// ...
let mds = caps & CAP_MDS_NO == 0;
let taa = tsx && caps & CAP_TAA_NO == 0;
let rfds = caps & CAP_RFDS_NO == 0 && caps & CAP_RFDS_CLEAR != 0;
let mmio = caps & CAP_MMIO_NO != CAP_MMIO_NO && caps & CAP_FB_CLEAR != 0;
let mut intel = Intel {
verw: features.edx & EDX_MD_CLEAR != 0 && (mds || taa || rfds || mmio),
l1d_flush: caps & (CAP_RDCL_NO | CAP_SKIP_L1DFL_VMENTRY) == 0 && features.edx & EDX_L1D_FLUSH != 0,
bhi_open: enhanced && caps & CAP_BHI_NO == 0 && !bhi_ctrl,
..Intel::default()
};
- IBRS в
IA32_SPEC_CTRL, пока включено расширение. Enhanced IBRS (Ice Lake и новее; EX44) выставляется один раз и держится. Legacy IBRS (Skylake; ноутбук Dell) работает как барьер только в момент записи, поэтому стаб пишет его заново после каждого выхода, до первого косвенного перехода хоста. - BHI_DIS_S рядом с ним, если у процессора есть такой бит (Alder Lake и новее): история ветвлений, оставленная гостем, не используется для предсказания косвенных переходов хоста.
- VERW последней инструкцией перед каждым входом — там, где MDS, TAA, RFDS или MMIO stale data позволили бы гостю выудить то, что хост оставил во внутренних буферах процессора. Очищает их только форма с операндом в памяти; операнд — селектор 0.
- Сброс L1D перед каждым входом — там, где L1TF позволяет гостю читать кэш (Skylake).
На i5-13500 в EX44 это даёт enhanced IBRS, BHI_DIS_S и VERW — из-за RFDS, которому подвержены его E-ядра, — и никакого сброса L1D: ровно то же, что Linux сообщает о том же процессоре.
Сколько это стоит
hv bench на EX44, голое железо, базовая частота ядер 2,5 ГГц; вклад каждой меры измерен модулем, собранным без неё (What an exit costs):
| Выход, нс | P-ядро | E-ядро |
|---|---|---|
| без мер защиты | 1 690 | 2 065 |
| со всеми мерами | 1 818 | 2 490 |
| RSB заполняется 2 записями, а не 32 | −18 | −30 |
| без BHI_DIS_S | −22 | −287 |
| без VERW | −30 | −106 |
| без IBRS, а значит и без BHI_DIS_S | −50 | −303 |
| без всех четырёх | −97 | −431 |
Это 7% выхода на P-ядре и 20% на E-ядре, причём на E-ядре две трети приходятся на BHI_DIS_S — плату не за сам world switch, а за каждый косвенный переход, который хост делает, пока включено расширение; Linux на той же машине платит её во всём своём ядре. Полное заполнение RSB на 32 записи осталось.
Первая сборка намерила больше, и цифра сама указала на ошибку: проверки на входе выросли с 17 нс до 418, потому что вход брал три per-CPU-записи, и каждый CpuLocal::with — это серия вызовов в ядро. Теперь это одна запись, Entries, и берётся она один раз. Одна цифра так и осталась необъяснённой: при первой загрузке модуля после старта машины выход на E-ядре стоит 2,2 мкс — доли BHI_DIS_S как не бывало, хотя hv, перечитывая регистр, видит бит выставленным.
Чтобы сбой был громким
Мера защиты, которую кто-то другой тихо выключил, — это сбой, который выглядит как успех, поэтому hv не доверяет собственному учёту. Он шлёт каждому CPU, на котором включено расширение, IPI, читающий IA32_SPEC_CTRL, и предупреждает, перечисляя процессоры, если в регистре больше нет того, что выставил hv on: «something wrote it behind this module's back». hv-test.py падает, если такое предупреждение появилось.
В самом отчёте нашлась ошибка ровно такого рода: он читал флаг, который on хранит для входов, и до первого hv on EX44 — чей i5-13500 IBPB поддерживает — объявлялся машиной без IBPB. 57a3c47 спрашивает CPUID, и после insmod, до hv on, EX44 теперь пишет:
hv: guests switched on a CPU 0 times, an IBPB between each two (0 issued)
Нашлась такая ошибка и в гейте: шесть скриптов, hv-test в их числе, брали модули из out/<arch>/modules, какой бы ни была сборка, так что прогон на сборке UBSAN=1 RUSTUB=1, которым проверяли слияние половины для AMD-V (19d0d14), гонял обычный hv.ko — если только инструментированный не подложили вручную. После de70e8d они читают метку варианта сборки.
Что видит гость и кто его соседи
То, что гостю сообщают о процессоре, определяет, чем он будет защищаться сам. До d2b01bf лист 7 был пустым, а на Intel это говорит Linux, что ARCH_CAPABILITIES нет: Alpine на EX44 считал свой процессор уязвимым ко всему — изоляция таблиц страниц без PCID, полный сброс TLB на каждом системном вызове, MDS и RFDS «Vulnerable: no microcode». Теперь ARCH_CAPABILITIES читается как у хоста, за вычетом того, что не является свойством самого процессора, — IBRS_ALL не показывается, потому что SPEC_CTRL гостю не принадлежит, — и Alpine сообщает для Meltdown, MDS и L1TF «Not affected», а для RFDS — «Mitigation: Clear Register File». А под всем этим nos теперь загружает собственный микрокод машины, на EX44 — с 0x3a до 0x3e (8a6e8af): любая мера защиты хороша ровно настолько, насколько хорош микрокод под ней.
Второе соседство — гиперпотоки: два потока одного ядра делят его исполнительные блоки, кэши и буферы, а именно на этом держатся MDS, L1TF и атаки через конкуренцию за порты. Начиная с 7cceb6f ядра гостя принадлежат только ему: это целые ядра, на которых нет ни vCPU, ни дисковой задачи другого гостя (hv::place). Это не бесплатно: на AX41 два цикла, нагружающих процессор, заняли в госте 14,7 секунды на двух потоках одного ядра и 9,7 — на двух ядрах; для машины, где все гости свои, есть hv cores shared.
Что ещё открыто
Список ведётся в документации гипервизора, в разделе Before other people's guests:
- SRSO и Retbleed на Zen 1–4 — то есть на AX41 — требуют пропускать каждый
retхоста через необученный return thunk (работа компилятора для всего ядра) или ставить IBPB на каждом выходе. - BTI без automatic IBRS — снова AX41 — требует собрать хост с ретполинами.
- Собственные средства управления спекуляцией у гостя — IBRS, STIBP, SSBD, IBPB — не предлагаются никому, пока
SPEC_CTRLне переключается вместе с гостем; до тех пор гость полагается на ретполины, а store bypass у него открыт. - Программная очистка истории ветвлений для процессора с enhanced IBRS, уязвимого к BHI и без BHI_DIS_S, — Ice Lake, Tiger Lake или nos под вложенным KVM в QEMU 8.2, который этот бит прячет.
hvназывает такой процессор, а не делает вид, что всё в порядке. - L1TF через гиперпотоки: сброс не достаёт до соседнего потока, а собственные задачи хоста, включая сетевой стек, по-прежнему работают на любом ядре.
- Ни разу не выполнялось на кремнии: записи IBPB и automatic IBRS в половине для AMD-V — TCG не предлагает ни того, ни другого, а первая из них впервые выполнится на Zen 2 в AX41, у которого IBPB есть, — и пути для Skylake из ноутбука Dell: legacy IBRS после каждого выхода и сброс L1D.
Каждая мера, которая уже есть, — это строка в выводе hv, проверка в гейте и цифра с EX44; каждая, которой ещё нет, — строка в этом списке. Остальная история — в посте о месяце гипервизора nos и на странице гипервизора.