Дэвид Лебланк - 19 смертных грехов, угрожающих безопасности программ
Другая проблема, свойственная разрешительному подходу, состоит в том, что пользователи могут быть недовольны, когда программа не дает вводить допустимые с их точки зрения символы. Например, вы можете запретить символ «+» в почтовых адресах, но найдутся люди, которые применяют этот символ, чтобы отличить тех, кому они сообщили свой адрес. И все же пропускание только разрешенных символов надежнее двух других подходов.
Рассмотрим случай, когда вы принимаете от пользователя значение, интерпретируемое как имя файла. Предположим, что проверка реализована так (код ниже написан на Python):
...for char in filename:
if (not char in string.ascii_letters and not char in string.digits
and char <> '.'):
raise "InputValidationError"
Здесь разрешены точки, чтобы пользователь мог указывать имена файлов с расширением, но о символе подчеркивания мы забыли. При подходе «все кроме» вы могли бы не подумать о том, чтобы запретить косую черту, а это плохо – противник может с помощью символа косой черты и точек сформировать имя файла из другой части файловой системы, вне текущего каталога. Если бы вы применили «закавычивание», то функция проверка оказалась бы гораздо более сложной.
Для такого рода проверок часто применяют регулярные выражения. Однако в регулярном выражении, особенно сложном, легко допустить ошибку. Если вам нужны вложенные конструкции и другие подобные вещи, лучше о регулярных выражениях забыть.
Вообще говоря, с точки зрения безопасности лучше перестраховаться, чем потом кусать локти. Регулярные выражения проще, но не безопаснее, особенно когда для точного контроля нужно учитывать сложную семантику, а не просто сопоставлять с образцом.
Если проверка не проходит
Есть три основные стратегии обработки ошибок. Они не являются взаимоисключающими, и, по крайней мере, первые два способа лучше применять совместно.
□ Известить об ошибке (и, разумеется, не запускать команду, несмотря ни на что). Но думайте о том, как выглядит сообщение об ошибке. Если вы просто включите в него неверные данные, то можете нарваться на атаку с кросс–сайтовым сценарием. Не стоит также сообщать противнику слишком много информации (особенно если в ходе проверки используются данные из конфигурационного файла). Иногда лучше всего просто сказать «недопустимый символ» или что–нибудь, столь же туманное.
□ Протоколировать ошибку и все связанные с ней данные. Но следите, чтобы сам процесс протоколирования не оказался мишенью атаки; некоторые системы протоколирования принимают символы форматирования, а попытка бесхитростно записать в протокол некоторые данные (например, символ возврата каретки или перевода строки) может привести к порче протокола.
□ Модифицировать поступившие данные, заменив их значением по умолчанию или как–то преобразовав.
В общем случае мы не рекомендуем третий вариант. Вы можете ошибиться, но даже в том случае, когда правы вы, а ошибся пользователь, результат может оказаться неожиданным. Лучше совсем отказаться от операции, но сделать это безопасно.
Дополнительные защитные меры
В языке Perl есть средства, которые позволяют обнаружить такого рода ошибки во время выполнения. Это так называемый «осторожный режим» (taint mode). Идея в том, что Perl не позволит передать непроверенные данные любой из перечисленных выше функций. Однако проверка выполняется только в осторожном режиме, поэтому, не включив его, вы не получите никаких преимуществ. Кроме того, вы можете случайно отключить этот режим, предварительно ничего не проверив. Есть и другие мелкие ограничения, поэтому лучше не полагаться только на этот механизм. Тем не менее это прекрасный инструмент для тестирования, и обычно стоит задействовать его в качестве одного из средств защиты.
Для стандартных вызовов API, с помощью которых происходит обращение к командным процессорам, имеет смысл написать собственные обертки, которые фильтруют входные данных по списку разрешенных символов и возбуждают исключение, если что–то не так. Это не должно быть единственным средством контроля, поскольку часто необходима более тщательная проверка. Однако в качестве первой линии обороны сойдет, к тому же и реализовать совсем нетрудно. Можно либо заменить «плохие» функции обертками прямо в библиотеке, либо пропустить исходный текст через простейшую программу поиска, найти все места, где они встречаются, и быстро провести контекстную замену.
Другие ресурсы
□ «How То Remove Metacharacters From User–Supplied Data in CGI Scripts»: www.cert.org/tech_tips/cgi_metacharacters.html
Резюме
Рекомендуется
□ Проверяйте все входные данные до передачи их командному процессору.
□ Если проверка не проходит, обрабатывайте ошибку безопасно.
Не рекомендуется
□ Не передавайте непроверенные входные данные командному процессору, даже если полагаете, что пользователь будет вводить обычные данные.
□ Не применяйте подход «все кроме», если не уверены на сто процентов, что учли все возможности.
Стоит подумать
□ О том, чтобы не использовать регулярные выражения для проверки входных данных; лучше написать простую и понятную процедуру проверки вручную.
Грех 6. Пренебрежение обработкой ошибок
В чем состоит грех
Безопасность подвергается серьезной угрозе, когда программист не уделяет должное внимание обработке ошибок. Иногда программа может оказаться в некорректном состоянии, но чаще все заканчивается отказом от обслуживания, так как приложение просто «падает». Эта проблема не утрачивает значимости даже в таких современных языках, как С# и Java, только в них аварийное завершение программы происходит из–за необработанного исключения, а не из–за того, что автор забыл проверить код возврата.
Суровая реальность такова: любая ненадежность программы, приводящая к краху или перезапуску, – это отказ от обслуживания, а следовательно, может угрожать безопасности, особенно если речь идет о сервере.
Очень часто причиной подобной ошибки становится некритическое копирование кода из какого–нибудь примера. Ведь обычно в примерах для простоты опускают проверку ошибок.
Подверженные греху языки
Уязвим любой язык, в котором функция извещает об ошибке с помощью кода возврата, например ASP, PHP, С и С++, а также языки, полагающиеся на исключения: С#, VB.NET и Java.
Как происходит грехопадение
Есть шесть способов впасть в этот грех:
□ раскрытие излишней информации;
□ игнорирование ошибок;
□ неправильная интерпретация ошибок;
□ бесполезные возвращаемые значения;
□ обработка не тех исключений, что нужно;
□ обработка всех исключений.
Рассмотрим каждый в отдельности.
Раскрытие излишней информации
Эта тема обсуждается в разных главах книги, а особенно в грехе 13. Ситуация типична: возникает ошибка, и вы из соображений «практичности» сообщаете пользователю все подробности случившегося, а заодно советуете, как ошибку исправить. Только вот беда – хакер получает лакомый кусок: сведения, с помощью которых он может скомпрометировать систему.
Игнорирование ошибок
Функция возвращает код ошибки не просто так, а чтобы вызывающая программа могла отреагировать. Согласны, некоторые значения кодов возврата носят чисто информационный характер и зачастую необязательны. Например, значение, возвращаемое функцией printf, проверяется очень редко; если оно положительно, то равно числу выведенных символов, а–1 означает ошибку. Честно говоря, для вызывающей программы ошибка в printf не слишком существенна.
Но чаще возвращаемое значение важно. Например, в Windows есть несколько функций, позволяющих сменить учетную запись, от имени которой работает программа: ImpersonateSelf(), ImpersonateLogonUser() и SetThreadToken(). Если любая из них возвращает ошибку, значит, олицетворение не состоялось, и поток продолжает работать от имени того же пользователя, что и весь процесс.
Или возьмем ввод/вывод. Если вы вызываете функцию fopenQ, а она не может открыть файл (его не существует или к нему нет доступа), и вы не обрабатываете ошибку, то все последующие вызовы fwrite() или fread() тоже завершатся неудачно. А если вы читаете из файла данные, а потом как–то их используете, то приложение может «грохнуться».
В языках, поддерживающих исключения, именно они являются основным механизмом для передачи информации об ошибках. Java пытается заставить программиста обрабатывать ошибки, проверяя во время компиляции, что исключения обрабатываются (или, по крайней мере, ответственность за обработку исключения делегируется вызывающей программе). Однако есть исключения, которые могут возбуждаться в самых разных местах, поэтому Java не требует, чтобы они обрабатывались. Типичный пример – NullPointerException. Это печально, так как любое исключение – признак логической ошибки; если оно возникло, то довольно трудно восстановить нормальную работу программы, пусть даже вы его перехватываете.