Januscape (CVE-2026-53359): гость ломает роль страницы в KVM — и хост-сервер падает вместе со всеми соседями по железу
Januscape (CVE-2026-53359): гость ломает роль страницы в KVM — и хост-сервер падает вместе со всеми соседями по железу
Представьте: вы арендовали одну виртуалку в публичном облаке — обычный тариф, ничего особенного. Внутри у вас root, потому что это ваша VM. Вы включаете вложенную виртуализацию, потому что тестируете свой собственный гипервизор внутри арендованной машины — довольно частый сценарий для CI, для лабораторий, для песочниц. И этого достаточно, чтобы обрушить не свою VM, а физический сервер целиком — вместе со всеми чужими виртуалками, которые на нём крутятся. Не через QEMU, не через баг в эмуляции устройств — а через код ядра Linux KVM, который пролежал непроверенным 16 лет. Уязвимость называется Januscape, номер CVE-2026-53359, и её нашёл исследователь Хёнву Ким (@v4bel) — тот же человек, что месяцем ранее опубликовал Dirty Frag, а до этого ITScape для ARM64.
ЧТО СЛУЧИЛОСЬ
Januscape — это use-after-free в shadow MMU эмуляции KVM/x86, общей для Intel (VMX) и AMD (SVM) кода. Баг позволяет гостевой системе, действуя исключительно на своей стороне, повредить состояние теневых страниц хост-ядра. Red Hat оценил уязвимость как Important, CVSSv3 7.0, CWE-825 (use-after-free). NVD пока не присвоил собственный балл. Ждать официального скоринга не стоит: по данным исследователя, эта же уязвимость уже использовалась как 0-day в программе Google kvmCTF, где за полный guest-to-host escape дают до $250 000.
Публичный PoC демонстрирует отказ в обслуживании — паника хост-ядра. Ким утверждает, что у него есть отдельный, не опубликованный эксплойт, который доводит тот же баг до выполнения кода с правами root на хосте — но именно этот код он выкладывать не планирует. Для наших целей это не меняет ничего: DoS-путь уже достаточно разрушителен, потому что один арендованный инстанс может положить весь физический сервер целиком, включая все остальные виртуалки на нём.
ЧТО ТАКОЕ SHADOW MMU И ПОЧЕМУ ОН ВООБЩЕ ЗАДЕЙСТВОВАН
На современном хосте с аппаратной двухуровневой трансляцией адресов (Intel EPT, AMD NPT) гостевые таблицы страниц не трогают — второй уровень трансляции из GPA в HPA делает железо, и KVM использует так называемый TDP MMU. Это путь по умолчанию (kvm-intel.ept=1, tdp_mmu=Y), и именно поэтому большинство систем этот баг никогда не видели в реальной работе.
Но как только гость (L1) сам становится гипервизором и внутри себя поднимает вложенного гостя (L2) с EPT/NPT — аппаратная трансляция теперь только одна ступень, и host (L0) обязан программно шедовить (shadow) те EPT/NPT-таблицы, которые L1 построил для L2. Именно это шедовинг вложенных таблиц идёт не через TDP MMU, а через старый, legacy shadow MMU — то есть ровно тот код, где живёт баг. Всё это происходит целиком внутри ядра: ни QEMU, ни какой-либо другой userspace VMM в процессе не участвует. Это чистый баг in-kernel KVM.
KVM хранит per-memslot структуру rmap (reverse map), которая по gfn (guest frame number) отслеживает, какие leaf SPTE (shadow page table entries) принадлежат какой теневой странице. Инвариант простой: страница устанавливается и снимается по одному и тому же ключу gfn. Именно нарушение этого инварианта и есть корень бага.
КАК УСТРОЕН БАГ
Функция kvm_mmu_get_child_sp() отвечает за то, чтобы шедовой MMU, идя по гостевым таблицам страниц, получал дочернюю теневую страницу нужного уровня. До патча решение о переиспользовании страницы выглядело так: если по данному sptep уже присутствует shadow-запись, она не большая (!is_large_pte), и gfn дочерней теневой страницы совпадает с запрошенным — переиспользуй её. Функция сравнивала только gfn. Она не сравнивала role — то есть тип этой теневой страницы.
Role в первую очередь определяется полем direct: разделяет, была ли эта страница создана как шедовинг гостевой таблицы страниц (indirect, direct=0) или как результат разбиения большой 2MB-страницы на 4KB-страницы (direct split, direct=1). Ситуация, где для одного и того же gfn нужны дети с разными role, возникает совершенно естественно в пути fetch шедовой MMU: один участок кода создаёт indirect-страницу, когда верхняя запись указывает на гостевую таблицу, другой создаёт direct split-страницу, когда большую 2MB-страницу нельзя замапить как есть и её приходится дробить на 4KB. Целевой gfn в обоих случаях может совпасть — и поскольку код не смотрел на role, при совпадении по gfn код просто возвращал -EEXIST и повторно использовал уже слинкованную страницу неправильного типа вместо того, чтобы создать новую страницу с корректной role.
Это переиспользование ссылается на теневую страницу с неверной role, что ломает учёт времени жизни и parent-указателей этой страницы. В результате возникает состояние, при котором одна теневая страница уже освобождена, но другая теневая страница всё ещё держит указатель внутрь неё — осиротевший parent-указатель.
Дальше события развиваются по одному из двух путей.
ПУТЬ USE-AFTER-FREE (потенциальный RCE)
Когда shadow MMU впоследствии очищает эту структуру и снимает осиротевший указатель, происходит цепочка: __kvm_mmu_prepare_zap_page() → kvm_mmu_unlink_parents() → drop_parent_pte() → mmu_spte_clear_no_track() → __update_clear_spte_fast(), которая пишет фиксированную константу SHADOW_NONPRESENT_VALUE (на x86-64 это BIT_ULL(63), то есть 0x8000000000000000) в тот самый slot. Ядро полагает, что «очищает один слот теневой страницы». Но если эта страница уже была освобождена и переиспользована как другой kernel-объект (жертва), запись WRITE_ONCE пишет фиксированную константу прямо в память жертвы. Это и есть use-after-free запись — атакующий-гость решает только, в какой offset внутри страницы придётся запись, но само значение фиксировано.
Ким прямо пишет, что довести этот примитив до полноценного RCE — задача нетривиальная: страница, которая переиспользуется, это spt теневой страницы, которая была zapped, вернулась в buddy allocator и потом переиспользована заново; после освобождения KVM больше не устанавливает на неё leaf SPTE, так что единственная оставшаяся запись — это одна фиксированная константа в одно место, и нужно ещё подобрать гостевой cross-cache объект, где эта запись попадёт в осмысленное поле. Именно поэтому этот путь исследователь не публикует.
ПУТЬ DOS (публичный PoC использует именно этот)
У переиспользованной direct split-страницы нет shadowed_translation, поэтому leaf gfn вычисляется как sp->gfn + index вместо использования реального сохранённого значения. Значит, leaf-запись регистрируется в rmap под настоящим gfn гостя, а при удалении её ищут уже по вычисленному gfn. Расхождение сначала ловит WARN_ONCE с сообщением «gfn mismatch under direct page» — это ещё не фатально, просто предупреждение. Фатально то, что происходит следом: когда leaf снимается при разошедшихся ключах, rmap не находит нужную запись, и срабатывает встроенная проверка целостности ядра KVM_BUG_ON_DATA_CORRUPTION в функции pte_list_remove(). На системах с CONFIG_BUG_ON_DATA_CORRUPTION=y (это значение по умолчанию на дистрибутивах вроде RHEL) в сочетании с panic_on_oops это немедленная паника хоста.
Реальный лог паники, полученный исследователем на RHEL с ядром 6.12.0-211.26.1.el10_2.x86_64, показывает оба симптома по отдельности: сперва предупреждение о расхождении gfn (gfn mismatch under direct page 8a00 (expected 8b00, got 256e4)) на одном CPU и PID, а затем на другом CPU и PID — уже фатальный kernel BUG at arch/x86/kvm/mmu/mmu.c:1091! с RIP внутри pte_list_remove.isra.0. Это именно два разных вызова: первый — просто симптом расхождения ключей, зафиксированный WARN_ONCE, второй — момент, когда rmap не находит нужную запись при удалении и срабатывает KVM_BUG_ON_DATA_CORRUPTION.
КАК УСТРОЕН ЭКСПЛОЙТ
PoC — это kernel-модуль, загружаемый внутри гостя (L1). Модуль сам напрямую строит вложенного гостя L2 через сырой VMX (Intel) или SVM (AMD), минуя QEMU. Как только L2 существует, хост L0 обязан шедовить вложенные EPT/NPT-таблицы L1 через shadow MMU — и именно в этот момент срабатывает баг переиспользования role на L0, обрушивая L0.
Ключевая конструкция — геометрия, где одна и та же физическая страница одновременно используется и как leaf большой страницы, и как страница таблицы: модуль строит вложенные таблицы страниц и образ L2 в собственной (L1) RAM функцией build_world(), при этом gfn для маппинга этой страницы как 2MB большой страницы и table_gfn для той же PDE, указывающей на неё же как на таблицу, совпадают — при разных role. Одна запись в этой таблице указывает на отдельную probe-страницу (заполненную 0x4141…), чтобы при установке leaf через переиспользованную страницу настоящий gfn (Q) расходился с вычисляемым.
Гонка идёт между потоком-writer’ом, который постоянно переключает PDE между huge и table режимами, и потоками-faulter’ами, которые многократно гоняют L2, порождая page fault’ы через эту PDE. Между моментом, когда L0 фиксирует новое значение PDE, и моментом, когда он снимает старую shadow-связь через kvm_page_track_write, существует не-атомарное окно — и если faulter попадает точно в него, kvm_mmu_get_child_sp() вызывается с новой role, пока ребёнок старой role всё ещё слинкован. С восемью гоняющими vCPU это окно расширяется за счёт конкуренции за mmu_lock. Время до триггера варьируется от секунд до минут, но при достаточном количестве гонок срабатывает детерминированно.
Дизайн одинаково работает на Intel и AMD: код бага лежит в общем arch/x86/kvm/mmu/mmu.c, который шедовят и VMX, и SVM, поэтому PoC меняет только архитектурно-специфичные биты таблиц страниц через абстракцию virt_ops — Intel EPT-биты (RWX, memory type, PS) против AMD NPT-битов (P/RW/US/PS), при этом остальная логика гонки идентична для обеих архитектур. По словам Кима, эмпирически AMD-эксплойт получается чуть проще.
ЧТО ЭТО ЗНАЧИТ НА ПРАКТИКЕ — ЗАВИСИТ ОТ КОНФИГУРАЦИИ
Для триггера с гостевой стороны нужно две вещи: root внутри гостевой VM (стандартное условие на арендованном облачном инстансе — вы обычно root над своей же машиной) и включённая на хосте вложенная виртуализация. QEMU и любой userspace VMM здесь ни при чём — баг чисто в ядре.
Важная деталь для облачных сценариев: при выделении инстанса вы обычно и так root над собственной VM, поэтому требование «загрузить kernel-модуль в госте» выполняется автоматически, если провайдер разрешает загрузку модулей. Единственное дополнительное условие сверх этого — вложенная виртуализация должна быть доступна на конкретном инстансе. Само действие по загрузке PoC-модуля — стандартная операция, доступная гостю с его собственными правами; никакого отдельного участия со стороны хоста для этого не требуется.
ARM64-хосты KVM этим конкретным багом не затронуты — общий x86 shadow MMU код туда не заходит. Но если на ваших ARM64-хостах ещё не закрыт более ранний ITScape (CVE-2026-46316, тот же исследователь), они всё равно уязвимы — к другой проблеме, но с похожим итогом.
КАК ЭТО ПРОЛЕЖАЛО 16 ЛЕТ
Диапазон уязвимого кода — от коммита 2032a93d66fa (1 августа 2010 года, эра ядра 2.6.36) до патч-коммита 81ccda30b4e8 (16 июня 2026-го, слит в mainline 19 июня). Официальный текст патча Januscape прямо ссылается на более ранний фикс в этом же коде: коммит 0cb2af2ea66ad («KVM: x86: Fix shadow paging use-after-free due to unexpected GFN», CVE-2026-46113, опубликован 28 мая 2026) уже закрывал похожее рассогласование shadow paging между хранимым и вычисленным gfn — тот баг триггерился изменением PDE-маппинга извне гостя с последующим удалением memslot, и rmap_remove() не находил запись, созданную после этого изменения. Тот патч устранял именно расхождение по gfn. Но, как прямо признаёт сам официальный changelog коммита 81ccda30b4e8, похожая дыра осталась там, где изменённая PDE указывает не на leaf, а на non-leaf-страницу: gfn в этом случае совпадает, а вот role — нет (direct=1 у исходной большой 2MB-страницы против direct=0 у новой 4KB), и именно этот случай kvm_mmu_get_child_sp() не проверял. Другими словами, майский патч закрыл одну половину проблемы с совпадением/несовпадением gfn и role, а Januscape — вторую половину той же самой логической дыры.
Причина живучести в том, что shadow MMU — legacy-путь, который на подавляющем большинстве современных хостов вообще не выполняется в обычной работе: аппаратный EPT/NPT и TDP MMU полностью его обходят. Код активируется только при вложенной виртуализации — сценарии, который тестируют куда реже, чем базовую работу VM. Малое количество живого трафика через этот код напрямую означает малое количество шансов случайно наткнуться на баг в проде, и почти никакой fuzzing-нагрузки в реальных условиях.
TIMELINE
Хронология раскрытия — от первого репорта до публикации — заняла меньше месяца, и это стоит того, чтобы разложить по датам. 12 июня 2026 года исследователь направил детальную информацию об уязвимости и рабочий эксплойт на [email protected]. 13 июня прошло обсуждение с мейнтейнерами KVM Паоло Бонцини и Шоном Кристоферсоном о том, как обрабатывать патч, и Бонцини написал исправление. 17 июня патч был выложен на lore.kernel.org для тестирования перед pull request. 19 июня коммит 81ccda30b4e8 был слит в mainline. 1 июля информация об уязвимости и эксплойте была направлена в рассылку linux-distros с эмбарго на 5 дней. 4 июля уязвимости присвоили номер CVE-2026-53359. 6 июля, после окончания эмбарго, информация была опубликована в рассылке oss-security вместе с write-up исследователя.
Обратите внимание на разрыв между 19 июня (патч уже в mainline) и 4 июля (только тогда появился CVE-номер) — почти две недели, когда фикс уже был публично виден в git-истории ядра, но без привязки к CVE его было практически невозможно найти через обычный трекинг уязвимостей по номеру.
ПОЧЕМУ ЕГО НАШЛИ ИМЕННО СЕЙЧАС
Это уже третье громкое раскрытие ядра Linux от Кима за примерно два месяца: в мае — Dirty Frag (CVE-2026-43284 и CVE-2026-43500), цепочка записи в page cache в духе Dirty Pipe и Copy Fail; в июне — ITScape (CVE-2026-46316), первый публично продемонстрированный guest-to-host escape на KVM/arm64 через гонку в виртуальном контроллере прерываний. Januscape продолжает ту же линию — целенаправленный, систематический разбор legacy-кода shadow MMU и вложенной виртуализации, а не случайная находка. Ким также подчёркивает, что Januscape успешно использовался как 0-day submission в Google kvmCTF — программе, которую Google запустил в 2024 году именно потому, что KVM лежит в основе и Android, и Google Cloud.
ПОЧЕМУ ЭТО ВАЖНО
Граница между гостем и хостом — это фундаментальная гарантия безопасности всей мультитенантной облачной инфраструктуры. Если она пробивается guest-side действиями без всякого участия QEMU или хостового администратора, под ударом оказываются не только те, кто сознательно включает вложенную виртуализацию у себя — под ударом все соседи по физическому серверу. Публичных облаков вроде GCP и AWS это касается напрямую там, где арендаторам разрешена вложенная виртуализация: один клиент, оплативший единственный дешёвый инстанс, может обрушить хост-ядро и забрать с собой все VM других клиентов на этой же машине.
Для операторов частных облаков и хостинг-провайдеров, предоставляющих VPS с поддержкой nested virtualization (для CI/CD, для тестовых лабораторий, для вложенных контейнерных платформ), риск ещё конкретнее: единственный недовольный или скомпрометированный клиентский аккаунт становится источником отказа для всех остальных клиентов на том же гипервизоре.
ЧТО ДЕЛАТЬ
Первым делом проверьте, применён ли патч, а не полагайтесь на номер версии ядра — дистрибутивные бэкпорты часто несут исправление под другим номером версии, чем в апстриме. Посмотрите текущую версию ядра и сверьте changelog вашего пакета на предмет коммита 81ccda30b4e8 или фразы «shadow paging use-after-free due to unexpected role»:
# текущая версия ядра
uname -r
# Debian / Ubuntu
apt-get changelog linux-image-$(uname -r)
# RHEL / AlmaLinux
rpm -q --changelog kernel-$(uname -r)
Только по номеру версии судить нельзя — бэкпорт может нести фикс под старым номером релиза, поэтому ищите именно упоминание патча в тексте changelog.
Исправленные stable-версии ядра вышли 4 июля 2026 года: 7.1.3, 6.18.38, 6.12.95, 6.6.144, 6.1.177, 5.15.211 и 5.10.260. Red Hat зарегистрировал проблему как CVE-2026-53359 (Bugzilla 2497033), Important, CVSSv3 7.0, с исправлениями для RHEL 6–10; AlmaLinux выпустил тестовые сборки для веток 8, 9 и 10 — если вы на AlmaLinux, проверьте статус патча именно в тестовом репозитории, прежде чем полагаться на стабильный. Если ваша версия ядра ниже соответствующей ветки — обновите пакет ядра штатным менеджером пакетов дистрибутива и перезагрузите хост, чтобы патч действительно загрузился в память, а не остался только на диске:
# Debian / Ubuntu
apt update && apt install linux-image-generic && reboot
# RHEL / AlmaLinux
dnf update kernel && reboot
Проверить, включена ли на хосте вложенная виртуализация, можно так:
# Intel
cat /sys/module/kvm_intel/parameters/nested
# AMD
cat /sys/module/kvm_amd/parameters/nested
Значение Y или 1 означает, что вложенная виртуализация включена и путь к багу открыт для любого гостя, у которого есть root внутри своей VM.
Если немедленное обновление ядра невозможно — временная мера: отключить вложенную виртуализацию. Выгрузите и перезагрузите соответствующий модуль с параметром nested=0:
# Intel
rmmod kvm_intel && modprobe kvm_intel nested=0
# AMD
rmmod kvm_amd && modprobe kvm_amd nested=0
Чтобы это сохранилось после перезагрузки хоста, добавьте параметр постоянно через файл конфигурации modprobe и обновите initramfs:
# создать конфиг (Intel; для AMD замените kvm_intel на kvm_amd)
echo "options kvm_intel nested=0" | sudo tee /etc/modprobe.d/kvm-nested-disable.conf
# Debian / Ubuntu
update-initramfs -u
# RHEL / AlmaLinux
dracut -f
Учтите: если на этом хосте модуль KVM уже используется запущенными VM, выгрузить его сразу не получится — потребуется либо остановить все VM перед выгрузкой, либо просто выставить параметр через конфиг и перезагрузить хост целиком.
Согласно исследователю, отключение nested virtualization полностью убирает единственное дополнительное условие, необходимое для триггера бага с гостевой стороны, — ценой потери функциональности вложенной виртуализации там, где она реально нужна легитимным пользователям. Взвесьте это против риска: если у вас нет клиентов или внутренних команд, которым нужна вложенная виртуализация, отключать её стоит вне зависимости от статуса патча — это просто снижает поверхность атаки.
ВЫВОДЫ
Для операторов публичных и частных облаков с x86 KVM-хостами, принимающими недоверенных гостей: обновление ядра до патченной версии — не опционально, а критично, особенно там, где арендаторам разрешена вложенная виртуализация. Если патч пока не докатился через дистрибутив — отключайте nested virtualization немедленно, это единственная реальная временная мера.
Для системных администраторов, управляющих собственными гипервизорами: проверьте changelog ядра на предмет коммита 81ccda30b4e8, а не полагайтесь на номер версии, и убедитесь, что вложенная виртуализация включена только там, где она действительно нужна.
Для клиентов облачных провайдеров, которые сами используют вложенную виртуализацию (тестовые среды, CI, контейнерные платформы поверх VM): уточните у своего провайдера статус патча — вы физически зависите от того, обновил ли хост-ядро кто-то другой, а не от собственных действий внутри своей VM.
