47 Написание
надежных программ.
Подавляющее большинство уязвимостей приложений и операционных систем связано с ошибками переполнения буфера. Ошибки этого типа настолько распространены, что вряд ли существует хотя бы один полностью свободный от них программный продукт.
Переполнение приводит не только к некорректной работе программы, но и возможности удаленного вторжения в систему с наследованием всех привилегий уязвимой программы. Это обстоятельство широко используется хакерами.
Причины и последствия
ошибок переполнения.
В большинстве языков программирования, в том числе и в Cи/Cи ++, массив одновременно является и совокупностью определенного количества данных некоторого типа, и безразмерным регионом памяти. Программист может получить указатель на начало массива, но не в состоянии непосредственно определить его длину: Си/Cи ++ не делает особых различный между указателями на массив и указателями на ячейку памяти, и позволяет выполнять с указателями различные математические операции.
Можно выделить два типа ошибок переполнения: одни приводят к чтению не принадлежащих к массиву ячеек памяти, другие - к их модификации. В зависимости от расположения буфера за его концом могут находится:
1)другие переменные и буфера;
2)служебные данные (например, сохраненные значения регистров и адрес возврата из функции);
3)исполняемый код;
4)никем не занятая или несуществующая область памяти.
Несанкционированное чтение не принадлежащих к массиву данных может привести к утере конфиденциальности, а их модификация в лучшем случае заканчивается некорректной работой приложения (чаще всего "зависанием"), а худшем - выполнением действий, никак не предусмотренных разработчиком (например, отключением защиты).
Еще опаснее, если непосредственно за концом массива следуют адрес возврата из функции - в этом случае уязвимое приложение потенциально способно выполнить от своего имени любой код, переданный ему злоумышленником! И, если это приложение исполняется с наивысшими привилегиями (что типично для сетевых служб), взломщик сможет как угодно манипулировать системой, вплоть до ее полного уничтожения!
Таким образом, независимо от того, где располагается переполняющийся буфер - в стеке, сегменте данных или в области динамической памяти (куче), он делает работу приложения небезопасной.
Предотвращение
возникновения ошибок переполнения.
1. Переход на другой язык (контроль адресации к элементам массива, например Java), использование классов массивов (CArray).
Но такой подход ограничивает производительность. Более того, такие проверки налагают жесткие ограничения на математические операции с указателями, а это не позволяет реализовывать многие эффективные алгоритмы.
Если в критических инфраструктурах (атомной энергетике, космонавтике) выбор между производительностью и защищенностью автоматически делается в пользу последней, в корпоративных, офисных и уж тем более бытовых приложениях наблюдается обратная ситуация. Однако покупать дополнительные мегабайты и мегагерцы ради одного лишь достижения надлежащего уровня безопасности и без всяких гарантий на отсутствие ошибок других типов, рядовой клиент ни сейчас, ни в отдаленном будущем не будет, как бы фирмы-производители его ни убеждали.
Тем более, что такие языки как Java (т. е. языки, не отягощенные проблемами переполнения)
принципиально не способны заменить Си/C++, не говоря уже об ассемблере! Разработчики
оказываются зажатыми несовершенством используемого ими языка программирования с
одной стороны, и невозможностью перехода на другой язык, - с другой.
2. Использование кучи
для создания массивов
От использования статических массивов рекомендуется вообще отказаться (за исключением тех случаев, когда их переполнение заведомо невозможно). Вместо этот следует выделять память из кучи (heap), преобразуя указатель, возвращенный функцией malloc к указателю на соответствующий тип данных (char, int), после чего с ним можно обращаться точно так, как с указателем на обычный массив.
Во-первых, получившая такой указатель функция может с помощью вызова _msize[1] узнать истинный размер буфера, не требуя от программиста явного указания данной величины. А, во-вторых, если в ходе работы выяснится, что этого размера недостаточно, функция может динамически увеличить длину буфера, обращаясь к realloc всякий раз, как только в этом возникнет потребность.
В этом случае, передавая функции, читающей строку с клавиатуры, указатель на буфер, не придется мучительно соображать: какой именно величиной следует ограничить его размер, - об этом позаботиться сама вызываемая функция, и программисту не придется добавлять еще одну константу в свою программу!
3. Отказ от
индикатора завершения
По возможности, не используйте какой бы то ни было индикатор завершения для распознания конца данных (например, символ нуля для задания конца строки). Во-первых, это приводит к неопределенности в длине самих данных и количества памяти, необходимой для их размещения, в результате чего возникают ошибки типа "buff = malloc(strlen(Str))", которые с первого взгляда не всегда удается обнаружить. (Пояснение для начинающих разработчиков: правильный код должен выглядеть так: "buff = malloc(strlen(Str)+1)", поскольку, в длину строки, возвращаемой функцией srtlen, не входит завершающий ее ноль).
Во-вторых, если по каким-то причинам индикатор конца будет уничтожен, функция, работающая с этими данными, "забредет" совсем не в свой "лес".
В-третьих, такой подход приводит к крайне неэффективному подсчету объема памяти, занимаемого данным, - приходится их последовательно перебирать один за другим до тех пор пока не встретится символ конца, а, поскольку, по соображениям безопасности, при каждой операции конкатенации и присвоения необходимо проверять достаточно ли свободного пространства для ее завершения, очень важно оптимизировать этот процесс.
Значительно лучше явным образом указывать размер данных в отдельном поле (так, например, задается длина строк в компиляторах Pascal и Delphi). Однако, такое решение не устраняет несоответствия размера данных и количества занимаемой ими памяти, поэтому, надежнее вообще отказаться от какого бы то ни было задания длины данных и всегда помещать их в буфер строго соответствующего размера.
4. Обработка
структурных исключений
Выделяется некий буфер, с обоих сторон "окольцованный" несуществующими страницами памяти и устанавливается обработчик исключений, "отлавливающий" прерывания, вызываемые процессором при попытке доступа к несуществующей странице (вне зависимости от того, был ли запрос на запись или чтение). Необходимость постоянного контроля границ массива при каждом к нему обращении отпадает! Точнее, теперь она ложится на плечи процессора, а от программиста требуется всего лишь написать несколько строк кода, возвращающего ошибку или увеличивающего размер буфера при его переполнении.
Единственной возможностью ошибки является попытка прыгнуть
далеко-далеко за конец буфера случайно попасть на не имеющую к нему никакого
отношения, но все-таки существующую страницу. В этом случае прерывание вызвано
не будет и обработчик исключений ничего не узнает о факте нарушения. Однако,
такая ситуация достаточно маловероятна, т.к. чаще всего буфера читаются и
пишутся последовательно, а не в разброс, поэтому, ей можно пренебречь.
Преимущество от использования технологии обработки структурных исключений заключаются в надежности, компактности и ясности, использующего его программного кода, не отягощенного беспорядочно разбросанными проверками, затрудняющими его понимание.
Основной недостаток - плохая переносимость и системнозависимость. Не всякие операционные системы позволяют прикладному коду манипулировать на низком уровне со страницами памяти, а те, что позволяют - реализуют это по-своему. Операционные системы семейства Windows такую возможность к счастью поддерживают.
Функция VirtualAlloc обеспечивает выделение региона виртуальной памяти, (с которым можно обращаться в точности как и с обычным динамическим буфером), а вызов VirtualProtect позволят изменить его атрибуты защиты. Можно задавать любой требуемый тип доступа, например, разрешить только чтение памяти, но не запись или исполнение. Это позволяет защищать критически важные структуры данных от их разрушения некорректно работающими функциями.
Для обработки исключительных ситуаций выполнения (переполнение, деление на ноль, обращение к недопустимой области памяти) в VC++ предусмотрено несколько ключевых слов.
__try {} // Защищенный блок программы. При возникновении исключения в данном блоке управление передается в блок __except или __finally
__except() {} // Обработка исключительной ситуации, после чего управление передается на следующую команду