Привет, Хабр!

Меня зовут Анна Мелехова, я старший архитектор ПО в «Лаборатории Касперского». Вместе с командой занимаюсь разработкой микроядерной KasperskyOS.

Безопасность микроядра часто воспринимается как почти очевидный тезис, то есть трюизм: микроядро компактное, драйверы вынесены в user space, а значит, поверхность атаки меньше и система в целом безопаснее. Но за этим базовым утверждением скрывается множество менее очевидных инженерных решений — случаев, когда количественное сокращение приводит к качественно иной архитектуре безопасности.

В этой статье, основанной на моем докладе для OS DevConf 2025, разберем, почему механизмы харденинга могут эффективнее работать в микроядре, чем в монолите; как «закрутить гайки» при передаче данных из user space; и насколько отличаются защитные механизмы в микроядерной архитектуре и в монолитной.

Кто я и о чем эта статья

В ИТ я уже больше 20 лет, и значительная часть этого времени связана с системным программированием. Я всегда хотела разрабатывать операционные системы и в каком-то смысле начала именно с этого. Виртуальная машина — моя первая «серьезная» ИТ-работа — была сопоставима с ОС по функциональности и объему в те стародавние времена, когда еще не было аппаратной поддержки виртуализации.

Позже мне захотелось задач посложнее, и я ушла в архитектуру, а затем в безопасностью микроядерную операционную систему KasperskyOS. С моим бэкграундом работа в «Лаборатории Касперского» выглядела одновременно как тавтология и при этом как возведение всего предыдущего опыта в куб: я стала заниматься безопасностью безопасной операционной системы в компании, которая специализируется на безопасности.

И эта статья будет о безопасности применительно к операционным системам. Но поскольку я все-таки архитектор, предлагаю посмотреть на этот вопрос через призму архитектурных решений.

Трюизмы о микроядрах: TCB, attack surface, syscalls — и где они спорны

Про безопасность микроядер говорят давно. Некоторые исследователи вообще сомневаются, что можно сказать что-то новое по этой теме, ведь еще в 1992 году Эндрю Таненбаум спорил с Линусом Торвальдсом про преимущества микроядерной архитектуры над монолитами и заключил спор утверждением «Микроядра победили». В поддержку тезиса о безопасности микроядра обычно приводят два аргумента: маленький объем кодовой базы и маленькая поверхность атаки.

Объем кодовой базы

Разница в объеме кодовой базы между монолитным ядром и микроядром действительно огромная. В Linux Kernel уже более 40 миллионов строк кода. Для конкретной аппаратной конфигурации объем реально используемого кода может снижаться до 4,5 миллионов строк, а при особенно жесткой настройке — до 2–2,5 миллионов. Но у микроядер другой порядок цифр: обычно речь идет о десятках тысяч строк кода и лишь в редких случаях — о сотнях тысяч. Разница колоссальная.

Отсюда следует, казалось бы, очевидный вывод: чем меньше кода, тем меньше багов; чем меньше багов, тем меньше возможностей для атаки. Но на практике все, как обычно, сложнее. Дело в том, какой код входит в доверенную вычислительную базу — TCB, Trusted Computing Base.

В микроядре драйверы принято выносить в user space, потому что им не доверяют. Поэтому, например, баг в драйвере сетевой карты, работающем в user space, не затрагивает ядро. Это уже серьезное архитектурное преимущество: успешная компрометация драйвера не означает автоматическую компрометацию всей системы (через доступ к ядру, к другим драйверам, к привилегированной функциональности). Но сетевой стек может быть целью атаки сам по себе, а уязвимость в сетевом драйвере использоваться для дальнейшего продвижения внутри атакуемой инфраструктуры. Поэтому аргумент про объем кодовой базы важен, но не исчерпывает всю модель безопасности.

В KasperskyOS изоляция драйверов дополняется контролем их взаимодействия с другими процессами и с ядром. За это отвечает reference monitor — механизм, который работает на основе заданных политик безопасности.

Поверхность атаки

Похожая история с поверхностью атаки. Один из привычных аргументов в пользу микроядер звучит так: в них меньше системных вызовов, а значит, меньше точек входа в ядро.

На первый взгляд все действительно так. В Linux системных вызовов больше четырехсот, хотя точное число зависит от версии ядра и конфигурации. Но в любом случае речь идет о сотнях системных вызовов, тогда как в микроядрах их около десятка.

При этом сервисов ядра, то есть функциональности работающей в ring0, может быть с десяток, а системных вызовов все равно три. Как это возможно?

Все зависит от того, как считать.

Разница кроется в том, что Джон Остерхоут в книге A Philosophy of Software Design называет глубокими API: интерфейс может выглядеть компактно, но скрывать за собой большой объем функциональности. В такой модели Send(), Recv() и Reply() работают как базовые IPC-примитивы. Они передают не только данные, но и идентификаторы конкретных операций, например: аллокации памяти, создания процесса или старта таймера.

Иными словами, вызовов в микроядре, конечно, меньше чем в монолите, да и функциональности тоже меньше, но не так мало как кажется после поверхностного сравнения.

Не только трюизмы: конструктивные преимущества микроядер (W^X, copy_from_user, LSM)

Если мы копнуть глубже, то интересны не метрики сами по себе, а конкретные конструктивные свойства, которые появляются из-за такой архитектуры.

Меньший размер микроядра начинает работать на безопасность, когда позволяет уменьшить число критичных точек внутри ядра и централизовать проверки, которые в большой системе разнесены по разным подсистемам.

Это хорошо видно на конкретных механизмах: W^X, проверках данных из user space и контроле чувствительных операций через security hooks.

Пример 1. Про eXecutable бит

Intel реализовала non eXecutable bit более двадцати лет назад. Этот бит в таблице страниц используется для защиты: исполняться могут только те страницы памяти, для которых бит NX сброшен. Выставление NХ бита при загрузке исполняемого файла и формировании адресного пространства процесса спасает как от случайных ошибок, так и от планомерных атак. Дело в том, что злоумышленнику очень хочется исполнить в контексте атакуемого процесса свой вредоносной код. А этот код («полезную нагрузку» — payload) нужно в процесс внедрить — провести code injection.

Особенно привлекательны для реализации этого вектора страницы памяти, которые одновременно доступны для записи и исполнения: writable + executable. При наличии W+X страниц развитие хак-примитива от Arbitrary-Write до Arbitrary-Code-Execution проходит достаточно легко, а значит для defense in depth такие страницы нужно убрать, а лучше вести строгий учет всех eXecutable страниц.

В монолитных ядрах задачу W^X (либо писать, либо исполнять) и контроля NX страниц решают несколькими способами.

  • Выставляют NX-бит для всех неисполняемых секций бинарника (ELF/PE/Mach-O/etc). Так система явно разделяет участки памяти, где код может исполняться, и участки, которые должны оставаться только данными.

  • Запрещают сочетание writable + executable. На ARM для этого используется механизм WXN. Похожий подход применяет Apple на своем кастомизируемом процессоре: для JIT-трансляций (just in time) у них есть быстрое переключение прав страницы (w->x->w) с fast permission restrictions. Но и эту функциональность Apple оставляет только для приложений с jit (com.apple.security.cs.allow-jit).

  • Сканируют page table на наличие сочетания writable + executable. В Linux для этого используется DEBUG_WX.

  • Проверяют checksum каждой исполняемой страницы при #PF. В Darwin этим занимается vm_fault_cs_handle_violation: Apple использует такой механизм, чтобы убедиться, что страницы с кодом не были изменены и никто ничего в них не записал.

А вот если у вас микроядро, то аллокация кода и его парсинг не обязательно должны происходить в ядре.

В KasperskyOS ELF-файл парсит отдельный процесс, который создает исполняемые страницы и инжектит их в таргетный процесс. Таким образом процессу не обязательно иметь возможность создавать себе исполняемые страницы. И значит на уровне политик ОС можно запретить eXecutable аллокации почти всем процессам (кроме jit и нескольких привилегированных компонентов ОС). Это позволяет полностью закрыть один из каналов атаки.

Трюк выполнен профессионалами, не пытайтесь повторить его в монолитной операционной системе ?.

Пример 2. copy_from_user

Еще один важный класс проблем связан с передачей данных из user space в kernel space и обратно. В Linux это copy_from_user/copy_to_user. Ядро и драйверы получают из пространства пользователя аргументы для исполнения системных вызовов и возвращает туда результаты. Но данных много, они разные и при их передаче всегда есть риск ошибки: скопировать больше или меньше (N+1), ошибиться с типом (type confusion), не освободить данные вовремя (UAF). При этом каждая функция/системный вызов сам ответственен за парсинг данных и, значит, вероятность ошибки на тысячах таких передачах крайне высока. Но и цена ее высока — утечка секретов и повреждение критичных данных в результате действия непривилегированного процесса.

В монолитных ядрах эту проблему решают несколькими способами.

  • Используют HARDENED_USERCOPY в Linux.

  • Применяют алгоритмы, которые проверяют целостность критичных данных, например lkrg.

  • Дополнительно изолируют критичные данные, например по модели VBS-like software TEE в Microsoft.

Но это дополнительные харденинги, создающие пенальти по производительности и не устраняющие источник проблем, а лишь снижающие ее влияние. Микроядерная архитектура KasperskyOS на этом фоне выигрывает за счет двух факторов: центральной точки обмена и типизации сообщений.

Существует пирамида проверок, которые должны выполняться на входных данных: откуда пришло сообщение, какого оно размера, соответствует ли оно ожидаемому формату, проходит ли лексические проверки и так далее.

В микроядре нижние уровни этих проверок можно централизовать — помните, мы говорили о 3-10 системных вызовах. При этом, если проверяющий модуль знает о формате сообщения, то есть проверки типизированные и не «тупые». Это позволяет централизованно обработать базовые ошибки ввода и заранее обезопасить систему от целого класса проблем. Пользу проверок мы смогли подтвердить практически: при внедрении ядерного фаззинга через syzkaller количество ошибок было до смешного мало при таком большом покрытии. Да, нашлись несколько race-ов, но вспомните как внедрялся фаззинг в Linux, и как кратно увеличилось число багов тогда.

Пример 3. Multiple LSM hooks: в чем проблема

LSM расшифровывается как Linux Security Module. LSM hooks — это точки расширения, через которые можно перехватывать выполнение отдельных системных вызовов. Перехват могут осуществлять загруженные security-модули, которые подключаются на старте системы.

То есть появляется механизм, который выносит вердикт — разрешить системный вызов или нет. Но сразу возникает вопрос: могут ли какие-то вызовы пройти в обход этого механизма?

Сейчас в Linux 224 hook-а и 463 системных вызова. Не все вызовы нужно «хукать», так как не все являются «чувствительными». Но покрыты ли хуками все security-sensitive-операции? И нет ли гонок или путей обхода вердикта LSM?

Почти 20 лет назад Трент Джегер и его коллеги в своих работах озадачились этими вопросами и провели анализ корректности размещения LSM-хуков. Они выявили один TOCTOU-сценарий и пришли к добавлению пяти новых LSM-хуков. Об этом шла речь в статьях Using CQUAL for Static Analysis of Authorization Hook Placement, Consistency Analysis of Authorization Hook Placement in the Linux Security Modules Framework и Maintaining the Correctness of the Linux Security Modules Framework. Были предложены инструменты и методология. Однако, это не стало частью постоянной практики сообщества, в отличие от syzkaller. Поэтому текущее состояние дел остается неочевидным.

В противовес сложной системе LSM hook-ов вспоминаем о менее чем десятке системных вызовов в микроядре. Вот где можно вставить точку контроля и быть уверенным, что ее не обойдут. Кода так мало, что можно даже применять формальную верификацию и доказывать, что при выполнении системного вызова эта точка контроля обязательно будет вызвана.

В KasperskyOS модуль безопасности, применяющий политики, зовется на всех системных вызовах в ядро и на всех IPC (кросс-процессных) вызовах. И обойти это нельзя. Хоть доказать формально это все еще не так просто.

Формальные методы и сложность верификации

Кстати, о формальных методах. Значение формальной верификации для безопасности хорошо видно по нормативной базе: во ФСТЭК, ISO 26262 и Common Criteria высокие уровни доверия к продукту подтверждаются в том числе формальными методами.

Показательно, что среди известных формально верифицированных ОС почти нет больших монолитных ядер. Исторически формальная верификация чаще применялась к микроядерным или близким к ним архитектурам. Это логично: чем меньше ядро и чем проще модель взаимодействий, тем реалистичнее описать систему формально и доказать ее свойства.

Формальная верификация важна не только для абстрактных свойств ядра, но и для конкретных механизмов изоляции.

Проблема верификации монолита укладывается в подход Трента Джегера, одного из заметных исследователей в области безопасности операционных систем. Он сформулировал набор критериев, по которым можно оценивать безопасность ОС и пришел к выводу:

…сочетание ядра Linux и фреймворка LSM слишком сложно для полной формальной верификации, которая потребовалась бы для доказательства полного контроля доступа (complete mediation) и защиты от несанкционированного вмешательства (tamper-proofing).

Трент Джегер о безопасности Linux в книге Secure Operating Systems

Харденинги: KASLR и kCFI

https://github.com/a13xp0p0v/linux-kernel-defence-map/

Но это все безопасность с высоты птичьего полета. Все знают, что даже если архитектура системы выстроена аккуратно, полностью исключить уязвимости невозможно. Поэтому помимо изоляции, политик доступа и формальной верификации нужны механизмы харденинга.

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

Есть прекрасная карта Linux Kernel; на изображении выше — лишь небольшой ее фрагмент. Всего hardening-механизмов в Linux несколько сотен, и есть специальные конфигурации, которые их включают. Мы поговорим о двух из них.

KASLR (Kernel Address Space Layout Randomization)

Когда злоумышленник пытается атаковать какой-либо компонент, ему важно знать адреса: где находится нужная структура данных, где — функция, которой можно передать управление.

Если эти адреса постоянно меняются, «прыгают» и остаются непредсказуемыми, атакующему становится значительно сложнее построить рабочую эксплуатацию.

KASLR — хороший защитный механизм, но и его нередко обходят в монолитах типа Linux. И тут есть два аспекта.

Во-первых, в адресном пространстве ядра монолитной ОС очень «тесно». Чем теснее, тем меньше пространства для рандомизации. Поэтому, например, для x86 и нового загружаемого kernel modules рандомизация приводит лишь к небольшому «дрожанию» адресов. То есть модуль не гуляет по всему адресному пространству от начала до конца, а лишь слегка смещается в рамках 2MB. Этого уже достаточно, чтобы усложнить жизнь атакующему. Но в микроядре можно сделать больше.

Микроядро маленькое: в нем меньше кода и меньше сущностей, которые нужно размещать в памяти. Поэтому отдельные фрагменты кода могут перемещаться гораздо свободнее и активно передвигаться меняя даже относительный порядок.

Но есть и вторая существенная причина, почему KASLR в микроядре работает «сильнее». В ядре нет сторонних драйверов, а значит, они не могут случайно выдать ядерные адреса атакующему из user-space, записав в лог лишние данные или передав наружу какой-нибудь offset.

За счет этого KASLR в микроядерной архитектуре оказывается эффективнее. А вот Linux приходится регулярно чинить атаки и сканировать ядро специальными скриптами в поисках некорректных записей в лог.

kCFI

Технология Clang KCFI — Kernel Control Flow Integrity — позволяет контролировать, откуда и куда передается управление внутри ядра.

На практике контроль строится не как полная проверка каждого перехода, а через проверку типов допустимых косвенных вызовов. Для этого используют хеши: для допустимых переходов вычисляются значения, которые затем проверяются при выполнении. И когда мы слышим слово «хеши», сразу возникает закономерный вопрос о коллизиях.

А дальше то самое количество, переходящее в качество: чем больше кода физически, тем выше вероятность коллизий между функциями.

Что в данном случае означает коллизия? Если злоумышленник пытается изменить цепочку вызовов и построить свою, он может использовать любую из 359 функций, которые попали в одну коллизионную группу, и таким образом передать управление дальше. И вот опять микроядро внезапно выигрывает.

Не только архитектура: исторические проблемы монолитов

Монолитные системы — Windows/Linux/OS X — существуют на рынкеуже более 3 десятилетий. Это большое достижение: у них огромная база пользователей, приложений, драйверов и сценариев использования. С одной стороны — это прекрасно, когда продукт используют по всему миру. С другой — пользователи ожидают, что старый софт продолжит работать и в новых версиях системы. Так постепенно накапливается наследие, от которого не так просто отказаться.

При этом надо понимать, что тогда, три десятка лет назад, вопросы безопасности не стояли так остро. Уже был Reflections on Trusty Trust, уже провели анализ безопасности multics и чуть позже даже отрефлексировали и его, но это не было такой доминантой. И потому во многих современных монолитах, и в Linux в частности, безопасность — фича не врожденная, а благоприобретенная трудами титанов. Однако и их титанических усилий не всегда хватает, чтобы поменять устоявшийся порядок вещей.

Например, структура прав и привелегий в Linux, на мой взгляд, не была спроектирована как цельная модель, а сложилась стихийно.

Из-за этого и возникают неприятные эффекты. Год назад была атака Plague, и в ней был достаточно обычный момент: модуль в user space отключал историю SSH, всего лишь сбросив переменную окружения (HISTFILE).

С одной стороны, почему бы процессу не дать возможность изменять свои переменные окружения, молодец Linux. С другой стороны, почему repudiation для процесса строится на переменных окружения и может быть отключен им изнутри. Ответ — так исторически сложилось, сложно что-то поменять.

https://www.nextron-systems.com/2025/08/01/plague-a-newly-discovered-pam-based-backdoor-for-linux/

Другая часть исторического наследия — количество механизмов, которые сейчас есть в Linux Kernel для контроля доступа и разделения прав.

На практике такую комбинацию механизмов достаточно сложно настраивать. Предсказать вердикт по операции с учетом суперпозиции механизмов и их несовместных состояний достаточно сложно даже для профессионалов. Да и использовать эти механизмы родом из 90-х сейчас нелегко. На это жалуются, например, разработчики Android в секции про historical development из Android security paper.

https://venam.net/blog/pdf/access_control/access_control.pdf

Еще одна проблема связана с той моделью ЭВМ, на которую Linux и другие ОС опираются. Если посмотреть на современные аппаратные схемы, выясняется, что многие устройства, которые раньше считались глупыми периферийными компонентами, стали едва ли не умнее центрального процессора. У них есть собственная память, своя система команд, множество внутренних механизмов и собственный высокопривилегированный код — прошивка (firmware).

Привилегии прошивки в современных ОС достаточно высоки. ОС вынуждена разрешать прошивке многое: маппировать нужную ей физическую память, доставлять прерывания, потому что иначе прошивка откажет и устройство перестанет работать к неудовольствию пользователя.

Полностью изолироваться от такого устройства уже не получается: оно слишком тесно встроено в работу системы. Хотя уже есть движения по изоляции и депривилегированию хотя бы части прошивок например у Apple.

https://www.usenix.org/conference/osdi21/presentation/fri-keynote

Что дальше?

Если вы думаете, что я предлагаю все выбросить и переписать с нуля, то это совсем не так. Есть очень точная цитата признанного архитектора Джона Галла. Он рассуждает о том, что происходит, когда команда решает выбросить какой-нибудь legacy-компонент и переписать его с нуля:

«Сложная система, которая работает, почти всегда вырастает из простой системы, которая работала. Сложная система, созданная с нуля, не работает и не чинится патчами до рабочего состояния. Приходится начинать заново — с простой работающей системы».

Поэтому к работающим системам относятся с уважением: понимают, почему они стали именно такими, что в них уже работает, что можно улучшить и в какую сторону стоит двигаться дальше.

Как я писала в начале, индустрии нужны новые эволюционные шаги: более строгая изоляция, меньшие зоны доверия, более понятные политики и более доказуемые механизмы безопасности. И такие шаги уже происходят.

Например, в Linux для графических драйверов после серии атак и длительных исследований к концу 2025 года предложили фильтрацию доступа на уровне ioctl через расширение SELinux-политик.

А в 2022 году вышла очень интересная обзорная статья — систематизация исследований о том, как можно разделять монолитное ядро на compartment, то есть изолированные фрагменты.

Один из примеров такого подхода — HAKC, исследовательская работа о разделении ядра на изолированные compartment-ы. В ней используются memory tagging, pointer authentication и собственный ARM API. За счет этого ядро делят на compartment-ы и пытаются изолировать драйверы, которым нельзя полностью доверять.

Пока такие подходы чаще остаются академическими или экспериментальными. Но многие технологические сдвиги начинались именно в университетах и исследовательских лабораториях. Поэтому, думаю, в ближайшие годы мы все чаще будем видеть эти идеи в практических системах.

Общий тренд уже заметен: часть механизмов и функций постепенно выносится в user space, недоверенные компоненты изолируются, а монолитные ядра все чаще заимствуют свойства, которые исторически ассоциировались с микроядерной архитектурой.

Заключение

Вывод здесь чуть сложнее, чем просто тезис «микроядро безопаснее, потому что в нем меньше кода».

Да, размер кодовой базы действительно важен. В микроядре меньше кода, меньше базовых системных вызовов и меньше мест, где может возникнуть ошибка. Это упрощает анализ, повышает шансы на формальную верификацию и делает некоторые харденинг-механизмы эффективнее.

Но аргумент про «меньше кода» не стоит воспринимать слишком буквально. Драйвер, вынесенный в user space, не исчезает из системы и не перестает быть критичным компонентом. Он все еще может быть частью TCB, а значит, может быть атакован, и сетевой стек все равно остается поверхностью атаки. Поэтому микроядро выигрывает не просто за счет количества строк, а за счет того, как именно устроены границы доверия и взаимодействие между компонентами.

При этом монолитные ядра тоже развиваются в ту же сторону, и есть шанс, что они изживут свои security-недостатки, сохранив performance достоинства.

Именно этим я хочу завершить статью. Не нужно спорить абстрактно, что лучше — монолит или микроядро. Лучше смотреть, какие архитектурные свойства дают реальный выигрыш в безопасности, и привносить эти свойства туда, где они могут сработать.

Комментарии (1)


  1. ant3mc
    16.07.2026 14:20

    Большой перевод seL4 Whitepaper, который является хорошим введением в микроядро seL4.
    https://habr.com/ru/articles/1036890/
    Здесь не только специфика seL4 и микроядер вообще, но и много полезного в целом по ОС, ИТ-безопасности, формальной верификации и системам жёсткого реального времени.