Как писать безопасный код на C, часть 1

Как писать безопасный код на C, часть 1

Coding


Использование языка программирования C часто приводит к написанию очень опасного кода. Но это не совсем справедливое обвинение; такие проекты, как OpenBSD показывают, что возможно писать безопасный код на C. Проблема C та же, что и в ассемблере — язык открывает вам все возможности архитектуры, но и кое-что ещё. Он дает все возможности для написания безопасного кода, но не делает эти вещи сам.


В этой статье рассмотрены стандартные примеры ошибок в коде C и то, как их и избежать.


Проверка ошибок

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


В частности, код на Java часто засорен блоками try…catch, которые ничего не делают, кроме отбрасывания ошибок, но даже в этом случае механизм исключений преследует цель: это заставляет программиста помнить о том, что он небезопасно обрабатывает условия ошибок.


В C большинство функций возвращают неправильное значение, когда произошла какая-либо ошибка. Обычно это делается двумя способами. Множество функций возвращают код ошибки ноль в случае успеха. Функции, возвращающие указатели, возвращают правильное значение в случае успеха или ноль в противном случае. Эта ситуация может немного смущать, так как ноль означает и успешное выполнение одних функций, и ошибку в других.


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


Стандартный пример функции, которая почти никогда не завершается с ошибкой — malloc(), наряду со связанными с ней функциями, вроде calloc(). В спецификации C указано, что malloc должна вернуть NULL в случае, если памяти недостаточно, чтобы удовлетворить запрос. Linux не полностью следует этому правилу: он возвращает NULL, если в системе нет достаточного виртуального пространства адресов, чтобы выполнить выделение памяти, но в случае недостатка памяти, Linux по-прежнему выделяет пространство адресов, а затем возвращает ошибку, если вы пытаетесь использовать эту память. Как бы то ни было, исходя из того, что у вас есть подходящая реализация C, необходимо проверять значения, которые возвращает malloc.


В большинстве случаев, вы не сможете сделать ничего разумного, если malloc завершится с ошибкой. Даже код, восстанавливающий ошибку, обычно нуждается в выделении памяти. Вы можете попробовать выделять эту память, когда программа запускается (не забудьте проверить, что вы можете получать к ней доступ). В качестве альтернативы, вы можете использовать что-нибудь вроде этого макроса:

#define MALLOC(x,y) do { y = malloc(x); if (!y) abort(1); } while(0)


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


Точно также важна проверка возвращаемых значений других функций.


Начальные значения

Если вы объявляете глобальную переменную в C, она безоговорочно инициализируется нулем. Этот способ очень удобный, и был бы ещё более удобным, если бы вы могли верить, что автор вашего компилятора прочитал эту часть спецификации. Как бы то ни было, если вы получаете память из любого другого источника, это правило не работает.


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


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


Ещё несколько проблем могут случиться с неинициализированными переменными. Одна из самых ужасных, что я видел, это когда вы начинаете с кода, который выглядит как-то так:

int a = 42;


А затем вы решаете, что вам необходимо условие для инициализации, поэтому вы копируете и вставляете его в выражение if:

if ({некоторое условие})
{
   int a = 42;
}


Ой, вы же забыли объявить переменную за пределами блока if, поэтому вы делаете это позже, и даете ей значение по умолчанию:

int a = 0;
if ({некоторое условие})
{
   int a = 42;
}


Теперь у вас есть код, который компилируется и запускается. Большинство времени (когда не встречается условие), он будет работать. Но код внутри фигурных скобок будет «холостым». Он определяет новую переменную под названием a и присваивает ей 42. Как только блок закончится, эта переменная выйдет из области видимости и останется старая a, до сих пор со значением 0.


Более незаметный вариант может возникнуть при опечатке в инициализации, когда вы выполняете что-то вроде этого:

int value = value + 12;


когда на самом деле ожидаете выполнение этого:

int value = othervalue + 12;


Как только компилятор распарсит int value, переменная станет валидной и будет находиться в области видимости. Вы можете считать её значение, добавить 12 к нему и присвоить результат ей же. К несчастью для вас, value, которую вы считываете, не определена. Теперь вам придется присвоить value неопределенное значение. Если вы считываете неосторожно, вы можете подумать, что проинициализировали её, хотя вы этого и не сделали. Компилятор с оптимизацией удалит +12, поскольку неопределенность плюс 12 есть неопределенность, и это будет эквивалентно следующему:

int value;


Если в вашем компиляторе есть выдача предупреждений о том, что не было проинициализировано, он должен выявить эти проблемы. К сожалению, выключение этих предупреждений происходит довольно часто, потому как в C нет способа указать выходные параметры. Вот почему следующие вещи встречаются относительно часто:

int a;
call_some_function(&a);


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


Проблемы целых


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


В высокоуровневых языках, числа обычно условно-точные, причем точность определена так, как вам надо. В C есть набор целых типов. Наиболее часто используемый — это int, который должен соответствовать машинному слову. На компьютерах, на которых я учил C, оно было длиной в 16 бит. Теперь он обычно размером в 32 бита, даже на архитектурах, где машинное слово имеет длину 64 бита, так как большое количество написанного кода подразумевает, что он всегда имеет длину 32 бита.


Одна из наиболее частых причин странного поведения — попытка хранить значение указателя в int. На обычных 32-битных платформах, этот метод работает хорошо. На некоторых 64-битных он работает, а на некоторых — нет. Стандарт C99 определяет новый целый тип, intptr_t, который гарантированно имеет достаточный размер, чтобы хранить указатель. Если вы планируете хранить указатель в int, вы всегда должны использовать intptr_t.


Указатель — один из других слегка сбивающих с толку моментов. C определяет два типа указателей: один указывает на код, другой указывает на данные. Указатель на функцию не имеет гарантировано такой же размер, что и указатель, указывающий на данные. На множестве встроенных платформ, часто используются 16-битные указатели для кода и 32-битные для данных. Преобразование типа в void* указателя на функцию приведет к тому, что некоторые данные будут отброшены.


Другим основным видом целого в C является char, short и long. В C99 также определен long long. Ни один из них не имеет фиксированного размера, но все они имеют минимальные размеры (технически у них есть минимальный набор значений, который они могут хранить, не предоставляя гарантий по внутреннему формату). Short должен быть, по крайней мере, 16 бит, long как минимум 32, а lon long как минимум 64. Если вам необходимо минимальное количество точности, выберите один из них, и вы сможете получить дополнительное пространство к тому, что запросили, в зависимости от архитектуры.


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


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

 a = b + c;


Так как вы храните результат в a, вы можете предположить, что сложение будет выполнено, каким бы типом не была a. По факту, оно будет выполнено с типом b и c. Это имеет смысл тогда, когда вы думаете, что лучше иметь значение (b + c) в зависимости от чего-то, чем b и c сами по себе. Вы можете думать, что тип этого выражения будет типом b. Стандарт C99 определяет набор правил для определения типа, но в основном он будет представлять собой тот тип, который больше, b или c. Например, если a — char, а b — int, тип выражения будет int. Поразительно часто встречается ошибка, когда делают что-то вроде такого:

long long a;
long b;// = чему-то
long c;// = чему-то

a = b * c;


a как минимум 64 бита, b и c как минимум 32 бита. Так как у вас есть только гарантия того, что b и c по 32 бита, вы не должны помещать нечто большее в них, даже если ваш компилятор или платформа имеет long в 64 бита. Когда вы умножаете их, вы получаете результат, подходящий для 64-битного int и вы храните его в чем-то, что подходит для 64-битного int. Звучит удобно? Так и есть, кроме того факта, что результат искажается до 32 бит прямо перед присваиванием. Вот правильный способ сделать это:

a = (long long)b * c;


Это расширяет b до 64 бит (или больше, в зависимости от реализации). Расширение типа гарантирует, что c имеет тип такого же размера, что и b, так что она также расширена. Тогда умножение происходит как умножение двух long long с 32 или более первыми нулями, а результат (long long) хранится в переменной типа long long.


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


Более неуловимая ошибка возникает от переполнения int. Она особенна часто возникает при использовании malloc, так как стандартный шаблон написания — malloc(i * sizeof(x)). Если у взломщика есть влияние на i, он может попытаться выполнить это переполнение. Для очень больших значений i, это даст результат гораздо меньший, чем i, что приведет к проблеме. Вызов malloc будет успешным, но когда вы попытаетесь использовать массив, полученный в результате, только первые несколько значений будут валидными. Взломщик может вынудить вас переписать другие данные.


Простым путем избегания этого вида уязвимости может быть использование calloc() вместо malloc() (конечно, в надежде, что реализация calloc в вашей системе производит проверку границ сама, а не просто делает malloc() и memset() для количество*размер байт).


realloc() — ещё одна проблема. Нет стандартного пути сделать это с ней, поэтому вам надо делать это самому. К счастью, OpenSSH включает в себя xrealloc(), который является версией realloc() с проверкой границ. Она включает несколько других проверок, но если вам не нужны все из них, вы можете реализовать упрощенную версию:

void * xrealloc(void *ptr, size_t nmemb, size_t size)
{
    void *new_ptr;
    size_t new_size = nmemb * size;
    if (SIZE_T_MAX / nmemb < size)
            return NULL;
        return realloc(ptr, new_size);
}


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


realloc возвращает NULL в случае ошибки, так что вам всегда следует проверять возвращаемое значение realloc на валидность. К сожалению, это является наиболее частой причиной утечек памяти (которые, в свою очередь, являются причиной атак DDoS). Если realloc() возвращает NULL, исходный указатель по-прежнему является валидным. Часто разработчики забывают этот принцип и просто делают что-то вроде этого:

ptr = realloc(ptr, newsize);


Когда realloc() возвращает NULL, вы теряете вашу ссылку на предыдущее выделение памяти. FreeBSD предоставляет удобную функцию, reallocf(), которая эквивалентна следующей:

void *reallocf(void* ptr, size_t size)
{
    void *newptr = realloc(ptr, size);
    if (NULL == newptr)
    {
        free(ptr);
    }
    return newptr;
}


Если у вас нет кода для обработки случая, когда realloc() завершается с ошибкой, вам необходимо использовать что-то вроде неё.

Report Page