Здравствуйте, меня зовут Анна Мелехова. Я старший архитектор в отделе развития архитектуры KasperskyOS. В статье я хочу поделиться практическим опытом системной разработки, которой я занималась сначала в проекте по виртуализации, а теперь в «Лаборатории Касперского», где мы делаем микроядерную операционную систему с повышенными требованиями к безопасности — KasperskyOS. Когда вы работаете в такой среде, быстро понимаете: харденинг — это не красивые галочки в чек-листе, а набор очень конкретных, очень практических решений, которые должны и защищать, и минимально снижать производительность. О них я и расскажу, а в конце дам личный топ самых полезных харденингов, которые бустят security и не снижают performance.

От чего мы вообще защищаемся?

Я начну с образа, который, возможно, многим уже знаком, но который идеально ложится на тему безопасности. В книге «Алиса в стране чудес» Льюиса Кэрролла героиня спрашивает Чеширского кота, куда ей идти. Тот отвечает, что это зависит от того, куда она хочет попасть. Если все равно, чем закончится путь, можно идти куда угодно. Так же и в безопасности. Система не абстрактно безопасна — нужно понимать, от чего мы защищаемся и каким именно образом наши меры защиты помогут. Поэтому давайте обсудим, зачем нам нужны харденинги и какие из них предпочесть. Если вы «в теме», то переходите сразу к разделу «Защиты и «их стоимость».

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

Атаки на ядро

При атаках на ядро операционной системы злоумышленник может пытаться решить несколько задач. Самая привлекательная цель для атакующего — найти и использовать уязвимость выполнения произвольного кода непосредственно в ядре (ACE, arbitrary code execution). Это позволит атакующему читать и менять память любых приложений, останавливать или перезапускать службы и произвольным образом управлять оборудованием. Также атакующему могут быть интересны и данные самого ядра, ведь в нем могут содержаться необходимые для атаки секреты. Часть security-подсистемы чаще всего располагается в ядре даже в микроядерной ОС, а у монолитов там может найтись сетевая подсистема, источники случайных чисел, влияющие на криптографию и так далее. А это дает возможность видеть и изменять нешифрованный сетевой трафик.

Еще одна задача (самая интересная с технической точки зрения), которую злоумышленники могут пытать решить атакой на ядро, — закрепление в системе (persistency), то есть способ, благодаря которому вредоносный код сможет выживать после перезагрузки ОС и даже после установки обновлений. Часто закрепление осуществляется через запись в ROMFS, что в принципе должно быть запрещено. Но из-за частных несовершенств процесса на стороне IDM (Independent Device Manufacturer) эти запреты не всегда проставлены. Если хотите почитать об этом больше или проверить свое железо, посмотрите проект CHIPSEC для архитектуры x86.

Атаки на приложения

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

От «Hello, переполнение стека» до сложных цепочек

Еще лет 15–20 назад, атаки выглядели почти наивно. Классическая схема: переполняем буфер на стеке, например Си-строку с окончанием '\0', перезаписываем адрес возврата и прямо на стек кладем код зловреда. При возвращении из функции управление передастся на сформированный нами код на стеке. Сегодня так уже не получится.

Во-первых, сейчас эволюционировали стандарты индустрии и сертификации. Те же требования безопасности ФСТЭК России или ГОСТ P 56939-2024 по защите безопасного программного обеспечения принуждают нас задумываться о безопасности.

Во-вторых, сейчас доступны новые, мощные инструменты для повышения качества кодовой базы. Например, статические анализаторы наподобие PVS-Studio, которые ловят многие ошибки еще на этапе разработки, в некоторые фреймворки встроены фаззеры, динамический анализ доступен «из коробки».

Кроме того, сейчас о безопасности много говорят в публичном пространстве. Обсуждение memory-safe-языков, трендов типа Make C++ Great Again идет непрерывно.

Конечно, все не так просто. Memory-safe-языки действительно существуют. Но это не всегда повод переписывать старый код. Так, в Google выяснили: при переписывании кода, например, на Rust появляются новые ошибки. В итоге старый, проверенный код часто оказывается безопаснее, чем переписанный.

В исследовании, опубликованном в 2022 году в журнале USENIX Security, обнаружилось, что подавляющее большинство уязвимостей находится в новом или недавно измененном коде.

Время жизни уязвимостей
Время жизни уязвимостей

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

static long keasy_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
  long ret = -EINVAL;
  struct file *myfile;
  int fd;
  myfile = anon_inode_getfile("[easy]", &keasy_file_fops, NULL, 0);
  fd = get_unused_fd_flags(O_CLOEXEC);
  if (fd < 0) {
  ret = fd;
  goto err;
  }
  fd_install(fd, myfile);
  if (copy_to_user((unsigned int __user *)arg, &fd, sizeof(fd))) {
  ret = -EINVAL;
  goto err;
  }
  return 0;
err:
  fput(myfile);
  return ret;
}
Что за ошибка

Да, разработчик забыл закрыть файловый дескриптор в ветке обработки ошибок.

  ret = -EINVAL;
  goto err;

На первый взгляд все, чем это грозит, — утечет файловый дескриптор. Это, конечно, неприятно — надо экономить память и файловые дескрипторы — но не катастрофично.

Однако атакующий может использовать это для получения полного контроля над системой. Сначала применяется эксплойт-примитив UAF (Use-After-Free) на fail-путях. Это означает, что после завершения программы злоумышленник сможет через референсы контролировать значения в файловом дескрипторе. То есть в kernel heap получается небольшая область, в которую можно писать из user space. Да, писать не напрямую, а делать инкремент, но это тоже немало.

Дальше в ход идет техника heap spray: через последовательность аллокаций и освобождений атакующий может добиться того, что на странице с контролируемым значением не останется занятых элементов, то ест страница станет свободной. Рано или поздно свободную страницу с какой-то вероятностью ядро начнет использовать под PTE (page table entry). А если злоумышленник контролирует хотя бы одну PTE, он сможет мапировать произвольную пользовательскую страницу в адресное пространство ядра. Это уже прямой путь к полному захвату системы. Прониклись, как быстро растут ставки? От файлового дескриптора на fail-путях до разворачивания полноценного эксплойта. Те, кто хочет совсем впечатлиться, почитайте описание современных атак — например, в блоге Александра Попова.

Защитные меры и их «стоимость»

На странице Linux Kernel Defense Map Александра Попова можно посмотреть 100+ харденингов как для ядра, так и для пользовательского пространства. Мы же попробуем посмотреть некоторые из них: от самых простых до более изощренных. Помните, вначале мы разделяли атаки на ядро и на приложения? Некоторые из харденингов актуальны только для ядра, но есть и общие. Начнем именно с них.

Нападение #1: базовая атака

Атака из старой эпохи, о которой мы говорили в начале. Никто сейчас так не действует, но отключать эту защиту нельзя, слишком уж просто такой уязвимостью воспользоваться. Для атаки нужно переполнение буфера при обработке входных параметров. Атакующий подготавливает входной буфер так, чтобы переписать адрес возврата на стековый адрес и на стеке же расположить код. Возврат из функции приведет к исполнению shellcode, который оказался после переполнения на стеке. Защита от этого очевидна — зачем вообще стеку быть исполняемым? Давайте скомпилируем программу так, чтобы он не был исполняемым. Это будет самый базовый харденинг, который входит в категорию DEP/NX-защит, — неисполняемый стек. Мы можем просто запретить выполнение кода на уровне таблиц страниц, то есть аппаратно.

Нападение #2: Новичок почитал Stack Overflow

Следующий уровень атаки — вместо shellcode злоумышленник располагает в стеке адрес, по которому и будет находиться функция, делающая все самое плохое. То есть при выполнении возврата из функции управление передастся на EvilFunction, которую до этого злоумышленник смог разместить в адресном пространстве приложения. Защита от этого — стековая канарейка (stack canary). Это как канарейка в шахте: она умирает первой при опасной концентрации газов. Если хотите углубиться в тему, очень советую статью Маши Недяк — там подробно разобрано, как правильно внедрять канарейку и какие сюрпризы вас могут ждать.

Реализуется эта защита просто: перед локальными переменными в стеке кладется контрольное значение (канарейка), которое проверяется перед выходом из функции. Если оно изменилось, значит, что-то пошло не так. Единственный минус — замедление работы, особенно при большом количестве функций.

Альтернативой канарейке является подход ShadowCallStack. В нем адреса возврата хранятся в отдельной области памяти, защищенной от записи из обычного кода. Это снижает накладные расходы и часто дает выигрыш в производительности по сравнению с классическим stack protector.

Я хочу заострить ваше внимание вот на чем: поменяв опцию компилятора со stack_protector на shadow_call_stack, на том же железе, не потеряв в безопасности, мы получили 3–4% прироста производительности ipc для KasperskyOS. Для микроядра, как вы понимаете, это классный результат.

Код с картинки

clang: -fstack-protector

main:
sub sp, sp, #320
stp x29, x30, [sp, #272]
str x28, [sp, #288]
stp x20, x19, [sp, #304]
add x29, sp, #272
adrp x8, :got:__stack_chk_guard // read canary
add x0, sp, #8
mov w2, #256
add x20, sp, #8
ldr x8, [x8, :got_lo12:__stack_chk_guard]
ldr x8, [x8]
stur x8, [x29, #-8] // store
ldr x1, [x1, #8]
bl strncpy
..
adrp x8, :got:__stack_chk_guard // read canary
ldr x8, [x8, :got_lo12:__stack_chk_guard]
ldur x9, [x29, #-8] ldr x8, [x8] // value from stack
cmp x8, x9
b.ne .LBB0_7
.LBB0_7:
bl __stack_chk_fail

clang: -fsanitize=shadow-call-stack -ffixed-x18

main:
str x30, [x18], #8 // x18 is used for scs; store
sub sp, sp, #304
stp x29, x30, [sp, #256]
str x28, [sp, #272]
stp x20, x19, [sp, #288]
add x29, sp, #256
ldr x1, [x1, #8]
mov x0, sp
mov w2, #256
mov x20, sp
bl strncpy
..
ldr x30, [x18, #-8]! // restore ret from scs
ret

Нападение #3: ROP chain

Продолжаем. Наши хакеры эволюционируют и уже разворачивают ропчейны (ROP chain, а также COP, JOP). Смысл в том, что злоумышленник не размещает зловредную функцию, а переиспользует кусочки существующего кода атакуемого приложения. Поскольку количество ассемблерных инструкций ограничено, в достаточно большом бинарнике, среди множества инструкций и функций, скорее всего получится собрать нужную цепочку для исполнения. У злоумышленников уже есть специальные инструменты, которые позволяют такое делать. На иллюстрации ниже можно увидеть ROP-гаджет. Злоумышленник может строить цепочку из операций return, call или jmp-кусочков, а также перезаписывая vtable-адреса или callback-функцию.

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

Одна из базовых защит от ропчейнов — это техника ASLR (Address Space Layout Randomization). Она перемешивает расположение библиотек, стека, heap и исполняемого кода, так что атакующему сложнее предсказать, где находится нужная ему инструкция. Сам ASLR работает в основном на загрузке приложения/системы и почти не снижает скорость. Разве что затрудняет отладку. Однако ASLR требует, чтобы код был position-independent — PIE. A это само по себе незначительно (~3%) снижает производительность.

Нападение #4: если ASLR обошли

Впрочем, ASLR легко можно обойти, если разработчик был невнимателен — достаточно не инициализировать данные на стеке и передать их содержимое в ответ на запрос или напечатать адрес функции в лог. Тогда на помощь приходит более основательный харденинг — CFI (Control Flow Integrity). Есть разные имплементации этой техники: на уровне компилятора, на уровне ОС, на аппаратном уровне. В общем суть следующая: проверяется, что переходы в коде происходят только в заранее разрешенные точки. Я рассматриваю CFI на уровне компилятора. Так вот, CFI очень классная технология, но она требует LTO (Link Time Optimization), а это, к сожалению, влечет свои накладные расходы. Во-первых, у вас увеличивается время линковки, во-вторых, нормально так может увеличиться потребление памяти. А сам CFI влияет на код очень несильно. По официальным данным clang, мы теряем около 1% производительности, но зато получаем много безопасности.

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

И здесь интересная история: чем больше бинарник, тем больше коллизий по хешу, тем меньше защищает CFI. Девиз Do One Thing And Do It Well начинает играть новыми красками.

Нападение #5: неинициализированные переменные

Частая ошибка разработчиков — неинициализированные переменные. Из-за этого могут утечь данные из приложения или ядра. Или атакующий сможет поместить в неинициализированные области свои данные. От влияния таких ошибок защитит опция компилятора vars-autoinit — инициализация всех переменных значением по умолчанию. Это предотвращает утечки данных и использование «мусора» в памяти.

Звучит классно, но и тут есть подвох. Разработчики языка C не стали утверждать в стандарте инициализацию переменных значениями по умолчанию не потому, что не догадались. Они оставили разработчикам выбор — производительность vs безопасность. Если включить vars-autoinit, то мы видим, что производительность снижается. Так что в продакшене применять это ко всему коду дорого, но для тестов и для компонентов 3rd party — самое оно.

Личный топ

Итак, обещанный личный топ харденингов.

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

Второе — надо добавить stack protector или ShadowCallStack.

И третье — обязательно включаем CFI и ASLR.

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

  1. Еще до харденингов необходимо разделить данные и работу с ними по процессам. Используйте SOLID или старый Unix-подход (Do One Thing And Do It Well), но пусть данные будут максимально инкапсулированы внутри процессов и привилегий для работы будет минимум. Если у вас процесс принимает данные из Интернета, разбирает их на части и делает платежи, то лучше бы у вас был один процесс с доступом в Сеть и один с доступом к платежам. Принцип Least Privilege стоит применить еще до всех низкоуровневых митигаций.

  2. Необходимо разделить данные внутри процесса — const, ro_after_init. Ro_after_init — штука простая в реализации и концептуально интересная. Так промаркированы некоторые данные в Linux Kernel, и это значит, что их можно записать в момент инициализации системы, а потом они закрываются для записи. Например, нельзя будет перезаписать канарейку или базу сегмента. Это достигается отдельной секцией для данных с таким тегом, и в конце инициализации у маппинга секции «отламывается» Writeable-бит.

  3. Если есть возможность, используйте memory-safe-языки. Если нет — тюнингуйте C/C++ так, чтобы он не был оружием массового поражения (asan, ubsan, msan + фаззинг и статический анализ).

  4. Включайте харденинги, о которых написано в статье. Лучше через «железные» фичи. Конечно, каждую опцию необходимо осмысливать, но чтобы облегчить задачу, дам вам несколько готовых наборов. Воспринимайте их как отправную точку, а не как пункт назначения. Итого, изюминка статьи и DYI-рекомендации — в следующей главе.

Набор компиляторных флагов для безопасности

Наш набор делится по уровням, каждый следующий уровень включает предыдущий. Уровни соответствуют, во-первых, разной степени доверия к компонентам, а во-вторых, категориям кода — основной рабочий код, тесты и 3rd party. То, что в тестах можно позволять себе некоторые вольности, это, конечно, спорное утверждение. Но наша задача не усложнить жизнь разработчикам, а действовать максимально разумно. Те требования безопасности, которые невозможно/сложно выполнить, не выполняются. С 3rd party же речь о том, что некоторые опции не сильно повышают качество/надежность, но требуют значительных изменений, что часто негуманно по отношению к стороннему коду.

Нулевой набор компиляторных опций для безопасности

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

-fsanitize=cfi -flto -Wcast-function-type-strict (fstack-protector-strong для Интел|fsanitize=shadow-call-stack, ffixed-x18 для арм) -Wl,-z,relro

Ну и не забудьте опции, специфичные для вашей платформы. Для нашей платформы (KasperskyOS), например, не нужен stack clash, но для вашей он может быть полезен.

Базовый набор компиляторных опций для безопасности

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

-werror -wall -wextra -wno-unused-parameter -wno-sign-compare -Wpointer-arith -Wunknown-pragmas -Wcast-qual -Wstrict-prototypes -Wcast-align=strict -Wundef -Wmisssing-prototypes -Wwrite-strings -Wformat=2 -Wformat-security -Wbad-function-cast -Wshift-count-overflow -Wshift-count-negative -Wshift-sign-overflow -Wstrict-overflow=4 -Wswitch-default -Wswitch-enum -Wenum-conversion -Wassign-enum

Дальше идет все большая «жесть», все-таки мы разрабатываем безопасную ОС. И как-нибудь мы напишем отдельную статью о нашем SDL и о том, как мы тюнили требования. А пока могу еще порекомендовать несколько статей о сборке проектов с фокусом на безопасность (не всегда с учетом производительности и удобства, но об этом я уже вам много рассказала).

Полезные статьи

Compiler Options Hardening Guide for C and C++ | OpenSSF Best Practices Working Group

Getting the maximum of your C compiler, for security | Security-related flags and options for C compilers

Writing C for curl | daniel.haxx.se

C-Based Toolchain Hardening - OWASP Cheat Sheet Series

Финал: безопасность и производительность не враги

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

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


  1. Demanoidos
    26.11.2025 20:28

    Спасибо за статью. Сходу, навеяло - https://www.youtube.com/watch?v=Kqd7k5F-YBI


  1. ikashapov
    26.11.2025 20:28

    Красиво написано, но даже базовой возможности работать со смарт-картами с KasperskyOS нет, используйте в место этого пароли.

    Хотел входить с защищенного терминала с KasperskyOS по RDP используя смарткарту, а такого функционала нет и сказали что и не будет, только пароль.