Как стать автором
Обновить
2765.06
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Использование подсказок, включаемых в исходный код, помогающих GCC выявлять случаи переполнения буфера

Время на прочтение11 мин
Количество просмотров4.4K
Автор оригинала: Martin Sebor
Ошибки, связанные с доступом к областям памяти, которые находятся за пределами допустимого адресного пространства (out-of-bounds memory access), в 2021 году всё ещё пребывают в списке самых опасных уязвимостей ПО CWE Top 25. Известно, что ошибочные операции записи данных (out-of-bounds write, CWE-787) с двенадцатого места, которое они занимали в 2019 году, перешли в 2020 году на второе. А неправильные операции чтения данных (out-of-bounds read, CWE-125) в тех же временных пределах сменили пятое место на четвёртое.



Понимание важности раннего выявления ошибок, приводящих к вышеозначенным проблемам, привело к тому, что в свежих релизах компиляторов GNU Compiler Collection (GCC) была значительно улучшена возможность детектирования подобных ошибок. Речь идёт об использовании ключей для проведения проверок и вывода предупреждений наподобие -Warray-bounds, -Wformat-overflow, -Wstringop-overflow и (самая свежая возможность, появившаяся в GCC 11) -Wstringop-overread. Но всем этим проверкам свойственно одно и то же ограничение, связанное с тем, что система может обнаруживать проблемные ситуации лишь в пределах отдельных функций. Получается, что, за исключением анализа небольшого набора встроенных в компилятор функций, вроде memcpy(), проверка прекращается на границе вызова функции. То есть, например, если буфер, объявленный в функции A, переполняется в функции B, вызванной из функции A, компилятор, если функция B не встроена в функцию A, на эту проблему не реагирует.

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

А именно, речь идёт о следующих возможностях:

  • Атрибут функций access (он появился в GCC 10, доступен и в C, и в C++).
  • Параметры функций, представленные массивами переменной длины (Variable-length array, VLA) (это — новая возможность, которая появилась в GCC 11, доступна она лишь в C).
  • Параметры функций, представленные массивами (новая возможность GCC 11, она доступна лишь в C).

Атрибут access


Применение атрибута access может оказаться полезным в функциях, которые, в виде одного из аргументов, принимают указатель на буфер, а в виде второго аргумента — размер буфера. Это, например, пара POSIX-функций read() и write(). Помимо того, что данный атрибут помогает программисту связать эти два параметра, он ещё и позволяет описать то, как именно функция работает с буфером. Атрибут применяется к объявлениям функций. Он используется и в местах вызова функций, и при анализе объявлений функций с целью выявления ошибочных операций доступа к памяти.

Этот атрибут имеет следующий синтаксис:

  • access (access-mode, ref-index)
  • access (access-mode, ref-index, size-index)

Здесь ref-index и size-index указывают на позиционные аргументы, нумерация которых начинается с 1. Они олицетворяют собой, соответственно, буфер и его размер. Аргумент, представляющий собой буфер, ref-index, может быть объявлен как обычный указатель на объект, включая void*. Он, кроме того, может быть представлен в форме массива (вроде T[] или T[N]). Ему необязательно указывать на завершённый тип. Необязательный аргумент size-index должен ссылаться на целочисленный аргумент, который задаёт количество элементов массива, к которому может обращаться функция. В случае с буферами незавершённых типов, таких, как void*, считается, что size-index содержит сведения о количестве байтов. Если аргумент size-index не указан, предполагается, что буфер содержит один элемент.

Аргумент access-mode описывает то, как именно функция будет работать с буфером, режим доступа к нему. В GCC 11 предусмотрено четыре режима доступа к буферам:

  • Режим read_only указывает на то, что функция лишь считывает данные из предоставленного ей буфера, но ничего в него не записывает. Ожидается, что буфер будет инициализирован источником вызова функции. Режим read_only обеспечивает более надёжные гарантии неизменности буфера, чем квалификатор const, использованный при объявлении буфера, так как квалификатор можно снять и буфер может быть модифицирован в корректной программе, если только сам объект буфера не является неизменным. Параметр, к которому применяется режим read_only, может быть объявлен с квалификатором const (но это необязательно). В C99 объявление параметра как read_only имеет тот же смысл, что объявление его с использованием одновременно и const, и restrict (правда, GCC 11 не распознаёт эти два варианта объявления параметров как равнозначные).
  • Режим write_only указывает на то, что функция осуществляет только запись данных в предоставленный ей буфер, но ничего из него не читает. Буфер может быть неинициализированным. Попытка применить режим write_only к const-параметру приводит к выдаче предупреждения и к игнорированию атрибута. Это, фактически, режим доступа, используемый по умолчанию для параметров, которым не назначен атрибут access.
  • Режим read_write указывает на то, что функция и читает данные из буфера, и пишет их в него. Ожидается, что функции будет предоставлен инициализированный буфер. Попытка применения режима доступа read_write к параметру, объявленному с квалификатором const, приведёт к выдаче предупреждения, а атрибут будет проигнорирован.
  • Режим none говорит о том, что функция никак не работает с буфером. Буфер может быть неинициализированным. Это — режим, который появился в GCC 11, он предназначен для функций, которые выполняют валидацию аргументов без доступа к данным, хранящимся в буфере.

В следующем примере демонстрируется использование атрибута access для аннотирования POSIX-функций read() и write():

__attribute__ ((access (write_only, 2, 3))) ssize_t
read (int fd, void *buf, size_t nbytes); 
__attribute__ ((access (read_only, 2, 3))) ssize_t
write (int fd, const void *buf, size_t nbytes);

Функция read() сохраняет данные в предоставленный ей буфер. Поэтому в качестве режима доступа используется write_only. Функция write() читает данные из буфера. Поэтому тут используется режим доступа read_only.

Атрибут access играет роль, подобную той, которую играет объявление параметра функции с использованием массива переменной длины, но использование атрибута даёт больший уровень гибкости. Помимо возможности указания режима доступа к буферу, аргумент size-index может быть связан с указателем, размер которого идёт в списке аргументов функции после него. Именно так часто и происходит. Массивам переменной длины посвящён следующий раздел.

Параметры функций, представленные массивами переменной длины


В C (но, в GCC 11, не в C++), параметр функции, объявленный в виде массива, может ссылаться на неконстантное выражение, включая предыдущие параметры той же функции, используемое для задания границ массива. Когда значение, представляющее границу, является ссылкой на другой параметр функции, объявление этого параметра должно предшествовать VLA (GCC содержит расширение, позволяющее обойти это ограничение языка; дополнительные сведения об этом можно найти в соответствующем разделе документации по GCC). Если подобным способом устанавливается лишь верхняя граница массива, то он становится обычным указателем, похожим на любой другой массив. В противном случае это — VLA. Так как это различие между двумя видами массивов в данном контексте является достаточно тонким, в диагностических сообщениях GCC все эти массивы называются VLA. Мы, в продолжение этого материала, тоже будем придерживаться такого вот упрощённого подхода к массивам. Например:

void init_array (int n, int a[n]);

Эта функция принимает обычный массив (или, если точнее, указатель) в качестве второго аргумента. Количество элементов этого массива задаёт первый аргумент. Хотя это, в общем-то, не нарушает требований языка, передача функции массива с меньшим числом элементов, чем указано в первом аргументе, почти наверняка указывает на ошибку. GCC проверяет вызовы подобных функций и выдаёт предупреждения в тех случаях, когда определяет, что переданные им массивы меньше, чем ожидается. Например, имеется следующая программа:

#include <stdlib.h>

#define N 32

void init_array (int n, int a[n]);

int* f (void)
{
    int *a = (int *)malloc (N);
    init_array (N, a);
    return a;
}

При вызове функции init_array GCC находит проблему, выдавая следующее предупреждение:

In function 'f':
warning: 'init_array' accessing 128 bytes in a region of size 32 [-Wstringop-overflow=]
   10 |     init_array (N, a);
      |     ^~~~~~~~~~~~~~~~~
note: referencing argument 2 of type 'int *'
note: in a call to function 'init_array'
    5 | void init_array (int n, int a[n]);
      |      ^~~~~~~~~~

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

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

void init_vla (int n, int[n]);
void init_vla (int, int[32]);
void init_vla (int, int*);
void init_vla (int n, int[n + 1]);

Тут, правда, возникает одна проблема. Какое из этих объявлений должно быть использовано для выдачи предупреждения, связанного с доступом к элементу, лежащему за пределами границ массива? В GCC 11 реализовано решение, опирающееся на первое объявление функции, и выдающее отдельное предупреждение, -Wvla-parameter, для любых последующих повторных объявлений, описывающих другое количество элементов в массиве. В результате при анализе кода, содержащего четыре вышеприведённых объявления функций, выдаются следующие предупреждения:

warning: argument 2 of type 'int[32]' declared as an ordinary array [-Wvla-parameter]
    2 | void init_vla (int, int[32]);
      |                     ^~~~~~~
warning: argument 2 of type 'int *' declared as a pointer [-Wvla-parameter]
    3 | void init_vla (int, int*);
      |                     ^~~~
warning: argument 2 of type 'int[n + 1]' declared with mismatched bound [-Wvla-parameter]
    4 | void init_vla (int, int[n + 1]);
      |                     ^~~~~~~~~
note: previously declared as a variable length array 'int[n]'
    1 | void init_vla (int n, int[n]);
      |                       ^~~~~~

Параметры функций, представленные массивами


Современные C-программисты, опасаясь ситуаций с выделением в стеке памяти неограниченного объёма, как правило, используют массивы переменной длины не так часто, как их можно было бы использовать. Это справедливо даже для особых случаев, вроде объявлений функций, где применение таких массивов не только безопасно, но и помогает компилятору анализировать код. В некоторых проектах, где VLA не используются, применяется более простое решение, которое заключается в объявлении параметров функций в виде обычных массивов (например — T(N)). При таком подходе ожидается, что вызывающая сторона предоставит функции доступ к некоему постоянному минимальному числу элементов (например — N). Так, стандартная C-функция tmpnam() ожидает, что её аргумент будет указывать на массив, в котором содержится, как минимум, L_tmpnam элементов. Для того чтобы выразить это ожидание в явном виде, в GNU libc 2.34 эта функция объявлена так:

char *tmpnam (char[L_tmpnam]);

GCC 11 распознаёт такие конструкции. Когда компилятор выясняет, что при вызове функции ей передаётся массив, размер которого меньше, чем указано в объявлении функции, он выдаёт предупреждение. Например, учитывая то, что в Linux L_tmpnam равно 20, испытаем следующую функцию:

void g (void)
{
  char a[16];
  if (tmpnam (a))
    puts (a);
}

GCC выдаст следующее предупреждение:

In function 'g':
warning: 'tmpnam' accessing 20 bytes in a region of size 16 [-Wstringop-overflow=]
 10 | if (tmpnam (a))
    |     ^~~~~~~~~~
note: referencing argument 1 of type 'char *'
note: in a call to function 'tmpnam'
  3 | extern char* tmpnam (char[L_tmpnam]);
    |              ^~~~~~

GCC 11, в дополнение к вызовам функций, проверяет ещё и определения функций, в которых используются параметры-массивы, и выдаёт предупреждения при нахождении операций по работе с массивами, которые направлены на элементы, выходящие за постоянные границы массивов. Например, анализ объявления функции init_array(), код которой показан ниже, приводит к выдаче предупреждения -Warray-bounds.

Вот код функции:

void init_array (int, int a[32])
{ 
  a[32] = 0;
}

А вот — предупреждение:

In function 'init_array':
warning: array subscript 32 is outside array bounds of 'int[32]' [-Warray-bounds]
    3 |   a[32] = 0;
      |   ~^~~~
note: while referencing 'a'
    1 | void init_array (int, int a[32])
      |                       ~~~~^~~~~

Аналогично, если речь идёт о повторных объявлениях функции с использованием VLA-параметров, GCC, кроме прочего, проверяет и их с учётом свойств параметра-массива первой функции, и выдаёт предупреждение -Warray-parameter в случае обнаружения несоответствий между первым объявлением и повторными объявлениями функции. Например, речь может идти о следующих объявлениях функции:

void init_array (int, int[32]);
void init_array (int, int[16]);
void init_array (int n, int[]);
void init_array (int n, int*);

Вот предупреждения, которые выдаёт GCC:

warning: argument 2 of type 'int[16]' with mismatched bound [-Warray-parameter=]
    2 | void init_array (int, int[16]);
      |                       ^~~~~~~
warning: argument 2 of type 'int[]' with mismatched bound [-Warray-parameter=]
    3 | void init_array (int n, int[]);
      |                         ^~~~~
warning: argument 2 of type 'int *' declared as a pointer [-Warray-parameter=]
    4 | void init_array (int n, int*);
      |                         ^~~~
note: previously declared as an array 'int[32]'
    1 | void init_array (int, int[32]);
      |                       ^~~~~~~

Нюансы и ограничения


Те возможности GCC, о которых мы говорили, уникальны в одном интересном аспекте. А именно, их использование предусматривает применение к коду и простого лексического анализа, и более сложного динамического анализа, опирающегося на особенности выполнения этого кода. В теории, предупреждения, опирающиеся на лексический анализ, могут быть одновременно и надёжными, и полными (то есть — они не могут быть ни ложноположительными, ни ложноотрицательными). Предупреждения -Warray-parameter и -Wvla-parameters выдаются на основе лексического анализа кода, в результате они практически не страдают от подобных проблем. А вот предупреждения, основанные на динамическом анализе кода, с другой стороны, по своей природе, не являются ни надёжными, ни полными. Такие предупреждения, напротив, могут быть и ложноположительными, и ложноотрицательными.

▍Ложноотрицательные предупреждения


Для использования атрибутов access и выявления операций, которые направлены на области памяти, находящиеся за пределами допустимого адресного пространства, функция, к которой применяются такие атрибуты, не должна быть встроенной. Если же функция встроена в источник вызова, большинство её атрибутов обычно теряется. Это может помешать GCC в детектировании ошибок тогда, когда некорректные операции доступа к памяти не могут быть надёжно выявлены, основываясь лишь на коде тела встроенной функции. Например, функция genfname() из следующего примера использует, для генерирования имени временного файла в директории /tmp, функцию getpid():

#include <stdio.h>
#include <unistd.h>
  
inline void genfname (char name[27])
{
  snprintf (name, 27, "/tmp/tmpfile%u.txt", getpid ());
}

int main (void)
{
  char name[16];
  genfname (name);
  puts (name);
}

Так как в большинстве систем POSIX-функция getpid() возвращает 32-битное целое число, самое длинное имя, которое может сгенерировать эта функция, имеет длину 26 символов (10 символов с учётом INT_MAX, плюс — ещё 16 с учётом строки /tmp/tmpfile.txt; тут надо учесть и ещё один байт для завершающего нуль-символа). Когда вызов genfname() в main() не является вызовом встроенной функции, GCC, как и ожидается, выдаёт следующее предупреждение:

In function 'main':
warning: 'f' accessing 27 bytes in a region of size 16 [-Wstringop-overflow=]
   11 |   f (a);
      |   ^~~~~
note: referencing argument 1 of type 'char *'
note: in a call to function 'f'
    3 | inline void f (char a[27])
      |             ^

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

К слову сказать, если вам интересно, почему вызов sprintf() не приводит к выводу предупреждения -Wformat-truncation, то знайте, что дело в том, что компилятор не может что-либо узнать о результатах вызова getpid().

▍Ложноположительные предупреждения


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

Как уже было сказано, в некоторых проектах используются параметры, представленные массивами, границы которых оформлены в виде констант. Это делается для того чтобы дать тому, кто вызывает функцию, подсказку о том, что он должен предоставить этой функции массив, в котором содержится, как минимум, столько элементов, сколько совпадает с количеством элементов в массиве-параметре. Но иногда разработчики функций не особенно строго придерживаются этого подхода. Например, функции используют массив лишь тогда, когда другой параметр имеет некое значение, а в других случаях не используют его. Так как нет ничего, что сообщило бы GCC об этой «особенности», предупреждение может быть выдано даже в том случае, если функция работает корректно. Мы рекомендуем не прибегать к вышеописанному подходу с массивами-параметрами в таких случаях.

Что дальше?


В GCC атрибут access можно использовать для выявления следующих проблем:

  • Доступ к областям памяти, находящимся за пределами допустимого адресного пространства (-Warray-bounds, -Wformat-overflow, -Wstringop-overflow и -Wstringop-overread).
  • Доступ к перекрывающимся областям памяти (-Wrestrict).
  • Доступ к неинициализированным областям памяти (-Wuninitialized).

В будущем нам хотелось бы использовать этот атрибут для выявления переменных, в которые осуществляется запись, но из которых никогда ничего не считывается (-Wunused-but-set-parameter и -Wunused-but-set-variable).

Мы, кроме того, рассматриваем возможность расширения, в какой-либо форме, атрибута access, для его применения к значениям, которые возвращают функции, а так же — к переменным. Аннотирование значений, возвращаемых функциями, позволит GCC детектировать попытки модификации иммутабельных объектов через указатели, возвращённые из функций (например — из функций getenv() или localeconv()). И, аналогично, аннотирование глобальных переменных позволит выявлять попытки случайной модификации содержимого соответствующих объектов (например — это может быть массив указателей environ, хранящий сведения о переменных окружения).

Пользуетесь ли вы подсказками для компиляторов, которые помогают им проверять код?
Теги:
Хабы:
+25
Комментарии8

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds