Некоторые разработчики могут пренебрегать проверками: они намеренно не проверяют, выделила ли функция malloc память или нет. Их рассуждения просты — они думают, что памяти хватит. А если не хватает памяти для выполнения операций, пусть программа крашится. Похоже на плохой подход, не так ли? По целому ряду причин.

Несколько лет назад я уже публиковал подобную статью под названием Почему важно проверять, что вернула функция malloc? Статья, которую вы сейчас читаете, является ее обновленной версией. Во-первых, у меня есть несколько новых идей, которыми я хочу поделиться с вами. Во-вторых, предыдущая статья была частью серии, посвященной проверенному нами проекту Chromium — в ней есть детали, отвлекающие от основной темы.

Примечание. В статье под функцией malloc будет подразумеваться, что речь идет не только об этой конкретной функции, но и о calloc, realloc, _aligned_malloc, _recalloc, strdup и т. д. на. Я не хочу загромождать статью всеми этими названиями функций. Общим для всех этих функций является то, что они могут возвращать нулевой указатель.

маллок

Если функция malloc не может выделить буфер памяти, она возвращает NULL. Любая нормальная программа должна проверять указатели, возвращаемые функцией malloc, и соответствующим образом обрабатывать ситуацию, когда память не может быть выделена.

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

Если функции malloc не удалось выделить память, маловероятно, что программа продолжит работать правильно. Скорее всего памяти не хватит для других операций, так зачем вообще заморачиваться с ошибками выделения памяти. Первое обращение к памяти по нулевому указателю приводит к генерации Structured Exception в Windows. В Unix-подобных системах процесс получает сигнал SIGSEGV. В результате программа вылетает, что вполне допустимо. Нет памяти, нет страданий. В качестве альтернативы вы можете поймать структурированное исключение/сигнал и более централизованно обработать разыменование нулевого указателя. Это удобнее, чем выписывать тысячи чеков.

Я не выдумываю. Я разговаривал с людьми, которые считают такой подход уместным и сознательно никогда не проверяют результат, возвращаемый функцией malloc.

Кстати, у разработчиков есть еще одна отговорка, почему они не делают проверки. Функция malloc только резервирует память, но не гарантирует, что физической памяти будет достаточно, когда мы начнем использовать выделенный буфер памяти. Поэтому, если гарантий все равно нет, зачем выполнять проверку? Например, Карстен Хейтцлер, один из разработчиков библиотек EFL Core, объяснил, почему я насчитал более 500 фрагментов без проверок в коде библиотеки. Вот его комментарий к статье:

Итак, это общепринятое мнение, что, по крайней мере, в Linux, который всегда был нашим основным направлением и долгое время был нашей единственной целью, возврату от malloc/calloc/realloc нельзя доверять, особенно для небольших сумм. Linux по умолчанию выделяет больше памяти. Это означает, что вы получаете новую память, но ядро ​​еще не назначило ей реальные страницы физической памяти. Только виртуальное пространство. Пока не прикоснешься. Если ядро ​​​​не может обслужить этот запрос, ваша программа все равно падает, пытаясь получить доступ к памяти в том, что выглядит как действительный указатель. Таким образом, в целом ценность проверки возвратов allocs, которые малы, по крайней мере, в Linux, невелика. Иногда мы это делаем… иногда нет. Но в целом нельзя доверять возвратам, ЕСЛИ это не очень большой объем памяти, и ваш alloc никогда не будет обслуживаться - например. ваш alloc вообще не может поместиться в виртуальном адресном пространстве (иногда это происходит на 32-битной версии). Да, overcommit можно настроить, но это обходится дорого, что большинство людей никогда не хотят платить, или никто даже не знает, что можно настроить. Во-вторых, если alloc терпит неудачу для небольшого куска памяти — т.е. узел связанного списка… реально, если возвращается NULL… сбой — это почти все, что вы можете сделать. У вас настолько мало памяти, что вы можете зависнуть, вызовите abort(), как это делает glib с g_malloc, потому что, если вы не можете выделить 20–40 байт … ваша система все равно рухнет, так как у вас все равно не останется рабочей памяти. Я говорю здесь не о крошечных встроенных системах, а о больших машинах с виртуальной памятью и несколькими мегабайтами памяти и т. д., которые были нашей целью. Я понимаю, почему PVS-Studio это не нравится. Строго говоря, это на самом деле правильно, но на самом деле код, потраченный на обработку этого материала, является пустой тратой кода, учитывая реальность ситуации. Я расскажу об этом позже.

Данное рассуждение разработчиков неверно. Ниже я подробно объясню, почему.

Вам необходимо выполнить проверки

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

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

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

В разных операционных системах для этих целей резервируется разный объем памяти. Кроме того, в некоторых операционных системах это значение настраивается. Поэтому нет смысла называть конкретное количество байтов памяти зарезервированным. Напомню, что в Linux-системах стандартным значением является 64Кб.

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

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

Сварите кофе, приступим!

Разыменование нулевого указателя — неопределенное поведение

С точки зрения языков C и C++ разыменование нулевого указателя вызывает неопределенное поведение. Когда вызывается неопределенное поведение, может случиться что угодно. Не думайте, что вы знаете, как поведет себя программа, если произойдет разыменование nullptr. Современные компиляторы используют серьезные оптимизации. В результате иногда невозможно предсказать, как проявит себя та или иная ошибка кода.

Неопределенное поведение программы очень неприятно. Вы должны избегать неопределенного поведения в своем коде.

Не думайте, что вы сможете справиться с разыменованием нулевого указателя, используя структурированные обработчики исключений (SEH в Windows) или сигналы (в UNIX-подобных системах). Если произошло разыменование нулевого указателя, то работа программы уже нарушена и произойти может что угодно. Давайте рассмотрим на абстрактном примере, почему нельзя полагаться на 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);

Выводы

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

Однако, если вы разрабатываете реальный программный проект или библиотеку, отсутствие проверок недопустимо!

Поэтому я идеологически не согласен с аргументом Карстена Хейтцлера о том, что отсутствие проверок в библиотеке 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 Возможно, произошло разыменование потенциального нулевого указателя TheTable. Проверить строки: 65, 59. stringmap.cpp 65

Сразу после выделения буфера памяти происходит запись в ячейку TheTable[NumBuckets]. Если значение переменной NumBuckets достаточно велико, мы испортим некоторые данные с непредсказуемыми последствиями. После такого повреждения нет смысла гадать, как будет работать программа. Возможны самые неожиданные последствия.

Я продолжу непрямую дискуссию с Карстеном Хейтцлером. Он говорит, что разработчики библиотеки понимают, что делают, когда не проверяют результат вызова функции 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] Возможно, происходит разыменование потенциального массива нулевых указателей. 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. Выполните проверку и живите спокойной жизнью.

Где гарантии, что memset заполнит память в прямом порядке?

Найдется человек, который скажет что-то вроде этого:

Я прекрасно знаю про 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 на эту тему. Это ответ:

Memset ядра Linux для архитектуры SuperH имеет следующее свойство: ссылка.

К сожалению, это код на незнакомом мне ассемблере, поэтому рассказывать об этом не берусь. Но все же есть такая интересная реализация на языке Си. Вот начало функции:

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 (см. В чем разница между xmalloc и malloc?). Однако эта функция может быть объявлена ​​и в других библиотеках, например в библиотеке GNU utils (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. Спасибо за внимание. Желаю вам меньше ошибок!