Просматривая популярную рассылку по информационной безопасности
BUGTRAQ (равно как и любую другую), легко убедиться, что подавляющее
большинство уязвимостей приложений и операционных систем связано с ошибками переполнения буфера (buffers overfull).
Ошибки этого типа настолько распространены, что вряд ли существует хотя
бы один полностью свободный от них программный продукт.
Переполнение приводит не только к некорректной работе
программы, но и возможности удаленного вторжения в систему с
наследованием всех привилегий уязвимой программы. Это обстоятельство
широко используется злоумышленниками для атак на телекоммуникационные
службы.
Проблема настольно серьезна, что попытки ее решения
предпринимаются как на уровне самих языков программирования, так и на
уровне компиляторов. К сожалению, достигнутый результат все еще
оставляет желать лучшего, и ошибки переполнения продолжают появляться
даже в современных приложениях - ярким примером могут служить: Internet Information Service 5.0 (см. Microsoft Security Bulletin MS01-016), Outlook Express 5.5 (см. Microsoft Security Bulletin MS01-012), Netscape Directory Server 4.1x (см. L0PHT A030701-1), Apple QuickTime Player 4.1 (см. SPSadvisory#41), ISC BIND 8 (см. CERT: Advisory CA-2001-02, Lotus Domino 5.0
(см. Security Research Team, Security Bulletin 010123.EXP.1.10) -
список можно продолжать до бесконечности. А ведь это серьезные продукты
солидных производителей, не скупящихся на тестирование!
Ниже вы найдете описание приемов программирования, следование
которым значительно уменьшает вероятность появления ошибок
переполнения, в то же время не требуя от разработчика никаких
дополнительных усилий.
Причины и последствия ошибок переполнения
В большинстве языков программирования, в том числе и в Cи/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.
Обработка структурных исключений
Приемы, описанные выше, реализуются с без особых усилий и излишних
накладных расходов. Единственным серьезным недостатком является их
несовместимость со стандартными библиотеками, т. к. функции стандартных
библиотек интенсивно используют завершающий символ нуля и не умеют по
указателю на начало буфера определять его размер. Частично эта проблема
может быть решена написанием "оберток" - слоя переходного кода,
"посредничающего" между стандартными библиотеками и вашей программой.
Но следует помнить, что описанные подходы сам по себе еще не защищает
от ошибок переполнения, а только уменьшают вероятность их появления.
Они исправно работают в том, и только в том случае, когда разработчик
всегда помнит необходимости постоянного контроля за границами массивов.
Практически гарантировать выполнение такого требования
невозможно и в любой "полновесной" программе, состоящей из сотен и
более тысяч строк, ошибки всегда есть. Это - аксиома, не требующая
доказательств.
К тому же, чем больше проверок делает программа, тем "тяжелее"
и медлительнее получается откомпилированный код и тем вероятнее, что
хотя бы одна из проверок реализована неправильно или по забывчивости не
реализована вообще!
Можно ли, избежав нудных проверок, в то же время получить высокопроизводительный код, гарантированно защищенный от ошибок переполнения?
Несмотря на смелость вопроса, ответ положительный, да - можно! И поможет в этом обработка структурных исключений (SEH).
В общих чертах смысл идеи следующий - выделяется некий буфер, с обоих
сторон "окольцованный" несуществующими страницами памяти и
устанавливается обработчик исключений, "отлавливающий" прерывания,
вызываемые процессором при попытке доступа к несуществующей странице
(вне зависимости от того, был ли запрос на запись или чтение).
Необходимость постоянного контроля границ массива при каждом к
нему обращении отпадает! Точнее, теперь она ложится на плечи
процессора, а от программиста требуется всего лишь написать несколько
строк кода, возвращающего ошибку или увеличивающего размер буфера при
его переполнении.
Единственным незакрытым лазом останется возможность прыгнув
далеко-далеко за конец буфера случайно попасть на не имеющую к нему
никакого отношения, но все-таки существующую страницу. В этом случае
прерывание вызвано не будет и обработчик исключений ничего не узнает о
факте нарушения. Однако, такая ситуация достаточно маловероятна, т.к.
чаще всего буфера читаются и пишутся последовательно, а не в разброс,
поэтому, ей можно пренебречь.
Преимущество от использования технологии обработки структурных
исключений заключаются в надежности, компактности и ясности,
использующего его программного кода, не отягощенного беспорядочно
разбросанными проверками, затрудняющими его понимание.
Основной недостаток - плохая переносимость и
системнозависимость. Не всякие операционные системы позволяют
прикладному коду манипулировать на низком уровне со страницами памяти,
а те, что позволяют - реализуют это по-своему. Операционные системы
семейства Windows такую возможность к счастью поддерживают, причем на
довольно продвинутом уровне.
Функция VirtualAlloc обеспечивает выделение региона
виртуальной памяти, (с которым можно обращаться в точности как и с
обычным динамическим буфером), а вызов VirtualProtect позволят
изменить его атрибуты защиты. Можно задавать любой требуемый тип
доступа, например, разрешить только чтение памяти, но не запись или
исполнение. Это позволяет защищать критически важные структуры данных
от их разрушения некорректно работающими функциями. А запрет на
исполнение кода в буфере даже при наличие ошибок переполнения не дает
злоумышленнику никаких шансов запустить собственноручно переданный им
код.
Использование функций, непосредственно работающих с виртуальной
памятью, воистину позволяет творить настоящие чудеса, на которые
принципиально не способны функции стандартной библиотеки Си/Cи ++.
Единственный их недостаток заключается в непереносимости.
Однако, эта проблема может быть решена написанием собственной
реализации функций VirtualAlloc, VirtualProtect и
некоторых других, пускай в некоторых случаях на уровне компонентов
ядра, а обработка структурных исключений изначально заложена в С++.
Таким образом, затраты на портирование приложений, построенных
с учетом описанных выше технологий программирования, в принципе
возможны, хотя и требует значительных усилий. Но эти усилия не
настолько чрезмерны, что бы не окупить полученный результат.
Традиции vs надежность
Народная мудрость и здравый смысл утверждают, "если все очень хорошо, то что-то тут не так".
Применительно к описанной ситуации - если предложенные автором приемы
программирования столь хороши, почему же они не получили массового
распространения? Видимо, на практике не все так хорошо, как на бумаге.
На самом деле основной "камень преткновения" - верность
традициям. В сложившейся культуре программирования признаком хорошего
тона считается использование везде, где только возможно, стандартных
функций самого языка, а не специфических возможностей операционной
системы, "привязывающих" продукт к одной платформе. Какой бы
небесспорной эта рекомендация ни была, многие разработчики слепо
следуют ей едва ли не с фанатичной приверженностью.
Но что лучше - мобильный, но нестабильно работающий и
небезопасный код или - плохо переносимое (в худшем случае вообще
непереносимое), зато устойчивое и безопасное приложение? Если отказ от
использования стандартных библиотек позволит значительно уменьшить
количество ошибок в приложении и многократно повысить его безопасность,
стоит ли этим пренебрегать?
Удивительно, но существует и такое мнение, что непереносимость
- более тяжкий грех, чем ошибки от которых, как водится, никто не
застрахован. Аргументы: дескать, ошибки - явление временное и
теоретически устранимое, а непереносимость - это навсегда. Можно
возразить - использование в своей программе функций, специфичных для
какой-то одной операционной системы, не является непреодолимым
препятствием для ее портирования на платформы, где этих функций нет, -
достаточно лишь реализовать их самостоятельно (трудно, конечно, но в
принципе осуществимо).
Другая причина не распространенности описанных выше приемов
программирования - непопулярность обработки структурных исключений
вообще. Несмотря на все усилия Microsoft, эта технология так и не
получила массового распространения, а жаль! Ведь при возникновении
нештатной ситуации любое приложение может если не исправить положение,
то, по крайней мере, записать все не сохраненные данные на диск и затем
корректно завершить свою работу. Напротив, если возникшее исключение не
обрабатывается приложением, операционная система аварийно завершает его
работу, а пользователь теряет все не сохраненные данные.
Не существует никаких объективных причин, препятствующих
активному использованию структурной обработке исключений в ваших
приложениях кроме желания держаться за старые традиции, игнорируя все
новые технологии. Обработка структурных исключений - очень полезная
возможность, области применения которой ограничены разве что фантазией
разработчика. И предложенные выше приемы программирования - лучшее тому
подтверждение.
Как с ними борются?
Было бы по меньшей мере удивительно, если бы с ошибками переполнения
никто не пытался бороться. Такие попытки предпринимались неоднократно,
но конечный результат все еще оставляет желать лучшего.
Очевидное "лобовое" решение проблемы заключается в
синтаксической проверке выхода за границы массива при каждом обращении
к нему. Такие проверки опционально реализованы в некоторых компиляторах
Си, например, в компиляторе "Compaq C" для Tru64 Unix и
Alpha Linux. Они не предотвращают возможности переполнения вообще и
обеспечивают лишь контроль непосредственных ссылок на элементы
массивов, но бессильны предсказать значение указателей.
Проверка корректности указателей вообще не может быть реализована синтаксически, а осуществима только на машинном уровне. "Bounds Checker" - специальное дополнение для компилятора gcc
- именно так и поступает, гарантированно исключая всякую возможность
переполнения. Платой за надежность становится значительное, доходящее
до тридцати (!) и более раз падение производительности
программы. В большинстве случаев это не приемлемо, поэтому, такой
подход не сыскал популярности и практически никем не применяется.
"Bounds Checker" хорошо подходит (и широко используется!) для
облегчения отладки приложений, но вовсе не факт, что все допущенные
ошибки проявят себя еще на стадии отладки и будут замечены
beta-тестерами.
В рамках проекта Synthetix удалось найти несколько
простых и надежных решений, не спасающих от ошибок переполнения, но
затрудняющих их использование злоумышленниками для несанкционированного
вторжения в систему. "Stack Guard" - еще одно расширение к
компилятору gcc, дополняет пролог и эпилог каждой функции особым кодом,
контролирующим целостность адреса возврата. Алгоритм в общих чертах
следующий: в стек вместе с адресом возврата заносится, так называемый, "Canary Word",
расположенный до адреса возврата. Искажение адреса возврата обычно
сопровождается и искажением Canary Word, что легко проконтролировать.
Соль в том, что Canary Word содержит символы \0, CR, LF, EOF, которые
не могут быть обычным путем введены с клавиатуры. А для усиления защиты
добавляется случайная привязка, генерируемая при каждом запуске
программы.
Компилятор Microsoft Visual C++ так же способен
контролировать сбалансированность стека на выходе из функции: сразу
после входа в функцию он копирует содержимое регистра-указателя вершины
стека в один из регистров общего назначения, а затем сверяет их перед
выходом из функции. Недостаток: впустую расходуется один из семи
регистров и совсем не проверяется целостность стека, а лишь его
сбалансированность.
"Bounds Checker", выпущенный фирмой NuMega для операционной
системы Microsoft Windows 9x\NT, довольно неплохо отлавливает ошибки
переполнения, но, поскольку, он выполнен не в виде расширения к
какому-нибудь компилятору, а представляет собой отдельное приложение, к
тому же требующее для своей работы исходных текстов "подопытной"
программы, он может быть использован лишь для отладки, и не пригоден
для распространения.
Таким образом, никаких готовых "волшебных" решений проблемы
переполнения не существует и сомнительно, чтобы они появилось в
обозримом будущем. Да и так ли это необходимо при наличие поддержки
структурных исключений со стороны операционной системы и современных
компиляторов? Такая технология при правильном применении обгоняет в
легкости применения, надежности и производительности все остальные, по
крайней мере, существующие на сегодняшний день контролирующие
алгоритмы.
Поиск уязвимых программ
Приемы, предложенные в разделе "Предотвращение ошибок переполнения",
хорошо использовать при создании новых программ, а внедрять их в уже
существующие и более или менее устойчиво работающие продукты -
бессмысленно. Но ведь даже отлаженное и проверенное временем приложение
не застраховано от наличия ошибок переполнения, которые годами могут
спать, пока не будут кем-то обнаружены.
Самый простой и наиболее распространенный метод поиска
уязвимостей заключается в методичном переборе всех возможных длин
входных данных. Как правило, такая операция осуществляется не в ручную,
а специальными автоматизированными средствами. К сожалению, таким
способом обнаруживаются далеко не все ошибки переполнения и наглядной
демонстрацией этого утверждения служит следующая программа:
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]); }
Листинг 1 Пример, демонстрирующий ошибку переполнения буферов
Данная программа запускает файл, имя которого указано в
командной строке. Попытка вызвать переполнение вводом строк различной
длины, скорее всего, ни к чему не приведет. Но даже беглый анализ
исходного кода позволит обнаружить ошибку, допущенную разработчиком.
Если в имени файла присутствует символ ":", программа полагает, что имя
записано в формате "протокол://путь к файлу/имя файла", и пытается
выяснить какой именно протокол был указан. При этом она копирует
название протокола в буфер фиксированного размера, полагая, что при
нормальном ходе вещей его хватит для вмещения имени любого протокола.
Но если ввести строку наподобие "ZZZZZZZZZZZZZZZZZZZZZZ:", произойдет переполнение буфера со всеми вытекающими отсюда последствиями.
Приведенный пример относится к категории простейших. На практике
нередко встречаются и более коварные ошибки, проявляющиеся лишь при
стечении множества маловероятных самих по себе обстоятельств.
Обнаружить подобные уязвимости одним лишь перебором входных данных
невозможно (тем не менее, даже такой поиск позволяет выявить огромное
число ошибок в существующих приложениях).
Значительно лучший результат дает анализ исходных текстов
программы. Чаще всего ошибки переполнения возникают вследствие путаницы
между длинами и индексами массивов, выполнения операций сравнения до
модификации переменной, небрежного обращения с условиями выхода из
цикла, злоупотребления операторами "++" и "—", молчаливого ожидания
символа завершения и т.д.
Например, конструкция "buff[strlen(str)-1]=0", удаляющая
символ возврата каретки, стоящий в конце строки, "спотыкаться" на
строках нулевой длины, затирая при этом байт, предшествующий началу
буфера.
Не менее опасна ошибка, допущенная в следующем фрагменте:
// fgets(&buff[0], MAX_STR_SIZE, stdin); while(buff[p]!='\n') p++; buff[p]=0; //
Листинг 2 Демонстрация некорректного удаления символа возврата каретки, стоящего в конце строки
На первый взгляд все работает нормально, но если пользователь
введет строку равную или превышающую MAX_STR_SIZE, функция fgets
автоматически отбросит ее хвост, вместе с символом возврата каретки. В
результате этого цикл while выйдет за пределы сканируемого буфера и
залезет в совсем не принадлежащую ему область памяти!
Так же часты ошибки, возникающие при преобразовании знаковых
типов переменных в беззнаковые и наоборот. Классический пример такой
ошибки - атака teardrop, возникающая при сборке TCP пакетов
один из которых находится целиком внутри другого. Отрицательное
смещение конца второго пакета относительно конца первого, будучи
преобразованным в беззнаковый тип, становится очень-очень большим
положительным числом и выскакивает далеко за пределы отведенного ему
буфера. Огромное количество операционных систем, подверженных teardrop
атаке наглядно демонстрирует каким осторожным следует быть при
преобразовании типов переменных, и без особой необходимости такие
преобразования и вовсе не следует проводить!
Вообще же, поиск ошибок - дело неблагодарное и чрезвычайно
осложненное психологической инерцией мышления - программист
подсознательно исключает из проверки те значения, которые противоречат
"логике" и "здравому смыслу", но тем не менее могут встречаться на
практике. Поэтому, легче решать эту задачу с обратного конца: сначала
определить какие значения каждой переменной приводят к ненормальной
работе кода (т.е. как бы смотреть на программу глазами взломщика), а уж
потом выяснить выполняется ли проверка на такие значения или нет.
Особняком стоят проблемы многопоточных приложений и ошибки их
синхронизации. Однопоточное приложение выгодно отличается
воспроизводимостью аварийных ситуаций, - установив последователь
операций, приводящих к проявлению ошибки, их можно заново повторять в
любое время любое количество раз. Это значительно упрощает поиск и
устранение источника их возникновения.
Напротив, неправильная синхронизация потоков (а уж тем более -
ее отсутствие), порождает трудноуловимые "плавающие" ошибки, спонтанно
проявляющиеся с некоторой (возможно пренебрежительно малой)
вероятностью.
Рассмотрим простейший пример: пусть один поток модифицирует
строку, и в тот момент, когда на место завершающего нуля помещен новый
символ, а завершающий строку ноль еще не добавлен, второй поток
пытается скопировать эту строку в свой буфер. Поскольку, завершающего
нуля нет, происходит выход за границы массива со всеми вытекающими
отсюда последствиями.
Поскольку, потоки в действительности выполняются не
одновременно, а вызываются поочередно, получая в своей распоряжение
некоторое (как правило, очень большое) количество "тиков" процессора,
вероятность прерывания потока в данном конкретном месте может быть
очень мала и даже самое тщательное и широкомасштабное тестирование не
всегда способно выловить такие ошибки.
Причем, вследствие трудностей воспроизведения аварийной
ситуации, разработчики в подавляющем большинстве случаев не смогут
быстро обнаружить и устранить допущенную ошибку, поэтому, пользователям
придется довольно длительное время работать с уязвимым приложением,
ничем не защищенным от атак злоумышленников.
Печально, но получив в свое распоряжение возможность дробить
процессы на потоки, многие программисты через чур злоупотребляют этим,
применяя потоки даже там, где легко можно было бы обойтись и без них.
Приняв во внимание сложность тестирования многопоточных приложений,
стоит ли удивляется крайней нестабильности многих распространенных
продуктов?
Не призывая разработчиков отказываться от потоков совсем, автор
хотел бы заметить, что гораздо лучше распараллеливать решение задач на
уровне процессов. Достоинства такого подхода следующие:
- а) каждый процесс исполняется в собственном адресном пространстве и полностью (под Windows 9x почти полностью) изолирован от всех остальных;
- б) межпроцессорный обмен может быть построен по схеме, гарантирующей синхронность и когерентность данных;
- с) каждый процесс можно отлаживать независимо от остальных, рассматривая его как однопоточное приложения.
К сожалению, заменить потоки уже существующего приложения на
процессы достаточно сложно и трудоемко. Но порой это все же гораздо
проще, чем искать источник ошибок многопоточного приложения.
Вместо заключения
Задумываться о борьбе с ошибками переполнения следует до начала
разработки программы, а не лихорадочно вспоминать о них на последней
стадии завершения проекта. Конечно, никакие предпринятые меры
не позволят гарантировать отсутствие ошибок, но, во всяком случае,
уменьшат их количество до минимума. Напротив, возлагать решение всех
проблем на beta-тестеров и надеяться, что надежно работающий продукт
удастся создать с одной лишь их помощью - слишком наивно.
Тем не менее именно такую тактику выбрали ведущие фирмы, -
стремясь захватить рынок, они готовы распространять сырой, кишащий
ошибками программный продукт, "доводимый до ума" его пользователями,
сообщающими производителю об обнаруженных ими ошибках, а взамен
получающих либо "заплатку", либо обещание устранить ошибку в
последующих версиях.
Как показывает практика, данная стратегия работает безупречно и
даже обращает ошибку в пользу, а не в убыток - достаточно веской
мотивацией пользователя к приобретению новой версии зачастую становятся
отнюдь не его новые функциональные возможности, а заверения, что все
(или, на худой конец, большинство) присущих ему ошибок теперь
исправлено. На самом деле исправляется лишь незначительная часть от
всех ошибок, и добавляется множество новых, поэтому, такую волынку
можно тянуть до бесконечности, и потребитель (куда ж ему деться) будет
без конца приобретать все новые и новые версии, обеспечивая компании
стабильный доход и процветание.
|