Рис. 22.1. Карта памяти стека приложения
Следующий участок стека содержит локальные переменные, используемые кодом, который запускает остальную часть программы. Здесь мы вызываем функцию _main(), несмотря на то что это может принести дополнительные сложности, поскольку касается таких моментов, как динамическая загрузка. Когда запускающий код вызывает для программы метод main(), он сохраняет адрес, который метод main() возвращает при завершении работы со стеком. Когда активизируется main(), может понадобиться сохранение некоторых регистров микропроцессора в стеке с возможностью повторного использования этих регистров. Затем метод выделяет пространство для своих локальных переменных.
Возвращаясь к нашему примеру переполнения буфера, следует отметить, что для переменной path выделяется память на верхушке стека. Байт path[0] находится на самом верху, затем следующий байт — path[1] и так далее. Если наша программа-пример записывает в path более _POSIX_PATH_MAX байтов, то начинается перезапись остальных элементов стека. Если этот процесс продолжается, то происходит попытка записи за пределами верхушки стека, что вызывает ошибку сегментации.
Существенная проблема возникает, если программа записывает возвращаемый адрес вне пределов стека, но не порождает ошибку сегментации. Это позволяет изменять адрес, возвращаемый из работающей функции, на любой случайный адрес в памяти. Когда функция возвращает управление, программа переходит к данному случайному адресу и продолжает выполнение с этой точки.
Реализации, использующие переполнение буфера, как правило, включают некоторый код в массив, записываемый в стек, и возвращаемый адрес устанавливается на этот код. Этот прием позволяет взломщику запускать любой произвольно выбранный код с теми правами доступа, которыми обладает атакуемая программа. Если эта программа является сетевым демоном, работающим как root, то любой удаленный пользователь получает доступ root к локальной системе!
Обработка строк — не единственное место, в котором встречается переполнение буфера (хотя, пожалуй, наиболее распространенное). Еще одним уязвимым моментом является чтение файлов. Файловые форматы нередко сохраняют размер элемента данных, за которым следуют сами данные. Если размер сохранения используется для выделения буфера, а конец поля данных определяется каким-то другим способом, может произойти ошибка переполнения буфера. Этот тип ошибки сделал возможным для Web-сайтов обращение к файлам, которые искажены так, чтобы предоставить удаленное пользование.
Чтение данных через сетевое соединение предоставляет еще одну возможность для переполнения буфера. Многие сетевые протоколы указывают максимальный размер для полей данных. Например, протокол ВООТР[160] фиксирует для всех пакетов размер 300 байтов. Это, однако, не мешает другой машине передать через сеть 350-байтовый пакет ВООТР. Если в сети работают программы с дефектами, то они попытаются скопировать этот нестандартный 350-байтовый пакет в пространство, выделенное для корректного 300-байтового пакета ВООТР, тем самым вызовут переполнение буфера.
Локализация и трансляция служат еще двумя побудителями переполнения буфера. Если программа написана для английского языка, то без сомнения для хранения названия месяца, загружаемого из таблицы, будет достаточно десятисимвольной строки. Когда эта программа переводится на испанский, "September" превращается в "Septiembre" и может произойти переполнение буфера. Всякий раз, когда программа поддерживает различные языки и локали, большинство первоначально статических строк становятся динамическими, и внутренние строковые буферы должны это учитывать.
Теперь уже очевидно, что переполнение буфера представляет собой критическую проблему в системе безопасности. Ее очень легко упустить из виду во время программирования (в конце концов, кто должен волноваться о файловых именах, длина которых превышает _POSIX_PATH_MAX?), и этим чрезвычайно легко воспользоваться.
Существует несколько приемов для устранения из кода возможности переполнения буфера. Хорошо продуманные программы используют множество способов для внимательного выделения буферов соответствующих размеров.
Лучшим способом распределения памяти для объектов является метод malloc(), который устраняет проблемы, возникающие из-за перезаписывания возвращаемого адреса, поскольку malloc() не выделяет память из стека. Аккуратное применение функции strlen() для вычисления необходимого размера и динамическое выделение буфера в программной куче обеспечивает хорошую защиту от переполнения. К сожалению, при этом также расходуется память, поскольку каждый вызов метод malloc() требует вызова метода free(). В главе 7 обсуждалось несколько способов отслеживания ненужных расходов памяти, однако даже с описанными инструментами трудно точно знать, когда можно освободить память, занимаемую объектом. Особенно в том случае, если динамическое распределение памяти для объекта подстроено под уже существующий код. Функция alloca() предлагает альтернативу malloc().
#include <alloca.h>
void * alloca(size_t size);
Подобно malloc(), alloca() выделяет область памяти длиной size байтов и возвращает указатель на начало этой области. Вместо использования памяти из программной кучи этот метод распределяет память из вершины стека, из того же места, где хранятся локальные переменные. Первое преимущество данной функции перед локальными переменными состоит в том, что необходимое количество байтов точно вычисляется в программе, а не определяется приблизительно. Превосходство над malloc() заключается в том, что при завершении работы функции память освобождается автоматически. Все это позволяет охарактеризовать alloca() как легкий способ распределения памяти, которая требуется только временно. До тех пор, пока размер буфера вычисляется должным образом (не забудьте учесть ' ' в конце каждой строки С!), можно не бояться переполнения буфера[161].
Есть еще также несколько других функций, которые помогают избежать переполнения буфера. Библиотечные методы strncpy() и strncat() легко предотвращают перегрузки буфера при копировании строк.
#include <string.h>
char * strncpy (char * dest, const char * src, size_t max);
char * strncat (char * dest, const char * src, size_t max);
Обе функции ведут себя как их родственники, называемые аналогично, strcpy() и strcat(), но они возвращают за один раз только max байт, копируемые в строку назначения. Если достигнут предел, то результирующая строка не завершается ' ', поэтому обычные строковые функции не смогут с ней работать. По этой причине необходимо явно завершить строку после вызова одной из подобных функций.
strncpy(dest, src, sizeof(dest));
dest[sizeof(dest) - 1] = ' ';
Частой ошибкой при использовании strncat() является передача общего размера dest в качестве параметра max. Это приводит к потенциальному переполнению буфера, так как strncat() добавляет до max байт в dest; она не прекращает копировать байты, когда общая длина dest достигает max байтов.
Несмотря на то что эти функции могут сделать выполнение программы некорректным при передаче длинных строк (из-за усечения этих строк), данный прием хорошо предотвращает перегрузки в буферах статических размеров. Во многих случаях это приемлемый компромисс (во всяком случае, при этом не произойдет ничего хуже того, что может случиться из-за переполнения буфера).
Функция strncpy() решает проблему копирования строки в статический буфер без переполнения его. А функции strdup() автоматически выделяют буфер, достаточный для хранения строки, до начала копирования в него исходной строки.
#include <string.h>
char * strdup(const char * src);
char * strdupa(const char * src);
char * strndup(const char * src, int max);
char * strndupa(const char * src, int max);
Первая из приведенных функций, strdup(), копирует строку src в буфер, выделенный методом malloc(), и возвращает буфер вызывающему оператору. Вторая функция, strdupa(), выделяет буфер с помощью alloca(). При этом обе функции выделяют буфер, в точности достаточный для хранения строки и замыкающего символа ' '.
Остальные две функции, strndup() и strndupa(), копируют не более чем max байтов из str в буфер вместе с замыкающим ' ' (и выделяют не более чем max+1 байтов). При этом выделение буфера происходит при помощи метода malloc() (для strndup()) или alloca() (для strndupa()).
Функция sprintf() также входит в число тех, которые часто вызывают переполнение буфера. Так же как strcat() и strcpy(), функция sprintf() имеет разновидность, позволяющую облегчить защиту от перегрузок.