Причины и последствия ошибок переполнения
В большинстве языков программирования, в том числе и в Cи/Cи++, массив одновременно является и совокупностью определенного количества данных некоторого типа, и безразмерным
регионом памяти. Программист может получить указатель на начало массива, но не имеет возможности непосредственно определить его длину. Си/Cи ++ не делает особых различный между указателями на массив и указателями на ячейку памяти, и позволяет выполнять с указателями различные математические операции.
Мало того, что контроль выхода указателя за границы массива всецело лежит на плечах разработчика, строго говоря, этот контроль вообще невозможен в принципе! Получив указатель на буфер, функция не может самостоятельно вычислить его размер и вынуждена либо полгать, что вызывающий код выделил буфер заведомо достаточно размера, либо требовать явного указания длины буфера в дополнительном аргументе (в частности, по первому сценарию работает gets, а по второму – fgets).
Ни то, ни другое не может считаться достаточно надежным, - знать наперед сколько памяти потребуется вызывающей функции (за редкими исключениями) невозможно, а постоянная "ручная" передача длины массива не только утомительна и непрактична, но и ничем не застрахована от ошибок (можно передать не тот размер или размер не того массива).
Другая частая причина возникновения ошибок переполнения буфера: слишком вольное обращение с указателями. Например, для перекодировки текста может использоваться такой алгоритм: код преобразуемого символа складывается с указателем на начало таблицы перекодировки и из полученной ячейки извлекается искомый результат. Несмотря на изящество этого (и подобных ему алгоритмов) он требует тщательного контроля исходных данных – передаваемый функции аргумент должен быть неотрицательным числом не превышающим последний индекс таблицы перекодировки. В противном случае произойдет доступ совсем к другим данным. Но о подобных проверках программисты нередко забывают или реализуют их неправильно.
Можно выделить два типа ошибок переполнения: одни приводят к чтению не принадлежащих к массиву ячеек памяти, другие – к их модификации.
В зависимости от расположения буфера за его концом могут находится: а) другие переменные и буфера; б) служебные данные (например, сохраненные значения регистров и адрес возврата из функции); с) исполняемый код; д) никем не занятая или несуществующая область памяти.
Несанкционированное чтение не принадлежащих к массиву данных может привести к утере конфиденциальности, а их модификация в лучшем случае заканчивается некорректной работой приложения (чаще всего "зависанием"), а худшем – выполнением действий, никак не предусмотренных разработчиком (например, отключением защиты).
Еще опаснее, если непосредственно за концом массива следуют адрес возврата из функции – в этом случае уязвимое приложение потенциально способно выполнить от своего имени любой код, переданный ему злоумышленником! И, если это приложение исполняется с наивысшими привилегиями (что типично для сетевых служб), взломщик сможет как угодно манипулировать системой, вплоть до ее полного уничтожения!
Сказанное справедливо и для случая, когда вслед за буфером, подверженном переполнению, расположен исполняемый код. Однако, в современных операционных системах такая ситуация практически не встречается, поскольку они довольно далеко разносят код, данные и стек друг от друга.
А вот наличие несуществующей станицы памяти за концом переполняющегося буфера – не редкость. При обращении к ней процессор генерирует исключение в большинстве случаев приводящее к аварийному завершению приложения, что может служить эффективной атакой "отказа в обслуживании".
Таким образом, независимо от того где располагается переполняющийся буфер – в стеке, сегменте данных или в области динамической памяти (куче), он делает работу приложения небезопасной.
Поэтому, представляет интерес поговорить о том, можно ли предотвратить такую угрозу и если да, то как.