Дэвид Лебланк - 19 смертных грехов, угрожающих безопасности программ
Но и для тех исключений, которые Java заставляет обработать, компилятор не в состоянии проконтролировать, что вы делаете это сколько–нибудь разумным образом. Часто в этом случае просто завершают программу, даже не пытаясь восстановиться, а это все тот же отказ от обслуживания. Еще того хуже и, как это ни грустно, гораздо более распространена практика включать пустой обработчик исключения, в результате чего оно распространяется дальше. Но об этом позже.
Неправильная интерпретация ошибок
Некоторые функции, например recv() (для чтения из сокета), ведут себя просто странно. recv() может вернуть одно из трех значений. В случае успешного завершения возвращается длина сообщения в байтах. Если в буфере сокета ничего нет и удаленный хост выполнил аккуратное размыкание соединения (orderly shutdown), то recv() возвращает 0. В противном случае возвращается–1, а в переменную errno записывается код ошибки.
Бесполезные возвращаемые значения
Некоторые функции из стандартной библиотеки С попросту опасны, например strncpy не возвращает никакого уведомления об ошибке, а лишь указатель на целевой буфер независимо от того, как завершилась операция копирования. Если в результате вызова произошло переполнение буфера, то вы получите указатель на переполненный буфер! Если вам нужен был довод в пользу отказа от использования этих ужасных функций С, так вот он!
Обработка не тех исключений, что нужно
В языках, поддерживающих исключения, надо внимательно относиться к тому, какие именно исключения вы обрабатываете. Например, стандарт С++ гласит:
Функция выделения памяти извещает об ошибке, возбуждая исключение bad_alloc. В этом случае никакая инициализация не проведена.
Но так, к сожалению, бывает не всегда. Например, в библиотеке Microsoft Foundation Classes оператор new в случае ошибки может возбуждать исключение CMemoryException, а многие современные компиляторы С++ (в том числе Microsoft Visual С++ и gcc) позволяют использовать спецификацию std::nothrow, чтобы предотвратить возбуждение исключения оператором new. Поэтому если ваша программа готова обрабатывать исключения типа FooException, а код внутри блока try/catch возбуждает Bar Exception, то программа завершится аварийно, ибо перехватывать Bar Exception некому. Разумеется, можно было бы перехватить все исключения, но это тоже плохо, а почему, мы расскажем в следующем разделе.
Обработка всех исключений
Казалось бы, тема этого раздела – обработка всех исключений – прямо противоположна названию греха «Пренебрежение обработкой ошибок», но на самом деле то и другое тесно связано. Обрабатывать все исключения так же плохо, как вообще не обрабатывать ошибки. Тем самым программа «глотает» ошибки, о которых ничего не знает, которые не может обработать или – и это самое страшное–которые маскируют логические дефекты. Если вы притворяетесь, что никакой ошибки не произошло, то скрытые «баги», о которых вы ничего не знаете, рано или поздно проявятся и программа «умрет» от «непостижимой» причины, да так, что отладить ее будет очень непросто.
Греховность C/C++
В примере ниже автор проверяет неинформативное значение, возвращенное функцией, – strncpy просто возвращает указатель на начало целевого буфера. Эта информация бесполезна.
...char dest[19];
char *p = strncpy(dest, szSomeLongDataFromAHax0r, 19);
if (p) {
// все сработало отлично, поинтересуемся значением dest или p
}
Переменная р указывает на начало dest вне зависимости от того, что произошло внутри strncpy. А между тем эта функция не завершает буфер нулем, если длина исходных данных больше или равна размеру буфера dest. При взгляде на этот код закрадывается подозрение, что автор просто не понимает, что именно возвращает strncpy, ожидая, что в случае ошибки получит NULL. Ох, грехи наши тяжкие!
Следующий код тоже часто встречается на практике. Да, здесь возвращаемое значение проверяется, но только внутри макроса assert, который исчезнет из программы, как только будет выключен отладочный режим. Кроме того, не проверяются аргументы функции, но это уже другая тема.
...DWORD OpenFileContents(char *szFileName) {
assert(szFileName != NULL);
assert(strlen(szFileName) > 3);
FILE *f = fopen(szFileName, "r");
assert(f);
// Можно работать с файлом
return 1;
}
Греховность C/C++ в Windows
Мы уже говорили, что в Windows есть функции олицетворения, которые могут завершаться неудачно. Более того, в Windows Server 2003 появилась новая привилегия, которая разрешает выполнять олицетворение только определенным учетным записям, например службам (LocalSystem, LocalService и NetworkService), а также администраторам. Следовательно, ваша программа может не сработать при таком вызове функции олицетворения:
...ImpersonateNamedPipeClient(hPipe);
DeleteFile(szFileName);
RevertToSelf();
Проблема в том, что если процесс работает от имени LocalSystem, а вызывает этот код низкопривилегированный пользователь, то обращение к DeleteFile может завершиться с ошибкой, так как у пользователя нет доступа к файлу. Надо полагать, именно этого вы и хотели. Но если функция олицетворения возвращает ошибку, то поток продолжает работать в контексте той учетной записи, от имени которой был запущен процесс. А это LocalSystem, и у нее, скорее всего, есть право на удаление файла! Итак, низкопривилегированный пользователь только что удалил ценный файл!
В следующем примере обрабатываются все вообще исключения. Механизм структурной обработки исключений (SEH) в Windows работает для любого языка:
...char *ReallySafeStrCopy(char *dest, const char *src) {
__try {
return strcpy(dst, src);
} __except (EXCEPTION_EXECUTE_HANDLER) {
// замаскировать ошибку
}
return dst;
}
Если в strcpy происходит ошибка из–за того, что длина src больше длины dest или src равно NULL, то вы понятия не имеете, в каком состоянии осталось приложение. Содержимое dst корректно? В зависимости от того, где размещен буфер dest, каково состояние кучи или стека? Ничего не известно, но приложение будет продолжать работать, возможно, даже несколько часов, пока, наконец, с грохотом не рухнет. Поскольку разрыв во времени между моментом ошибки и окончательным сбоем так велик, никакая отладка не поможет. Не поступайте так.
Греховность С++
В следующем примере оператор new не возбуждает исключения, поскольку вы явно запретили компилятору это делать! Если внутри new возникнет ошибка, а вы попробуете воспользоваться переменной р, беды не миновать.
...try {
struct BigThing { double _d[16999]; };
BigThing *p = new (std::nothrow) BigThing[14999];
// воспользуемся p
} catch(std::bad_alloc& err) {
// обработать ошибку
}
В примере ниже программа ожидает исключения std::bad_alloc, но работает с библиотекой Microsoft Foundation Classes, в которой оператор new возбуждает исключение CMemory Exception:
...try {
CString str = new CString(szSomeReallyLongString);
// воспользуемся str
} catch(std::bad_alloc& err) {
// обработать ошибку
}
Греховность C#, VB.NET и Java
На примере показанного ниже псевдокода демонстрируется, как не следует обрабатывать исключения. Здесь перехватываются все возможные исключения, а это, как и приведенный выше пример Windows SEH, может замаскировать ошибки.
...try {
// (1) Загрузить XML-файл с диска
// (2) Извлечь из XML-данных URI
// (3) Открыть хранилище клиентских сертификатов и достать оттуда
// сертификат в формате X.509 и закрытый ключ клиента
// (4) Выполнить запрос на аутентификацию к серверу, определенному
// на шаге (2), используя сертификат и ключ из шага (3)
} catch (Exception e) {
// Обработать все возможные ошибки,
// включая и те, о которых я ничего не знаю
}
Упомянутые в этом примере функции могут возбуждать самые разнообразные исключения. Если речь идет о каркасе .NET, то к ним относятся: SecurityEx–ception, XmlException, IOException, ArgumentException, ObjectDisposedExcep–tion, NotSupportedException, FileNotFoimdException и SocketException. Ваша часть программы действительно знает, как все их корректно обработать?
Не поймите меня неправильно. Иногда перехват всех исключений – вещь совершенно нормальная, только убедитесь, что вы понимаете то, что делаете.
Родственные грехи
Этот грех стоит особняком, никакие другие с ним не связаны. Впрочем, первая его разновидность обсуждается более подробно в грехе 13.
Где искать ошибку
Так просто и не скажешь, нет характерных признаков. Самый эффективный способ – провести анализ кода.
Выявление ошибки на этапе анализа кода
Обращайте особое внимание на следующие конструкции:
Тестирование
Как отмечено выше, лучший способ обнаружить проявления греха заключается в анализе кода. Тестирование затруднительно, поскольку предполагается, что вы должны заставить функцию систематически возвращать ошибку. С точки зрения экономичности и затраченных усилий анализ кода – это самое дешевое средство.
Существуют некоторые инструменты, аналогичные lint, которые обнаруживают отсутствующие проверки кода возврата.
Примеры из реальной жизни
Следующий пример взят из базы данных CVE (http://cve.mitre.org).