Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
Некоторые разработчики пренебрежительно относятся к проверкам: удалось ли выделить память с помощью функции malloc или нет. Их логика проста – памяти всегда должно хватить. А если не хватит, всё равно уже ничего не сделаешь, и пусть программа упадёт при разыменовании нулевого указателя. Очень плохой подход по множеству причин.
Несколько лет назад я уже публиковал схожую статью под названием "Почему важно проверять, что вернула функция malloc". Статья, которую вы сейчас читаете, является её обновлённым вариантом. Во-первых, появилось несколько новых соображений, которыми хочется поделиться. Во-вторых, предыдущая статья писалась в рамках цикла, посвящённого проверке проекта Chromium, и содержит детали, отвлекающие от основной темы.
Примечание. В статье под функцией malloc будет подразумеваться, что речь идёт не только именно об этой функции, но и о calloc, realloc, _aligned_malloc, _recalloc, strdup и так далее. Не хочется загромождать текст статьи, постоянно повторяя названия всех этих функций. Общее у них то, что они могут вернуть нулевой указатель.
Если функция malloc не смогла выделить буфер памяти, то она возвращает NULL. Любая нормальная программа должна проверять указатели, которые возвращает функция malloc, и соответствующим образом обрабатывать ситуацию, когда память выделить не получилось.
К сожалению, многие программисты небрежно относятся к проверке указателей, а иногда сознательно не проверяют, удалось ли выделить память или нет. Их логика следующая.
Если функция malloc не смогла выделить память, то вряд ли программа продолжит функционировать должным образом. Скорее всего, памяти будет не хватать и для других операций, поэтому можно вообще не заморачиваться об ошибках выделения памяти. Первое же обращение к памяти по нулевому указателю приведёт к генерации Structured Exception в Windows, или процесс получит сигнал SIGSEGV, если речь идёт о Unix-подобных системах. В результате программа упадёт, что вполне приемлемо. Раз нет памяти, то и нечего мучаться. Как вариант, можно перехватить структурное исключение/сигнал и обрабатывать разыменовывания нулевого указателя более централизовано. Это удобнее, чем писать тысячи проверок.
Я не придумываю. Я не раз общался с людьми, которые считают такой подход уместным и сознательно никогда не проверяют результат, который возвращает функция malloc.
Кстати, существует ещё одно оправдание разработчиков в отсутствии проверок. Функция malloc только резервирует память, но вовсе нет гарантии, что хватит физической памяти, когда мы начнём использовать выделенный буфер. Поэтому раз всё равно гарантии нет, то и проверять не надо. Например, именно так Carsten Haitzler, являющийся одним из разработчиков библиотеки EFL Core, объяснял, почему я насчитал более 500 мест в коде библиотеки, где отсутствуют проверки. Вот его комментарий к статье:
OK so this is a general acceptance that at least on Linux which was always our primary focus and for a long time was our only target, returns from malloc/calloc/realloc can't be trusted especially for small amounts. Linux overcommits memory by default. That means you get new memory but the kernel has not actually assigned real physical memory pages to it yet. Only virtual space. Not until you touch it. If the kernel cannot service this request your program crashes anyway trying to access memory in what looks like a valid pointer. So all in all the value of checking returns of allocs that are small at least on Linux is low. Sometimes we do it... sometimes not. But the returns cannot be trusted in general UNLESS its for very large amounts of memory and your alloc is never going to be serviced - e.g. your alloc cannot fit in virtual address space at all (happens sometimes on 32bit). Yes overcommit can be tuned but it comes at a cost that most people never want to pay or no one even knows they can tune. Secondly, if an alloc fails for a small chunk of memory - e.g. a linked list node... realistically if NULL is returned... crashing is about as good as anything you can do. Your memory is so low that you can crash, call abort() like glib does with g_malloc because if you can't allocate 20-40 bytes ... your system is going to fall over anyway as you have no working memory left anyway. I'm not talking about tiny embedded systems here, but large machines with virtual memory and a few megabytes of memory etc. which has been our target. I can see why PVS-Studio doesn't like this. Strictly it is actually correct, but in reality code spent on handling this stuff is kind of a waste of code given the reality of the situation. I'll get more into that later.
Приведённые рассуждения программистов являются неправильными, и ниже я подробно объясню почему.
Есть четыре причины, каждой из которых достаточно, чтобы обязательно делать проверки после вызова функции malloc. Если кто-то в команде не пишет проверки, то обязательно заставьте его прочитать эту статью.
Прежде чем начать, небольшая теоретическая справка, почему возникают структурные исключения или сигналы, если происходит разыменовывание нулевого указателя. Это будет важно для дальнейшего повествования.
В начале адресного пространства одна или несколько страниц памяти защищены операционной системой от записи. Это позволяет выявить ошибки обращения к памяти по нулевому указателю или указателю, значение которого близко к 0.
В разных операционных системах для этих целей резервируется разное количество памяти. При этом в некоторых ОС это значение можно настраивать. Поэтому нет смысла называть какое-то конкретное число зарезервированных байт памяти. Но, чтобы как-то сориентировать читателя, скажу, что в Linux-системах типовым значением является 64 Кбайта.
Важно, что, прибавив к нулевому указателю какое-то достаточно большое число, можно "промазать" мимо контрольных страниц памяти и случайно попасть в какие-то незащищенные от записи страницы. Таким образом можно испортить где-то какие-то данные, но операционная система этого не заметит, и никакого сигнала/исключения она не сгенерирует.
Примечание. Если говорить про встраиваемые системы, то там вообще может не быть никакой защиты от записи по нулевому адресу. В некоторых системах мало памяти, и вся она используется для хранения данных. Впрочем, в системах с малым количеством RAM, скорее всего, не будет динамического управления памятью и соответственно функции malloc.
Заваривайте кофе, мы начинаем!
С точки зрения языков C и C++ разыменовывание нулевого указателя приводит к неопределённому поведению. Неопределённое поведение — это что угодно. Не думайте, что вы знаете, как будет вести себя программа, если произойдёт разыменовывание nullptr. Современные компиляторы занимаются серьёзными оптимизациями, в результате чего бывает невозможно предсказать, как проявит себя та или иная ошибка в коде.
Неопределённое поведение программы — это очень плохо. Вы не должны допускать его в своём коде.
Не думайте, что сможете совладать с разыменовыванием нулевого указателя, используя обработчики структурных исключений (SEH в Windows) или сигналы (в UNIX-like системах). Раз разыменовывание нулевого указателя было, то работа программы уже нарушена, и может произойти что угодно. Давайте рассмотрим абстрактный пример, почему нельзя полагаться на SEH-обработчики и т.п.
size_t *ptr = (size_t *)malloc(sizeof(size_t) * N * 2);
for (size_t i = 0; i != N; ++i)
{
ptr[i] = i;
ptr[N * 2 - i - 1] = i;
}
Этот код заполняет массив от краёв к центру. К центру значения элементов увеличиваются. Это придуманный за 1 минуту пример, поэтому не гадайте, зачем такой массив кому-то нужен. Я и сам не знаю. Мне было важно, чтобы в соседних строках программы происходила запись в начало массива и куда-то в его конец. Такое иногда бывает нужно и в практических задачах, и мы рассмотрим реальный код, когда доберёмся до 4-ой причины.
Ещё раз внимательно посмотрим на эти две строки:
ptr[i] = i;
ptr[N * 2 - i - 1] = i;
С точки зрения программиста, в начале цикла произойдёт запись в элемент ptr[0] и возникнет структурное исключение/сигнал. Оно будет обработано — и всё будет хорошо.
Однако компилятор в каких-то целях оптимизации может переставить присваивания местами. Он имеет на это полное право. С точки зрения компилятора, если указатель разыменовывается, то он не может быть равен nullptr. Если указатель нулевой, то это неопределённое поведение, и компилятор не обязан думать о последствиях оптимизации.
Так вот компилятор может решить, что в целях оптимизации выгоднее выполнить присваивания так:
ptr[N * 2 - i - 1] = i;
ptr[i] = i;
В результате в начале произойдет запись по адресу ((size_t *)nullptr)[N * 2 - 0 - 1]. Если значение N достаточно велико, то страница защиты в начале памяти будет "перепрыгнута" и значение переменной i может быть записано в какую-то ячейку, доступную для записи. В общем, произойдёт порча каких-то данных.
И только после этого будет выполнено присваивание по адресу ((size_t *)nullptr)[0]. Операционная система заметит попытку записи в контролируемую ею область и сгенерирует сигнал/исключение.
Программа может обработать это структурное исключение/сигнал. Но уже поздно. Где-то в памяти есть испорченные данные. Причем непонятно, какие данные испорчены и к каким последствиям это может привести!
Виноват ли компилятор, что поменял операции присваивания местами? Нет. Программист допустил разыменовывание нулевого указателя и тем самым ввёл программу в состояние неопределённого поведения. В данном конкретном случае неопределённое поведение программы будет заключаться в том, что где-то в памяти испорчены данные.
Вывод
Исходите из аксиомы: любое разыменовывание нулевого указателя — это неопределённое поведение программы. Не бывает "безобидного" неопределённого поведения. Любое неопределённое поведение недопустимо.
Не допускайте разыменовывания указателей, которые вернула функция malloc и её аналоги, без их предварительной проверки. Не полагайтесь на какие-то другие способы перехвата разыменовывания нулевого указателя. Следует использовать только старый добрый оператор if.
То, что некоторые разработчики вообще не считают за ошибку, другие воспринимают как уязвимость. Именно так обстоит дело с разыменовыванием нулевого указателя.
В ряде проектов считается допустимым, если программа из-за разыменовывания нулевого указателя упадёт или если ошибка будет обработана каким-то общим способом с помощью перехвата сигнала/структурного исключения.
В других приложения разыменовывание нулевого указателя интерпретируется как разновидность потенциальной уязвимости, которую можно использовать для DoS-атаки уровня приложения. Вместо того чтобы штатно обработать нехватку памяти, программа или один из потоков исполнения завершает свою работу. Это может приводить к потере данных, нарушению целостности данных и так далее.
Приведу пример. Программа Ytnef предназначена для декодирования TNEF потоков, например, созданных в Outlook. Отсутствие проверки после вызова calloc в ней было квалифицировано как уязвимость CVE-2017-6298.
Все исправленные места, в которых могло произойти разыменовывание нулевого указателя, имели приблизительно один и тот же вид:
vl->data = calloc(vl->size, sizeof(WORD));
temp_word = SwapWord((BYTE*)d, sizeof(WORD));
memcpy(vl->data, &temp_word, vl->size);
Выводы
Если вы разрабатываете безответственное приложение, для которого упасть в процессе работы не является бедой, то да, писать проверки необязательно.
Однако если вы разрабатываете настоящий программный проект или библиотеку, то отсутствие проверок недопустимо!
Поэтому я идеологически не согласен, например, с аргументацией Carsten Haitzler о допустимости отсутствия проверок в библиотеке EFL Core (подробности в статье). Данный подход не позволяет построить на основе таких библиотек надёжные приложения. Если вы создаёте библиотеку, то помните, что в некоторых приложениях разыменовывание нулевого указателя — это уязвимость. Необходимо обрабатывать ошибки выделения памяти и штатно возвращать информацию о неудаче.
Те, кто ленится писать проверки, почему-то думают, что разыменование затрагивает именно нулевые указатели. Да, часто именно так и бывает. Но может ли поручиться программист за код всего приложения? Уверен, что нет.
Сейчас я на практических примерах покажу, что я имею в виду. Возьмём, например, код из библиотеки LLVM-subzero, которая используется в Chromium.
void StringMapImpl::init(unsigned InitSize) {
assert((InitSize & (InitSize-1)) == 0 &&
"Init Size must be a power of 2 or zero!");
NumBuckets = InitSize ? InitSize : 16;
NumItems = 0;
NumTombstones = 0;
TheTable = (StringMapEntryBase **)
calloc(NumBuckets+1,
sizeof(StringMapEntryBase **) +
sizeof(unsigned));
// Allocate one extra bucket, set it to look filled
// so the iterators stop at end.
TheTable[NumBuckets] = (StringMapEntryBase*)2;
}
Примечание. Здесь и далее я использую фрагменты старого программного кода, которые остались у меня от написания различных статей. Поэтому код или номера строк могут уже не соответствовать тому, что есть сейчас. Однако это неважно для повествования.
Предупреждение PVS-Studio: V522 [CWE-690] There might be dereferencing of a potential null pointer 'TheTable'. Check lines: 65, 59. stringmap.cpp 65
Сразу после выделения буфера памяти происходит запись в ячейку TheTable[NumBuckets]. Если значение переменной NumBuckets достаточно большое, то мы испортим какие-то данные с непредсказуемыми последствиями. После такой порчи вообще нет смысла рассуждать, как будет работать программа. Могут последовать самые неожиданнейшие последствия.
Продолжу заочную дискуссию с Carsten Haitzler. Он говорит, что разработчики библиотеки понимают, что делают, когда не проверяют результат вызова функции malloc. Боюсь, они недооценивают опасность такого подхода. Давайте взглянем, например, на вот такой фрагмент кода из библиотеки EFL:
static void
st_collections_group_parts_part_description_filter_data(void)
{
....
filter->data_count++;
array = realloc(filter->data,
sizeof(Edje_Part_Description_Spec_Filter_Data) *
filter->data_count);
array[filter->data_count - 1].name = name;
array[filter->data_count - 1].value = value;
filter->data = array;
}
Предупреждение PVS-Studio: V522 [CWE-690] There might be dereferencing of a potential null pointer 'array'. edje_cc_handlers.c 14249
Перед нами типовая ситуация: в буфере не хватает свободного места для хранения данных, и его следует увеличить. Для увеличения размера буфера используется функция realloc, которая может вернуть NULL.
Если это произойдёт, то вовсе не обязательно возникнет структурное исключение/сигнал из-за разыменовывания нулевого указателя. Взглянем вот на эти строчки:
array[filter->data_count - 1].name = name;
array[filter->data_count - 1].value = value;
Если значение переменной filter->data_count достаточно большое, то данные будут записаны по какому-то непонятному адресу.
В памяти будут испорчены какие-то данные, а программа продолжит своё выполнение. Последствия непредсказуемые, но ничего хорошего точно не получится.
Вывод
Я вновь задаю вопрос: "Где гарантии, что будет разыменовывание именно нулевого указателя?". Нет таких гарантий. Невозможно, разрабатывая или модифицируя код, помнить про только что рассмотренный нюанс. Запросто можно что-то испортить в памяти, при этом программа продолжит выполняться как ни в чём не бывало.
Единственный способ написать надёжный и правильный код — это всегда проверять результат, который вернула функция malloc. Проверьте и живите спокойно.
Найдётся кто-то, кто скажет что-то подобное:
Я отлично понимаю про realloc и всё остальное, что написано в статье. Но я профессионал и, выделяя память, сразу заполняю её нулями с помощью memset. Там, где действительно необходимо, я использую проверки. Но лишние проверки после каждого malloc я писать не буду.
Вообще заполнять память сразу после выделения буфера достаточно странная идея. Странная потому, что есть функция calloc. Тем не менее так поступают очень часто. Далеко за примером ходить не надо, вот код из библиотеки WebRTC:
int Resampler::Reset(int inFreq, int outFreq, size_t num_channels) {
....
state1_ = malloc(8 * sizeof(int32_t));
memset(state1_, 0, 8 * sizeof(int32_t));
....
}
Выделяется память, затем буфер заполняется нулями. Частая практика, хотя две строчки можно сократить до одной, используя calloc. Но всё это неважно.
Главное, что даже подобный код небезопасен! Функция memset не обязана начинать заполнять память с начала и тем самым вызывать разыменовывание нулевого указателя.
Функция memset имеет право начать заполнять буфер с конца. И если выделялся большой буфер, то могут быть затёрты какие-то полезные данные. Да, заполняя память, функция memset рано или поздно достигнет страницы, защищённой от записи, и операционная система сгенерирует структурное исключение/сигнал. Однако обрабатывать их уже не имеет смысла. К этому моменту будет испорчен большой фрагмент памяти — и дальнейшая работа программы будет непредсказуема.
Читатель может возразить, что всё это носит исключительно теоретический характер. Да, функция memset чисто теоретически может заполнять буфер, начиная с конца буфера, но на практике никто не будет так реализовывать эту функцию.
Соглашусь, что подобная реализация memset – действительно экзотика, и я даже задавал вопрос на Stack Overflow на эту тему. В ответе говорится:
The Linux kernel's memset for the SuperH architecture has this property: link.
К сожалению, это код на незнакомой мне разновидности ассемблера, поэтому я не берусь рассуждать о нём. Зато ещё есть вот такая интересная реализация на языке Си. Приведу начало этой функции:
void *memset(void *dest, int c, size_t n)
{
unsigned char *s = dest;
size_t k;
if (!n) return dest;
s[0] = c;
s[n-1] = c;
....
}
Обратите внимание на:
s[0] = c;
s[n-1] = c;
Здесь мы возвращаемся к причине N1 "Разыменовывание нулевого указателя — это неопределённое поведение". Нет гарантии, что компилятор в целях оптимизации не поменяет присваивания местами. Если компилятор это сделает и аргумент n будет иметь большое значение, то вначале будет испорчен какой-то байт памяти. И только потом произойдёт разыменовывание нулевого указателя.
Опять неубедительно? Хорошо, а как вам вот такая реализация:
void *memset(void *dest, int c, size_t n)
{
size_t k;
if (!n) return dest;
s[0] = s[n-1] = c;
if (n <= 2) return dest;
....
}
Вывод
Нельзя доверять даже функции memset. Да, это во многом искусственная и надуманная проблема. Я просто хотел показать, как много существует нюансов, если не проверять значение указателя. Просто невозможно всё это учесть. Следует аккуратно проверять каждый указатель, который вернула функция malloc и аналогичные ей. Именно тогда вы станете профессионалом и будете писать надёжный код.
Предыдущая статья породила несколько обсуждений: 1, 2, 3. Отвечу на некоторые замечания.
1. Если malloc вернула NULL, то лучше сразу завершить работу программы, чем писать кучу if-ов и пытаться как-то обработать нехватку памяти, из-за которой часто выполнение программы всё равно невозможно.
Я вовсе не призывал до последнего бороться с последствиями нехватки памяти, пробрасывая ошибку всё выше и выше. Если для приложения допустимо завершить работу без предупреждения, то пусть так и будет. Для этого достаточно всего одной проверки сразу после malloc или использования xmalloc (см. следующий пункт).
Я возражал и предупреждал об отсутствии проверок, из-за которых программа продолжает работать "как ни в чем не бывало". Это другое. Это опасно, так как приводит к неопределённому поведению, порче данных и так далее.
2. Не рассказано про решение, которое заключается в написании функций-врапперов для выделения памяти с последующей проверкой или использование уже существующих функций, таких как xmalloc.
Согласен, этот момент выпал из моего рассмотрения. Мне было важнее донести до читателя, в чём опасность отсутствия проверки. Как исправить код, это уже вопрос вкуса и деталей реализации.
Функция xmalloc не является частью стандартной библиотеки C (см. "What is the difference between xmalloc and malloc?"). Однако эта функция может быть объявлена в других библиотеках, например в GNU utils library (GNU libiberty).
Суть функции в том, что программа завершает работу, если не удалось выделить память. Реализация этой функции может выглядеть, например, так:
void* xmalloc(size_t s)
{
void* p = malloc(s);
if (!p) {
fprintf (stderr, "fatal: out of memory (xmalloc(%zu)).\n", s);
exit(EXIT_FAILURE);
}
return p;
}
Соответственно, вызывая везде функцию xmalloc вместо malloc, можно быть уверенным, что в программе не возникнет неопределённого поведения из-за какого-либо использования нулевого указателя.
К сожалению, xmalloc тоже не является панацеей от всех бед. Надо помнить, что использование xmalloc недопустимо, если речь идёт о написании кода библиотек. Про это я напишу далее.
3. Больше всего комментариев было следующего вида: "на практике malloc никогда не возвращает NULL".
Обычно это утверждают Linux разработчики. Они не правы. К счастью, не один я понимаю это. Мне очень понравился вот этот комментарий:
По опыту обсуждения подобной темы складывается ощущение, что в "Интернетах" есть две секты. Приверженцы первой свято уверены в том, что под Linux-ом malloc никогда не возвращает NULL. Приверженцы второй свято уверены в том, что если память в программе выделить не удалось, то ничего уже в принципе сделать нельзя, нужно только падать. Переубедить их никак нельзя. Особенно когда эти две секты пересекаются. Можно только принять это как данность. Причём не суть важно, на каком профильном ресурсе идёт обсуждение.
Я подумал и решил последовать совету — и не буду пытаться переубеждать :). Будем надеяться, что эти группы разработчиков пишут только некритичные программы. Если, например, какие-то данные испортятся в игре или игра упадёт, это нестрашно.
Единственное, что важно, – чтобы так не делали разработчики библиотек, баз данных и т.д.
Если вы разрабатываете библиотеку или иной ответственный код, то всегда проверяйте значение указателя, который вернула функция malloc/realloc, и возвращайте вовне код ошибки, если память выделить не удалось.
В библиотеках нельзя вызвать функцию exit, если не удалось выделить память. По этой же причине нельзя использовать xmalloc. Для многих приложений недопустимо просто взять и аварийно завершить их работу. Из-за этого, например, может быть испорчена база данных или проект, над которым человек работал многие часы. Могут быть потеряны данные, которые считались много часов. Из-за этого программа может быть подвержена уязвимостям "отказ в обслуживании", когда вместо того, чтобы как-то корректно обработать возрастающую нагрузку, работа многопоточного приложения просто завершается.
Нельзя предположить, как и в каких проектах будет использована библиотека. Поэтому следует исходить из того, что приложение может решать очень ответственные задачи. И просто "убить" его, вызвав exit, — никуда не годится. Скорее всего, такая программа написана с учётом возможности нехватки памяти и может что-то предпринять в этом случае. Например, некая CAD-система из-за сильной фрагментации памяти не может выделить достаточный буфер для очередной операции. Но это не повод ей завершиться в аварийном режиме с потерей данных. Программа может дать возможность сохранить проект и перезапустить себя в штатном режиме.
Ни в коем случае нельзя полагаться на то, что malloc всегда может выделить память. Неизвестно, на какой платформе и как будет использоваться библиотека. Если на одной платформе нехватка памяти — это экзотика, то на другой — это может быть весьма частой ситуацией.
Нельзя надеяться, что программа упадёт, если malloc вернёт NULL. Произойти может всё что угодно. Программа может писать данные вовсе не по нулевому адресу. В результате могут быть испорчены некие данные, что ведёт к непредсказуемым последствиям. Даже memset опасен. Если заполнение данных идёт в обратном порядке, то вначале испортятся некие данные, и только потом программа упадёт. Но падение может произойти слишком поздно. Если в момент работы функции memset в параллельных потоках будут использованы испорченные данные, то последствия могут быть фатальны. Можно получить испорченную транзакцию в базе данных или отправку команды на удаление "ненужных" файлов. Может успеть произойти всё что угодно. Предлагаю читателю пофантазировать самостоятельно, к чему может привести использование мусора в памяти.
Таким образом, у библиотеки есть только один правильный вариант работы с функциями malloc. Нужно СРАЗУ проверить, что вернула функция, и если это NULL, то вернуть статус ошибки.
Всегда сразу проверяйте указатель, который вернула функция malloc или аналогичная ей.
Как видите, анализатор PVS-Studio совсем не зря предупреждает о том, что нет проверки указателя после вызова malloc. Невозможно написать надёжный код, не делая проверки. Особенно это важно и актуально для разработчиков библиотек.
Надеюсь, теперь вы по-новому взглянете на функцию malloc, проверки указателей в коде и предупреждения анализатора PVS-Studio. Не забудьте показать эту статью своим коллегам и начать использовать PVS-Studio. Спасибо за внимание и желаю всем поменьше багов.
0