Хабр, привет! Меня зовут Мария Недяк, я специализируюсь на разработке харденингов нашей собственной микроядерной операционной системы «Лаборатории Касперского» KasperskyOS. Если вкратце: мы стараемся сделать любые атаки на нашу ОС невозможными — или хотя бы очень дорогими

Один из главных инструментов в нашей нелегкой работе — «канарейка» (ну или Stack Canary), которая защищает от базовой атаки переполнения стека. Лично я к работе с этой птичкой уже давно привыкла — набила руку во время многократных CTF-турниров, где без такого харденинга было никуда… Этот бэкграунд очень пригодился мне в «Лаборатории Касперского», когда перед нашей командой встала задача усилить «канарейку» в KasperskyOS.
В статье я подробно объясню, как работает Stack Canary, как ее ломают — и как от этих методов взлома защититься. Сразу скажу: тема непростая, так что для самых любопытных я оставила список полезной литературы в конце текста. Поехали!
Часть 1. Как работает канарейка
Стоп, а что это вообще за птичка такая?..
Стековая канарейка — один из самых взрослых харденингов, то есть одна из первых попыток предотвратить определенные атаки на уровне ОС. Чтобы понять смысл работы Stack Canary, обратимся к истории этого термина.
Почему вообще вот этот механизм назвали канарейкой? Cкорее всего, название пошло от шахтерской практики начала 20-го века. Тогда, не имея нормальных детекторов газа, работники брали с собой в шахты канареек. Если птичка теряла сознание, это был сигнал о том, что концентрация газа достигла вредного для здоровья уровня и пора выбираться из шахты.
Так же работает и Stack Canary: предупреждает ОС о потенциальной атаке и позволяет завершить работу до начала вредоносной операции. Как именно канарейке это удается — расскажу ниже!
С какими уязвимостями борется канарейка?
Для начала поговорим про уязвимости, для борьбы с которыми так необходим этот харденинг. Яркий пример — переполнение стека данными.
Предположим, у нас есть код, написанный на Си. Хочу отметить, что речь идет не о коде из репозиториев «Лаборатории Касперского», — это синтетический пример.
Пусть он проверяет некий пароль.
void check_password() { char password[12]; scanf(“%s”, password); // handle password // … } int main(int argc, char **argv) { check_password(); return 0; }
check_password — функция проверки пароля. Под него функция аллоцирует буфер на стеке, затем считывает пароль с пользовательского ввода.
Совершенно случайно в коде может затесаться некоторая функция admin_panel следующего вида:
void admin_panel() { system(“/bin/sh”); } void check_password() { char password[12]; scanf(“%s”, password); // handle password // … } int main(int argc, char **argv) { check_password(); return 0; }
Для начала давайте подробнее рассмотрим функцию check_password() и ее стек фрейм.
void check_password() { char password[12]; scanf(“%s”, password); // handle password // … }
Исполняя инструкцию call, процессор сохраняет на стек адрес возврата. Это нужно, чтобы знать, откуда продолжать выполнение после того, как отработает функция check_password(). Также на стек сохраняется контекст функции main и base pointer (bp).
Далее, начиная выполнение, функция аллоцирует статический буфер password. В нашем примере его размер 12 байт.

Но в коде функции check_password() есть проблема. Мы аллоцировали фиксированный размер буфера, но не передали это ограничение в scanf — это значит, что длина вводимых пользователем данных никак не контролируется. Злоумышленник может передать в стек не 12 байт, а больше, перезаписав стек, где лежит адрес возврата в функцию main. Вместо него он вполне может указать адрес своей функции admin_panel.

Таким образом, после возврата из функции check_password() злоумышленник может сломать систему всего одной строчкой и исполнить свой код.
Как же от этого защититься?
В теории можно написать идеальный код без багов. Но, увы, — это не защитит от подобных уязвимостей в сторонних библиотеках. Как разработчики ОС, мы можем добавить канарейку, которая позволяет проверить, что состояние стека после выполнения check_password() осталось тем же, каким было до. По сути, мы оставляем метку на стеке и с ее помощью проверяем, что адреса возврата не перезаписались.
int canary_value = 0xcafebabe; … void check_password() { int canary = canary_value; char password[12]; scanf(“%s”, password); … if (canary != canary_value) { // ALARM; DO NOT CONTINUE!!!! } } …
Здесь у нас генерируется случайное значение — канарейка. Начиная исполнение функции, мы вставляем это значение на стек до аллокации всех локальных переменных. А в конце функции проверяем, что значение не перетерлось.

Если, используя ту же атаку, попытаться перезаписать вместе со статическим буфером password еще и адрес возврата, канарейка прервет выполнение кода — программа упадет. Это лучше, чем отдать сервер злоумышленнику.

Канарейку не нужно вставлять в код вручную. Есть готовые решения на уровне компилятора. Вот соответствующие ключи для GCC и Clang:

Поддержка этого механизма есть и в Windows:
MSVC: /GS (Buffer Security Check)
В нашей ситуации аналогично — стековая канарейка падает первой, но в итоге — единственной жертвой атаки.
Часть 2. Как ломают канарейку
Если злоумышленник будет заранее знать значение канарейки, он сможет перезаписать стек незаметно, вернув его на свое место. Но int canary_value = 0xcafebabe — случайное значение, которое инициализируется на старте процесса, значит, у нас остается два варианта: узнать это значение или вписать туда свое.
Теперь рассмотрим распространенные ОС, чтобы понять, какие атаки на канарейку там реализуемы.

Перезаписываем канарейку
Идея в том, чтобы поменять значение канарейки на свое. Во время атаки его же можно записать в стек, так что конечная проверка пропустит атаку, а злоумышленник получит возможность исполнения своего кода.
Перезапись будет работать при двух условиях:
-
мы знаем, где лежит глобальная канарейка;
-
у нас есть возможность записи в эту память.

Как защититься от перезаписи?
Достаточно простое решение — хранить канарейку в readonly-памяти, в которую у нас нет прав на запись. Если попробовать в нее записать, сработает механизм ОС, который уронит процесс, поскольку в нем что-то происходит не так.
В Windows канарейку положили в Data-секцию, которая на запись недоступна. В OpenBSD ввели свою секцию, также закрытую для записи. А в glibc пошли уникальным путем, про который расскажу чуть подробнее.
Glibc — стандартная библиотека распространенных Debian-систем (если вы используете Ubuntu, скорее всего там будет glibc). В этой библиотеке канарейка может храниться в двух местах:
-
если для архитектуры реализован Thread Local Storage (TLS), то канарейка хранится в нем;
-
если TLS не реализован, канарейка хранится в секции data.rel.ro, которая закрывается на запись, когда отработает динамический линковщик.
Если с data.rel.ro все понятно, то к хранению в TLS есть некоторые вопросы.

Thread Local Storage — это очень узкая тема для системных разработчиков. Если вы пишете на Cи или C++, вероятно, когда-нибудь клали свои переменные в TLS рядом с потоком, чтобы быстрее к ним добраться. Но хранить канарейку в TLS не совсем рациональное решение.
Заглянем в стек функции check_password(). Если обратиться к реализации glibc, область TLS у дочерних потоков находится в начале стека:

В Thread Local Storage в блоке Thread Control хранится разная служебная информация, там же лежит глобальная канарейка (значение, с которым будет происходить сравнение после выполнения функции). Нюанс в том, что эта область также доступна на запись. Стек — один, поэтому злоумышленник может переполнить не только локальную, но и глобальную канарейку, добавив еще байт в свою атаку.

Если интересно, в этом примере можно посмотреть расстояния между локальной и глобальной канарейками. У меня получалось что-то около 1000 байт.
Разработчики glibc не просто так положили канарейку в TLS. Это хранилище быстрое, соответственно, доступ к канарейке дает совсем небольшой оверхед по памяти. Но если так подумать, канарейка в glibc — это все-таки старая реализация. Если бы ее писали сейчас на современных процессорах, разработчики не задумываясь положили бы ее в data-секцию, потому что обращение к данным там происходит через rip-адресацию, а значит тоже не дает большого оверхеда (при этом канарейка была бы закрыта на запись). Хранить канарейку в TLS в 2025 году — не совсем здорово.

Читаем локальную канарейку
Перейдем к следующему классу атак — чтение канарейки. Узнать канарейку можно несколькими способами:
-
можно предсказать канарейку;
-
угадать канарейку;
-
и прочитать со стека или глобально.
Про предсказание канарейки я не буду много рассказывать. Важно знать, что есть злоумышленники — гуру энтропии, которые могут по источнику энтропии (то есть зная, что источник энтропии слабый или энтропия использовалась неправильно) просто предсказать канарейку. Это сложный и интересный класс атак, но их рассмотрение выходит за рамки моей статьи.
Угадать канарейку помогает системный вызов fork ядра Linux. Возможно, вы встречали в своей практике такой паттерн программирования.
У нас есть сервер, который принимает запросы от клиентов. Fork позволяет скопировать логику обслуживания в отдельный процесс. Это кустарное решение балансировки нагрузки, которое перекладывает задачу на ОС.

Системный вызов fork — это явное копирование процесса, при котором память не меняется. Соответственно, канарейка, инициализированная при старте родителя, также не изменится.
Fork «ломает» не только канарейку, но и очень много других харденингов, например ASLR (чуть подробнее об этом механизме расскажу далее). Злоумышленник может много раз подключаться и довольно быстро перебрать эти 8 байт, в итоге их угадав.
Прочитать канарейку немного сложнее. Нужно, чтобы программа была уязвима и предоставляла злоумышленнику возможность читать какие-то лишние данные, например, через функцию strcpy. Возможно, вы слышали, что эту функцию нельзя использовать, потому что это не модно. И этот пример показывает почему.

Предположим, у нас есть канарейка, пароль в стеке, и он не содержит null в конце строки. Если в программе есть код, который копирует пароль в буфер и этот буфер находится под контролем злоумышленника:
strcpy(password, your_buf);
то в этом случае функция strcpy скопирует не только пароль, но и все данные дальше — пока не встретит null-символ на стеке. То есть вместе с паролем утечет и канарейка, и адреса возврата, и прочее.
Чтобы защититься от такой атаки, можно вписать ноль в канарейку, пожертвовав энтропией.

У такой канарейки есть собственное название — Terminator Canary. Именно она реализована в glibc.
Читаем глобальную канарейку
Для чтения глобальной канарейки тоже нужно некоторое удачное (для злоумышленника) стечение обстоятельств:
-
он должен знать адрес глобальной канарейки;
-
должен иметь возможность прочитать с этого адреса.
Легко получить адрес злоумышленнику мешает Address Space Layout Randomization (ASLR). Это харденинг, который на старте программы распихивает ее сегменты в разные места, — то есть каждый раз канарейка будет лежать в других местах адресного пространства. Если у злоумышленника получится узнать, где лежит канарейка для одного процесса, то в другом процессе он не сможет использовать эти знания.
Часть 3. Как мы усиливали канарейку
Если коротко, «усиление» канарейки стало ключевым элементом улучшения защиты нашей ОС.
Что хотели сделать?
Мы планировали, чтобы на практике от атакующего по итогу требовалось больше действий, что делало бы вероятную атаку куда менее вероятной и более трудозатратной для злоумышленника. Благодаря уникальной per-function и изменению прав доступа к самой канарейке, процесс потенциальной атаки должен был состоять из таких этапов:
-
чтение локального стека, чтобы узнать ebp;
-
чтение глобальной канарейки (до этого нужно узнать ее адрес, то есть обойти aslr);
-
так еще и произвести то самое переполнение стека, с которого ранее еще и узнали ebp.
Как именно прошло улучшение?
Обновление можно разделить на два этапа:
-
Повышение защиты доступа. При изменении прав доступа к нашей канарейке (чтобы она была Read-Only во время работы) мы решили перенести ее в data.rel.ro. Это специальная секция, данные в которой помечаются Read-Only после инициализации. Эта секция является частью PT_GNU_RELRO-сегмента, который задействован в механизме защиты GOT (Global Offset Table), который закрывает другой класс бинарных эксплуатаций. (Если интересно узнать про этот механизм подробнее — пишите в комментариях.)
-
Добавление уникальности канарейке. При реализации per-function в Clang нам немного повезло: так как per-function-уникальность в Clang уже была реализована до нас для Windows, нам оставалось только ее включить для KasperskyOS. Но мы пошли немного дальше и сделали per-function-уникальность для всех поддерживаемых архитектур, Windows же делали в Clang только для x86-архитектуры. (Справедливости ради, у Windows есть свой дефолтный компилятор MSVC, с которым подобных проблем нет.)
Часть 4. Методы защиты и выводы
Как защититься от чтения?
Если от перезаписи мы могли защититься элементарно — просто закрыв права на запись, то с чтением так не получится, нам самим нужны эти данные. Поэтому разработчики ОС пошли дальше и несколько видоизменили канарейку — «поксорили» ее, что дало уникальность канарейки в каждой функции. Злоумышленнику стало сложнее использовать полученную информацию.
Например, Windows делает xor с ebp, а OpenBSD — с return address (свой механизм они называют RETGUARD, но по сути это тоже своего рода канарейка).
Почему при этом информацию сложнее использовать?
Предположим, у нас есть две функции и соответствующие им стеки. В одной функции злоумышленник нашел уязвимость и смог прочитать канарейку, но она поксорена с ebp. Допустим, он даже смог восстановить исходное значение канарейки. Но этой информации ему будет недостаточно, чтобы проэксплуатировать уязвимость на втором стеке, — ему не хватит адреса ebp. Разработчики ОС полагают, что реальные программы сложнее задачек из университета, а значит, и проэксплуатировать уязвимость становится очень сложно (много стеков, много функций).
В итоге были проведены следующие действия:
-
Чтобы защититься от простого чтения со стека или глобально в Windows, канарейку поксорили с ebp. Кроме того, там нет системного вызова fork. А нет вызова — нет проблемы:)
-
В OpenBSD канарейку поксорили с return address, при этом от угадывания они никак не могут защититься, потому что основаны на ядре Linux, которое предоставляет системный вызов fork.
В glibc канарейку не ксорили и от чтения никак не защитили. Также у них есть вызов fork. Если взять Ubuntu и собрать простейшую программу с канарейкой (в Си даже флаги сейчас не надо передавать — канарейка по умолчанию включена), то можно увидеть, что канарейка используется как есть и совпадает с глобальной.
Вот как это работает:
<check_password>: push rbp mov rbp,rsp sub rsp,0x20 // аллоцируем память на стеке mov rax,QWORD PTR fs:0x28 // берем канарейку из tls (про то, что мы упоминали ранее) mov QWORD PTR [rbp-0x8],rax // кладём канарейку на стек ... /* check_password's logic */ ... mov rdx,QWORD PTR [rbp-0x8] // берем канарейку со стека sub rdx,QWORD PTR fs:0x28 // вычитаем канарейку из tls je 1379 <check_password+0x84> // если получился 0 (то есть канарейка со стека совпадает с глобальной из tls), то выходим из функции call 10a0 <__stack_chk_fail@plt> // иначе бросаем ошибку leave ret
В KasperskyOS, как я говорила ранее, мы положили канарейку в data.rel.ro-секцию. Она недоступна на запись. Кроме того, мы поксорили ее с ebp, то есть пропатчили компилятор. В KasperskyOS также нет системного вызова fork, поэтому нам не нужно защищаться от угадывания.

Общие выводы: что делать?
Я разобрала, наверное, самую базовую уязвимость и такой же базовый харденинг ОС. Для себя сделала вывод, что канарейка не так проста и на нее было интересно смотреть, когда ты пытаешься не просто сломать ее, но и сделать более безопасной. Такой харденинг может быть полезен — стоит его включать и проверять наличие других харденингов в проекте.
Кроме этого, для успешной работы с харденингом очень помогут следующие аспекты:
-
Включайте флаги компиляции, причем не только финального проекта, а каждого юнита компиляции. Распространенная ошибка — финальный проект собирается с канарейкой, но какая-то библиотека была собрана без нее (или некоего другого харденинга).
-
Если не получается использовать флаги компиляции, можно применить тулинг для проверки финального бинаря. На просторах Интернета можно поискать такие решения — в большей степени они ориентированы на злоумышленников, но как пример можно попробовать hardening-check.
-
Главное и основное, что можно сделать, чтобы такие атаки были невозможны, — не допускать ошибок работы с памятью. В этом помогут статические или динамические анализаторы кода. Они подскажут, где может происходить переполнение стека и что нужно сделать.
-
Используйте фаззинг — это техника, которая рандомно пихает данные в надежде на срабатывания переполнения. Без санитайзеров он бессмыслен, поэтому рекомендую подключать и их. Например, чтобы отловить переполнение, нужен Address Sanitizer.
Чего ждать дальше?
Из разобранных примеров видно, что на некоторые харденинги полностью полагаться нельзя. Нужно предпринимать совокупность действий и держать руку на пульсе — ходить по конференциям, читать новости. От переполнения стека индустрия пытается защититься уже лет 25, но все еще задача решена не до конца. Возможно, еще лет через 25 мы и не вспомним про такую уязвимость. Разработчики железа вводят защиту на своем уровне — создают два стека (механизм shadow stack), один из которых теневой. Исполняя инструкцию call, процессор кладет адрес возврата и на основной, и на теневой стек, причем ко второму у программы нет никакого доступа. По завершении выполнения функции процессор проверяет, совпадает ли адрес возврата с тем, который лежит на теневом стеке. Этот харденинг работает. Но даже если мы справимся с переполнением стека, останутся другие уязвимости.
Кроме того — если вы тоже уже канарейку съели набили руку на прокачке харденингов и защите от переполнения стека, — скорее откликайтесь на наши свежие вакансии! Мы ищем Си-разработчиков, которые готовы придумывать нетривиальные способы сделать операционную систему более защищенной и укрепить микроядро
Что почитать на тему канареек:
ссылка на оригинал статьи https://habr.com/ru/articles/893064/
Добавить комментарий