[Перевод] Что GCC делает для усиления защиты ядра?

Усиление защиты ядра Linux — это задача, предполагающая постоянную работу сразу по нескольким направлениям. И иногда эта работа может быть выполнена даже не в самом ядре, а с помощью других инструментов, или даже в компиляторах. На конференции 2023 GNU Tools Cauldron Цин Чжао (Qing Zhao) рассказала о работе, проделанной в компиляторе GCC для укрепления ядра, а также о работе, которую еще предстоит проделать.

Проект Kernel self-protection является отправной точкой для львиной доли работ по укреплению ядра, — начала она. Усиление защиты может быть выполнено несколькими способами, начиная с исправления известных ошибок безопасности, которые могут быть обнаружены с помощью статических анализаторов, фаззеров или инспекции кода. Однако исправление ошибок — это задача, не имеющая конца, и гораздо лучше, когда у нас есть возможность полностью исключить целые классы ошибок. Таким образом, большая часть усилий по укреплению ядра была направлена на устранение таких проблем, как переполнения стека и кучи, целочисленные переполнения, инъекции форматных строк, утечки указателей, использование неинициализированных переменных, use-after-free уязвимости и т.д. Также ведется работа по блокированию разных методик эксплойтов, включая возможность перезаписи текста ядра или указателей функций.

[Qing Zhao]

Qing Zhao

По ее словам, в релизе GCC 11 (апрель 2021 г.) появилась возможность обнуления регистров, используемых функцией, при возврате из нее; это позволяет предотвратить утечку информации. Теперь эта функция включена по умолчанию. В GCC 12 (май 2022 г.) добавлена автоматическая инициализация стековых переменных, которая также включена по умолчанию для сборок ядра. В GCC 13 (апрель 2023 г.) добавлена более строгая обработка динамических массивов-членов.

Чжао кратко упомянула о некоторых функциях, которые сообщество разработчиков ядра хотело бы видеть в будущих версиях компилятора. Среди них — улучшенная поддержка проверки динамических массивов, уменьшение количества ложных срабатываний предупреждений при использовании опции -warray-bounds, улучшение проверки целочисленных переполнений, поддержка проверки целостности потока управления и еще многое другое.

Возвращаясь к динамическим массивам, Чжао отметила, что выход за границы массива является одним из основных источников уязвимостей в ядре. Их можно предотвратить с помощью проверки границ — если известен размер массива. Для статических массивов размер известен на этапе компиляции, поэтому проверка доступа к массиву может быть выполнена как на этапе компиляции (если это возможно), так и на этапе выполнения. Однако с динамическими массивами дела обстоят сложнее. В языке C такие массивы имеют две формы: массивы переменной длины и динамические массивы-члены структур; в ядре на сегодняшний день используются только последние.

Динамический массив-член — это массив, заключенный в структуру в качестве конечного элемента. Он часто объявляется как имеющий размерность либо ноль, либо единица (хотя последнее часто является источником ошибок), либо просто как array[]. Когда выделяется место для экземпляра структуры, его размер должен быть достаточно большим, чтобы вместить реальный массив, длина которого будет варьироваться от экземпляра к экземпляру.

В GCC 12 все массивы, определенные как конечный член структуры, считаются динамическими, независимо от объявленного размера массива. Поэтому даже объявленный ниже массив:

struct foo {
        int int_field;
	int array[10];
    };

будет считаться компилятором динамическим по размеру, хотя это (скорее всего) не входило в намерения разработчика; в результате проверка границ при обращении к таким массивам не выполняется. В GCC 13 опция -fstrict-flex-arrays позволяет контролировать, какие массивы считаются динамическими; в этой статье приведен детальный разбор того, как она работает. Это в конечном результате облегчает проверку границ массивов, размер которых не должен меняться.

Однако проблемы все же еще существуют: Чжао упомянула случай, когда структура содержит динамический массив в качестве члена, будучи, в свою очередь, вложенной в другую структуру:

struct s1 {
        int flex_array[0];
    };

    struct s2 {
	type_t some_field;
	struct s1 flex_struct;
    }

Даже если динамическая структура является конечным членом содержащей ее структуры (s2 в примере выше), версии GCC ниже 14 будут некорректно воспринимать массив как статический. Чжао внесла свой вклад в исправление этой проблемы. Отдельная проблема возникает, когда динамическая структура не является конечным членом содержащей ее структуры. В этом случае неясно, что компилятор должен делать, но GCC, тем не менее, принимает такие структуры. Новая опция -Wflex-array-member-not-at-end будет предупреждать о таком коде.

Еще одной проблемой являются динамические массивы-члены объединений: GCC принимает такие члены, если они объявлены как array[0], но не принимает (совершенно легальную) форму array[]. Это делает невозможным создание объединений, которые будут компилироваться в самом строгом режиме -fstrict-flex-array. С объеденениями, содержащими в качестве членоа только динамические массивы, возникает другая проблема: они могут оказаться объектами нулевой длины, что не допускается стандартом C. Пока эту проблему решает добавление члена фиксированной длины; возможно, в будущих итерациях GCC будет сделана попытка разрешить полностью динамические объединения.

Использование динамических массивов в настоящее время не позволяет выполнять проверку границ. Однако фактическая длина любого массива известна (или, по крайней мере, должна быть известна) коду в процессе его выполнения. Если этот размер каким-либо образом может быть сообщен компилятору, то проверку границ все-таки можно будет добавить. Существует два возможных способа объявления этой информации; один из них заключается в добавлении нового синтаксиса для встраивания этой информации в сам тип:

struct foo {
        size_t len;
	char array[.len*4];
    };

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

Альтернативным вариантом является добавление атрибута к динамическому массиву-члену. Это сохраняет существующий ABI, проще в принятии и может быть распространено на другие типы (например, указатели). С другой стороны, его сложнее распространить на более сложные выражения. В GCC 14 был добавлен атрибут counted_by () без поддержки выражений; пока он может ссылаться только на другое поле в той же структуре.

struct foo {
        size_t len;
	char array[] __counted_by(len);
    };

В этом случае поле len может быть использовано только для непосредственного определения размера массива, никакие выражения тут не допускаются. Пока этот атрибут работает только для размера самого динамического массива; в будущем, возможно, удастся довести его до такого состояния, когда, например, он будет предупреждать о том, что объем памяти, выделенной под структуру, недостаточен для размещения массива.

Ведутся разговоры о том, чтобы распространить эту проверку и на значения указателей; компания Apple предложила более сложный флаг -fbounds-safety (для LLVM), реализующий эту идею. Это надстройка над существующим поведением counted_by(); его реализация и принятие потребуют больших усилий, но если он окажется востребован, то обязательно будет рассмотрен позже.

Проверка границ полезна только в том случае, если проверки корректны, поэтому существование ложно срабатывающих предупреждений также является большой проблемой. В частности, код, оптимизированный с использованием jump threading проходов, может выдавать такие ложные срабатывания. Один из аспектов этой проблемы был исправлен в GCC 13, в то время как другой до сих пор остается открытым. Эта проблема препятствует включению по умолчанию -Warray-bounds в сборках ядра. Существует несколько идей о том, как пометить код, в котором был использован jump threading, и подавить возникающие при этом предупреждения.

Отдельной проблемой является обнаружение целочисленного переполнения. В стандарте С переполнение определено для беззнаковых целочисленных значений, но не определено для знаковых значений и указателей. Для неопределенного случая GCC предоставляет опции либо для определения ожидаемого поведения, либо для обнаружения переполнения. Однако для беззнакового переполнения такой выбор отсутствует, поскольку его поведение вполне определено. Но беззнаковое переполнение часто бывает непреднамеренным, и на самом деле было бы неплохо его обнаруживать. Возможно, считает она, необходимо создать новую опцию, позволяющую обнаруживать переполнение в таком случае.

Возвращаясь к знаковому переполнению, она отметила, что опция -fwrapv делает поведение определенным; при переполнении будет происходить перенос значения переменной. Но, хотя ядру и нужно, чтобы переполнения отлавливались большую часть времени, иногда есть случаи, где это должно быть разрешено. Флориан Ваймер (Florian Weimer) отметил, что сейчас существует встроенный механизм, который можно использовать для отключения проверки определенных операций; Чжао сказала, что изучит этот вопрос.

На этом время, отведенное на доклад, закончилось, и Чжао не смогла перейти к обсуждению опций целостности потока управления. Однако картина, сложившаяся по итогам сессии, была ясна. Было проделано достаточно много работы по улучшению GCC, чтобы он мог помочь в защите ядра (и других программ, конечно). Но, как и многие другие работы, задача защиты ядра от злоумышленников, похоже, никогда не потеряет актуальности. В обозримом будущем разработчикам, как компилятора, так и ядра, будет чем заняться.

Материал подготовлен в рамках онлайн-курса «Administrator Linux. Professional». Чтобы узнать, соответствует ли ваш уровень знаний C++ программе курса, пройдите вступительный тест.

© Habrahabr.ru