В статье разбираются причины возникновения и последствия ошибок переполнения буфера, предлагаются эффективные методы борьбы с ними. Изложение ориентировано на прикладных разработчиков, работающих с языками программирования Си/Си++ и операционными системами семейства Windows, однако многие из затронутых вопросов применимы и к Unix-подобным системам.

Просматривая популярную рассылку по информационной безопасности BUGTRAQ, легко убедиться, что подавляющее большинство уязвимостей приложений и операционных систем связано с ошибками переполнения буфера. Ошибки этого типа настолько распространены, что вряд ли существует хотя бы один полностью свободный от них программный продукт. Переполнение приводит не только к некорректной работе программы, но и к возможности удаленного вторжения в систему с наследованием всех привилегий компрометированной программы. Это обстоятельство широко используется злоумышленниками. Проблема настольно серьезна, что попытки ее решения предпринимаются как на уровне самих языков программирования, так и на уровне компиляторов. К сожалению, достигнутые результаты оставляют желать лучшего, и ошибки переполнения продолжают появляться даже в современных приложениях. Вот всего несколько примеров: Microsoft Internet Information Service 5.0, Outlook Express 5.5; Netscape Directory Server 4.1x; Apple QuickTime Player 4.1; ISC BIND 8; Lotus Domino 5.0 — список этот можно было бы продолжать и продолжать. А ведь все это серьезные продукты солидных компаний, не скупящихся на тестирование. Давайте поговорим о приемах программирования, следование которым значительно уменьшает вероятность появления ошибок переполнения, в то же время не требуя от разработчика особых дополнительных усилий.

Причины и последствия ошибок переполнения

Во многих языках программирования, в том числе и в Cи/Cи++, массив одновременно является и совокупностью определенного количества объектов данных некоторого типа, и безразмерным фрагментом памяти. Программист может получить указатель на начало массива, но не имеет возможности непосредственно определить его длину. В Си/Си++ не делается особых различий между указателями на массив и указателями на ячейку памяти. Допускаются различные математические операции с указателями.

Мало того, что контроль выхода указателя за границы массива всецело лежит на разработчике — строго говоря, этот контроль невозможен в принципе. Получив указатель на буфер, функция не может самостоятельно вычислить его размер и вынуждена либо полагать, что вызывающий код выделил буфер заведомо достаточного размера, либо требовать явного указания длины буфера в дополнительном аргументе (в частности, по первому сценарию работает gets, а по второму — fgets). Ни то, ни другое не может считаться достаточно надежным; знать наперед сколько памяти потребуется вызывающей функции, вообще говоря, невозможно, а постоянная «ручная» передача длины массива не только утомительна и непрактична, но и не застрахована от ошибок.

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

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

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

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

Предотвращение возникновения ошибок переполнения

Таким образом, независимо от того, где располагается переполняющийся буфер — в стеке, сегменте данных или в области динамической памяти (куче), он делает работу приложения небезопасной. Поэтому представляет интерес поговорить о том, можно ли предотвратить такую угрозу и если да, то как.

Переход на другой язык

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

Именно такой подход использован в языках Ада, Perl, Java и некоторых других. Но сферу его применения ограничивает производительность: постоянные проверки требуют значительных накладных расходов, в то время как отказ от них позволяет транслировать даже серию операций обращения к массиву в одну инструкцию процессора. Тем более, такие проверки налагают жесткие ограничения на математические операции с указателями (в общем случае требуя их запретить), а это в свою очередь не позволяет реализовывать многие эффективные алгоритмы.

Если в критических областях применения (атомной энергетике, космонавтике и т.п.) выбор между производительностью и защищенностью автоматически делается в пользу последней, в офисных и уж тем более в бытовых приложениях ситуация обратная. В лучшем случае речь может идти только о разумном компромиссе. Покупать дополнительные мегабайты и мегагерцы ради одного лишь достижения надлежащего уровня безопасности и без всяких гарантий на отсутствие ошибок других типов рядовой клиент не будет, как бы его ни убеждали. Тем более, что ни Ада, ни Perl, ни Java (языки, не отягощенные проблемами переполнения) принципиально не способны заменить Си/Cи++, не говоря уже об ассемблере. Разработчики оказываются зажатыми между несовершенством используемого ими языка программирования и невозможностью перехода на другой язык. Даже если бы и появился язык, удовлетворяющий всем мыслимым требованиям, совокупная стоимость его изучения и переписывания с нуля созданного программного обеспечения многократно бы превысила убытки от отсутствия в старом языке продвинутых средств контроля за ошибками: производители вообще несут очень мало издержек за «ляпы» в своих продуктах и не особо одержимы идей их тотального устранения.

Использование «кучи» для создания массивов

От использования статических массивов рекомендуется вообще отказаться (за исключением тех случаев, когда их переполнение заведомо невозможно). Вместо этого следует выделять память из кучи (Heap), преобразуя указатель, возвращенный функцией malloc к указателю на соответствующий тип данных (char, int), после чего с ним можно обращаться точно так же, как с указателем на обычный массив. Вернее, почти «точно так» — за двумя небольшими исключениями. Во-первых, получившая такой указатель функция может с помощью вызова msize узнать истинный размер буфера, не требуя от программиста явного указания данной величины. А, во-вторых, если в ходе работы выяснится, что этого размера недостаточно, она может динамически увеличить длину буфера, обращаясь к realloc всякий раз, как только в этом возникнет потребность. В этом случае, передавая функции, читающей строку с клавиатуры, указатель на буфер, не придется мучительно соображать: какой именно величиной следует ограничить его размер, — об этом позаботится сама вызываемая функция. Программисту же не придется добавлять еще одну константу в свою программу.

Отказ от индикатора завершения

По возможности не используйте какой бы то ни было индикатор завершения для распознания конца данных (например, нуль для задания конца строки). Во-первых, это приводит к неопределенности в длине самих данных и количества памяти, необходимой для их размещения, в результате чего возникают ошибки типа buff = malloc(strlen(Str)), которые с первого взгляда не всегда удается обнаружить (правильный код должен выглядеть так: buff = malloc(strlen(Str)+1), поскольку, в длину строки, возвращаемой функцией srtlen, не входит завершающий ее нуль).

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

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

Значительно лучше явным образом указывать размер данных в отдельном поле (так, например, задается длина строк в компиляторах Turbo Pascal и Delphi). Однако такое решение не устраняет несоответствия размера данных и количества занимаемой ими памяти. Поэтому надежнее вообще отказаться от какого бы то ни было задания длины данных и всегда помещать их в буфер строго соответствующего размера.

Избавиться от накладных расходов, связанных с необходимостью частых вызовов достаточно медленной функции realloc можно введением специального ключевого значения, обозначающего отсутствие данных. В частности, для этой цели подойдет тот же нуль, однако, теперь он будет иметь другое значение — обозначать не конец строки, а отсутствие символа в данной позиции. Конец же строки определяется размером выделенного под нее буфера данных. Выделив буфер «про запас» и забив его «хвост» нулями, можно значительно сократить количество вызовов realloc. (Правда, в этом случае за эффективное использование ресурсов процессора приходится расплачиваться существенным перерасходом памяти. — Прим. ред.)

Обработка структурных исключений

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

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

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

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

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

Использование функций, непосредственно работающих с виртуальной памятью, позволяет творить настоящие чудеса, на которые не способны функции стандартной библиотеки Си/Cи++. Единственный их недостаток заключается в непереносимости. Однако эту проблему можно решить, собственноручно реализовав функции VirtualAlloc, VirtualProtect и некоторые другие (правда, в некоторых случаях это придется делать на уровне компонентов ядра; обработка же структурных исключений изначально заложена в Си++).

Таким образом, усилия, направленные на защиту от ошибок переполнения буфера не настолько чрезмерны, чтобы не окупить полученный результат.

Традиции против надежности

Здравый смысл подсказывает: «Если все очень хорошо, то что-то тут не так». Если описанные приемы программирования столь хороши, почему же они не получили массового распространения? Видимо, на практике не все так складно, как на бумаге.

Пожалуй, основной камень преткновения — «верность традициям». В сложившейся культуре программирования признаком хорошего тона считается использование везде, где только возможно, стандартных функций самого языка, а не специфических возможностей операционной системы, «привязывающих» программу к одной платформе. Вряд ли этот подход бесспорен, но многие разработчики ему следуют. Но что лучше — мобильный, но нестабильно работающий и небезопасный код или плохо переносимое (в худшем случае вообще не переносимое), зато устойчивое и безопасное приложение? Если отказ от использования стандартных библиотек позволит уменьшить количество ошибок в приложении и многократно повысить его безопасность, стоит ли этим пренебрегать? Удивительно, но существует и такое мнение, что непереносимость — более тяжкий грех, чем ошибки, от которых, как водится, никто не застрахован. Аргументы: дескать, ошибки — явление временное и теоретически устранимое, а непереносимость — это навсегда. Однако, использование в своей программе функций, специфичных для какой-то одной операционной системы, не является непреодолимым препятствием для ее переноса на платформы, где этих функций нет. Достаточно лишь реализовать эти функции самостоятельно.

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

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

Как с ними борются?

Было бы по меньшей мере удивительно, если бы с ошибками переполнения никто не пытался бы бороться. Такие попытки предпринимались неоднократно, но результат во всех случаях оставлял желать лучшего. Очевидное «лобовое» решение проблемы состоит в синтаксической проверке выхода за границы массива при каждом обращении к нему. Такие проверки реализованы в некоторых компиляторах Си, например, в Compaq C для Tru64 Unix и Alpha Linux. Они не предотвращают возможности переполнения вообще и обеспечивают лишь контроль непосредственных ссылок на элементы массивов, но бессильны предсказать значение указателей.

Проверка корректности указателей вообще не может быть реализована синтаксически, а осуществима только на машинном уровне. Bounds Checker — специальное дополнение для компилятора gcc — именно так и поступает, гарантированно исключая всякую возможность переполнения. Платой за надежность становится значительное, измеряемое десятками раз падение производительности программы. В большинстве случаев это не приемлемо, поэтому такой подход не стал популярен и практически никем не применяется. Bounds Checker хорошо подходит для облегчения отладки приложений, но вовсе не факт, что все допущенные ошибки проявят себя еще на стадии отладки и будут замечены тестировщиками. В рамках проекта Synthetix удалось найти несколько простых и надежных решений, не спасающих от ошибок переполнения, но затрудняющих их использование злоумышленниками для несанкционированного вторжения в систему. StackGuard, еще одно расширение к компилятору gcc, дополняет пролог и эпилог каждой функции особым кодом, контролирующим целостность адреса возврата.

Алгоритм в общих чертах таков: в стек вместе с адресом возврата заносится так называемое Canary Word, расположенный до адреса возврата. Искажение адреса обычно сопровождается и искажением Canary Word, что легко проконтролировать. Соль в том, что Canary Word содержит символы «», CR, LF, EOF, которые могут быть обычным путем введены с клавиатуры. А для усиления защиты добавляется случайная привязка, генерируемая при каждом запуске программы. Компилятор MS Visual C++ также способен контролировать сбалансированность стека на выходе из функции: сразу после входа в функцию он копирует содержимое регистра-указателя вершины стека в один из регистров общего назначения, а затем сверяет их перед выходом из функции. Недостаток: впустую расходуется один из семи регистров и совсем не проверяется целостность стека, а лишь его сбалансированность.

Bounds Checker для Windows 9x/NT, выпущенный компанией NuMega, неплохо отлавливает ошибки переполнения, но, поскольку он выполнен не в виде расширения к какому-нибудь компилятору, а представляет собой отдельное приложение, к тому же требующее для своей работы исходных текстов «подопытной» программы, может использоваться лишь для отладки.

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

Поиск уязвимых программ

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

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

int file(char *buff)
{	char *p; int a=0; char
 proto[10];
p=strchr(&buff[0],?:?);
if (p) {
for (;a!=(p-&buff[0]);a++)
 proto[a]=buff[a];
proto[a]=0;
if (strcmp(&proto[0],»file»))
 return 0; else
WinExec(p+3,SW_SHOW);
}
else WinExec(&buff[0],SW_SHOW);
return 1;
}

main(int argc,char **argv)
{if (argc>1) file(&argv[1][0]);}

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

Приведенный пример — один из самых простых. На практике нередко встречаются и более коварные ошибки, проявляющиеся лишь при стечении множества маловероятных самих по себе обстоятельств. Обнаружить подобные уязвимости только лишь перебором входных данных невозможно (тем не менее, даже такой поиск позволяет выявить огромное число ошибок в существующих приложениях). Значительно лучший результат дает анализ исходных текстов программы. Чаще всего ошибки переполнения возникают вследствие путаницы между длинами и индексами массивов, выполнения операций сравнения до модификации переменной, небрежного обращения с условиями выхода из цикла, злоупотребления операторами «++» и «—», молчаливого ожидания символа завершения и т.д. Например, конструкция «buff[strlen(str)-1]=0», удаляющая заключительный символ возврата каретки, будет «спотыкаться» на цепочках нулевой длины, затирая при этом байт, предшествующий началу буфера.

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

Особняком стоят проблемы многопоточных приложений и ошибки их синхронизации. Однопоточное приложение выгодно отличается воспроизводимостью аварийных ситуаций: установив последовательность операций, приводящих к проявлению ошибки, их можно повторить в любое время требуемое число раз. Это значительно упрощает поиск и устранение источника их возникновения. Напротив, неправильная синхронизация потоков, как и полное ее отсутствие, порождает трудноуловимые «плавающие» ошибки, проявляющиеся время от времени с некоей (возможно, пренебрежительно малой) вероятностью.

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

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

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

Заключение

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

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

Крис Касперски (kpnc@aport.ru) — независимый автор