Ниже представлена классификация уязвимостей языка C/C++, немного подправленная и
дополненная, взятая из книги Джека Козиола, Дэвида Личфилда и др.: «Искусство взлома и защиты систем». Список не претендует на полноту, но даёт представление о различных типах уязвимостей которые наиболее часто встречаются.
Всегда полезно знать, какие дефекты часто (или редко) встречаются в приложениях. Попытаемся отметить основные разновидности ошибок, встречающихся и современных приложениях. Каждые несколько лет обнаруживается новая разновидность дефектов, и почти сразу после этого хакеры выявляют целый пласт уязвимостей. Другие уязвимости быстро найти не удается, но в любом случае, чтобы выявите уязвимость, необходимо сначала опознать ее.
1 Общие логические ошибки
Хотя класс общих логических ошибок составляет самую размытую категорию уязвимостей, именно ошибки этой категории лежат в основе многих проблем, Чтобы найти дефекты в логике программирования, создающие угрозу безопасности, необходимо достаточно хорошо понять само приложение. Разберитесь во внутренних структурах и классах, специфических для приложения, и попробуйте изобрести способы их некорректного использования. Например, если в приложении задействована стандартная буферная структура или строковый класс, хорошее понимание логики приложения поможет найти те места, в которых члены структуры или класса применяются некорректно или небезопасно. При анализе достаточно защищенных, хорошо протестированных приложений, поиск общих логических ошибок может стать вторым по эффективности методом анализа.
2 Пережитки прошлого
Некоторые уязвимости, часто встречавшиеся в программах с открытыми исходными текстами еще пять лет назад, в настоящее время почти исчезли. Обычно они воплощаются в форме хорошо
известных функций копирования содержимого памяти без проверки, таких как strcpy, sprintf и strcat Хотя эти функции можно вызывать и приложениях вполне безопасно, раньше они часто использовались неправильно, что приводило к переполнению буфера. Впрочем, в современных программах с открытыми текстами эти типы уязвимостей практически не встречаются. Функции strcpy, sprintf, strcat, gets и другие аналогичные функции не имеют никакой информации о размере приемных буферов. Если правильно выделить приемный буфер пли проверить размер данных перед копированием, большинство функции может использоваться без всякого риска, но в противном случае возникает угроза безопасности. Информация об этих проблемах широко распространена в сообществе разработчиков. Например, в man-страницах функций sprintf и strcpy упоминается об опасности вызова этих функций без предварительной проверки границ.
3 Форматные строки
Уязвимости форматных строк привлекли к себе внимание примерно в 2000 году, и за последние несколько лет было открыто немало серьезных уязвимостей этого класса. Данные дефекты основаны на возможности нападающего контролировать форматную строку, которая передается функциям, получающим аргументы в стиле printf (syslog, *printf и их аналоги). Если нападающий получит контроль над форматной строкой, он сможет передать директивы, приводящие к порче содержимого памяти и выполнению произвольного кода. Эти уязвимости в значительной мере базируются на малоизвестной директиве %n, которая записывает количество уже выведенных байтов по указателю на целое число.
Уязвимости форматных строк очень легко обнаруживаются в процессе анализа. Количество функций, получающих аргументы в стиле printf, относительно невелико; часто достаточно поочередно проверить вызовы этих функций на предмет того, может ли нападающий получить контроль над форматкой строкой. Например, следующие два вызола syslog заметно отличаются друг от друга:
Код потенциальной уязвимостью:
syslog(LOG_ERR,string);
Кол без уязвимости:
sys1og(L0G_ERR,"%s",string);
Если в первом примере строка string окажется под контролем нападающего, может возникнуть угроза безопасности. Чтобы убедиться в существовании уязвимости форматной строки, нередко приходится отслеживать поток данных на несколько функций назад. Некоторые приложения содержат собственные реализации аналогов printf, поэтому анализ не должен ограничиваться узким набором стандартных функции. Процедура выявления дефектов форматных строк достаточно стандартна, поэтому поиск таких дефектов может осуществляться автоматически.
Самым распространенным местом поиска дефектов форматных строк является код ведения журналов. Часто приходится видеть, как константная форматная строка перелается журнальной функции, после чего вывод направляется в буфер и передается syslog с созданием уязвимости.
Следующий гипотетический пример демонстрирует классическую уязвимость форматной строки в коде функции ведения журнала:
void log_fn(const char *fmt....)
{
va_list args;
char log_buf[1024J;
va_start(args,fmt):
vsnprintf(log_buf,sizeof(log_buf),fmt,args);
va_end(args);
syslog(LOG_NOTICE,log_buf);
}
Уязвимости форматных строк впервые были выявлены на сервере wu-ftpd, а в дальнейшем были обнаружены во многих приложениях. Но из-за того, что эти уязвимости очень легко выявляются в процессе анализа, они практически полностью исчезли во всех основных программных пакетах с открытыми текстами.
4 Общие ошибки проверки границ
Нередко приложения пытаются организовать проверку границ при выполнении небезопасных операции; впрочем, на практике эта процедура часто организуется неверно. Ошибки проверки границ отличаются от других классов уязвимостей, в которых проверка вообще отсутствует, однако в конечном счете результат оказывается одним и тем же. Оба типа уязвимостей объясняются логическими ошибками при реализации проверки. Без углубленного анализа кода проверки эти уязвимости часто остаются незамеченными. Другими словам, не стоит полагать, что некоторый фрагмент кода неуязвим только потому, что он пытается проверять границы. Прежде чем следовать дальше, убедитесь и там, что эта попытка делается правильно.
Хорошим примером ошибки проверки границ является дефект препроцессора Snort RFC,
обнаруженный группой ISS X-Force в начале 2003 г. Следующий фрагмент присутствует в
уязвимых версиях Snort:
while( index < end)
{
/* Получить длину фрагмента (31 бит) и переместить указатель
к началу фактических данных */
hdrptr = (int *) index;
length = (int)(*hdrptr & 0x7FFFFFFF);
if (length > size)
{
DebugMessage(DEBUG FLOW, "WARNING: rpc_decode calculated bad "
"length: %d\n", length);
return;
}
else
{
total_len += length;
index += 4:
for (i=0; i < length; i++,rpc++,index++,hdrptr++)
*rpc = *index;
}
}
В контексте приложения length — длина одного фрагмента RPC, a size — размер всего пакета данных. Выходной буфер совпадает с входным, а для ссылок на него в двух разных местах используются переменные грс и index. Программа пытается восстановить фрагменты RPC, удаляя заголовки из потока данных. При каждой итерации цикла позиции rрс и index увеличиваются, a total_len представляет размер данных, записанных в буфер. Здесь сделана попытка организовать проверку границ, однако проверка выполняется неверно. Длина текущего фрагмента RPC сравнивается с общим размером данных, тогда как в действительности общая длина всех фрагментов RPC, включая текущий, должна сравниваться с размером буфера. При невнимательном просмотре кода легко предположить, что проверка выполняется правильно. Данный пример показывает, как важно проконтролировать все фрагменты, обеспечивающие проверку границ в важных местах программы.
5 Циклические конструкции
Переполнение буфера очень часто обнаруживается в циклах — вероятно потому, что с точки зрения программирования они несколько сложнее линейного кода. Чем сложнее цикл, тем больше вероятность того, что ошибка программиста приведет к появлению уязвимости. Многие широко распространенные и критичные в плане безопасности приложения содержат крайне запутанные циклы, часть значений которых небезопасна. Нередко в программах встречаются циклы внутри циклов; так появляются сложные наборы команд, в которых вероятность ошибок весьма велика. Циклы синтаксического разбора и любые циклы обработки пользовательского ввода являются хорошей отправной точкой для анализа приложения. Сосредоточив внимание на этих областях, можно получить ценные результаты с минимальными усилиями.
Интересный пример ошибки в сложном цикле дает уязвимость, которую Марк Дауд (Mark Dowd) обнаружил в функции crackaddr программы Sendmail. Этот цикл слишком велик, чтобы его можно было привести здесь; наверняка он входит в список самых сложных циклов, встречающихся в программах с открытыми исходными текстами. Вследствие сложности и огромного количества переменных, обрабатываемых в цикле, при некоторых комбинациях входных данных происходит переполнение буфера. Sendmail содержит многочисленные проверки для предотвращения переполнения, и все же цикл приводит к непредвиденным последствиям. Некоторые аналитики, в том числе польская группа исследователей в области безопасности «The Last Stages of Delirium», недооценили возможность практического использования этой ошибки просто потому, что не нашли комбинации данных, приводящей к переполнению.
6 Уязвимости единичного смещения
Уязвимости единичного смещения (или на другое небольшое число) принадлежат к числу распространенных ошибок программирования, при которых совсем небольшое число байтов записывается за пределами выделенной памяти. Эти ошибки часто являются результатом некорректного завершения строк нулем, неправильной организации циклов или неудачного использования стандартный строковых функций. В прошлом такие уязвимости встречались в некоторых распространенных приложениях. Например, следующий фрагмент взят из кода Apache 2 (до 2.0.46); позднее эта ошибка была исправлена без особого шума:
if (last_len + len > alloc_len)
{
char *fold_buf;
alloc_len += alloc_len;
if (last_len + len > alloc_len)
{ alloc_len = last_len + len;}
fold_buf = (char *)apr_palloc(r->pool, alloc_len);
memcpy(fold_buf, last_field, last_len);
last_field = fold_buf;
}
memcpy(last_field + last_len, field, len +1);
Код обрабатывает MIME-заголовки, передаваемые как часть запроса веб-серверу. Если первые два условия if истинны, то длина выделенного буфера окажется на 1 меньше, чем следует, и завершающий вызов memcpy запишет пулевой байт за границей буфера. Использовать этот дефект на практике чрезвычайно трудно из-за нестандартной реализации кучи; тем не менее, перед вами несомненный случай ошибки единичного смещения.
Любой цикл, после которого строка завершается нулем, следует дважды проверить на предмет ошибки смещения. Следующий фрагмент FTP-демона Open BSD демонстрирует проблему:
char npath[MAXPATHLEN];
int i;
for (i = 0; *name != '\0' && i < sizeof(npath) – 1; i++, name++)
{
npath[i] = *name;
if (*name == '"')
npath[++i] ='"';
}
npath[i] = '\0';
Хотя программа пытается зарезервировать место для нулевого байта, если последним символом на границе выходного буфера является кавычка, происходит ошибка единичного смешения.
Ошибка единичного смещения возникает также при неправильном использовании некоторых библиотечных функций. Например, функция strncat всегда завершает выходную строку нулем; если третий аргумент не будет соответствовать объему оставшегося места в выходном буфере за вычетом одного байта, то функция запишет нулевой байт за границами буфера.
Пример неправильного вызова strncat:
strcpy(buf."Test:");
strncat(buf,sizeof(buf)-strlen{buf));
Безопасный вызов:
strncat(buf,input,sizeof(buf)-strlen(buf)-1);
7 Ошибки некорректного завершения строк
В общем случае строки должны завершаться нуль-символами; это позволяет легко определить их границы и корректно выполнять операции с ними. Отсутствие завершителей у строк может создать дефекты безопасности при выполнении программы. Например, если строка не завершена положенным символом, содержимое прилегающей памяти будет интерпретировано как продолжение строки. Это может привести к различным последствиям, от включения в строку лишних символов до порчи памяти за пределами строкового буфера операциями, изменяющими строку. Некоторые библиотечные функции являются источником проблем с завершением строк и требуют особого внимания при анализе исходного кода. Например, если у функции strncpy кончается свободное место в приемном буфере, она не завершает записываемую строку нулем.
Программист должен явно записать завершитель, иначе в программе может возникнуть
уязвимость. Например, следующий код небезопасен:
char dest_buf[256];
char not_term_buf[256];
strncpy (not_term_buf,input,sizeof(non_term_buf));
strcpy(dest_buf,not_term_buf);
Так как вызов strncpy не завершает буфер non_term_buf, второй вызов strcpy небезопасен, хотя оба буфера имеют одинаковый размер. Если вставить следующую строку между strncpy и strcpy, угроза переполнения буфера исчезает:
Возможность эксплуатации этих дефектов несколько ограничивается состоянием прилегающих буферов, но во многих ситуациях некорректное завершение строк может привести к выполнению постороннего кода.
8 Пропуск завершителя
Некоторые уязвимости в приложениях появляются в результате пропуска завершающего нулевого байта и продолжения обработки дальше в памяти. Если после пропуска нулевого байта произойдет операция записи, появляется потенциальная возможность порчи содержимого памяти и выполнения постороннего кода. Такие уязвимости обычно возникают в циклах, где строка обрабатывается по одному символу или делаются допущения относительно длины строки.
Следующий фрагмент до недавнего времени присутствовал в модуле mod_rewrite Apache:
else if (is_absolute_uri(r->filename))
{ /* Пропустить 'scheme:' */
for (cp = r->filename; *cp != ':' && *cp != '\0'; cp++)
;
/* Пропустить '://' */
cp += 3;
//Здесь is_absolute_uri делает следующее:
int i = strlen(uri);
if ( (i > 7 && strncasecmp(uri, "http://", 7) == 0)
(i > 8 && strncasecmp(uri, "https://", 8) == 0)
(i > 9 && strncasecmp(uri, "gopher://", 9) == 0)
(i > 6 && strncasecmp(uri, "ftp://", 6) == 0)
(i > 5 && strncasecmp(uri, "ldap://", 5) == 0)
(i > 5 && strncasecmp(uri, "news://", 5) == 0)
(i > 7 && strncasecmp(uri, "mailto://", 7) == 0) ) {
return 1;
}
else {
return 0;
}
Проблема кроется и команде:
В этой команде код обработки пытается обойти конструкцию :// в URI. Тем не менее, обратите внимание, что внутри is_absolute_uri не все схемы URI заверишаются символами ://. При запросе URI вида ldap:a программа пропустит нулевой завершающий байт. Дальнейшая обработка URI приведет к записи нулевого банта, в результате чего возникнет потенциальная уязвимость. В данном случае для этого должны быть установлены некоторые правила перезаписи, но подобные, проблемы все еще часто встречаются в программах с открытыми исходными текстами, и поэтому им следует уделять внимание в процессе анализа.
9 Уязвимости знакового сравнения
Многие программисты пытаются проверять длину вводимых пользователем данных, но при наличии знаковых спецификаторов проверка часто осуществляется неверно. Многие спецификаторы длины (такие, как size_t) являются беззнаковыми, и им не присущи проблемы знаковых спецификаторов вроде off_t. В ходе сравнения двух знаковых целых чисел при проверке длины можно упустить возможность того, что одно из чисел отрицательно, особенно при сравнении с константой.
Правила сравнения разнотипных целых чисел не всегда очевидны по повелению откомпилированного кода. Согласно стандарту ISO для языка С, при сравнении двух целых разного типа или размера они предварительно преобразуются к знаковому типу int, а затем сравниваются.
Если какое-либо из целых превышает по размеру знаковый тип int, оба числа преобразуются к большему типу, а затем сравниваются. При сравнении беззнакового и знакового чисел беззнаковый тип обладает большим приоритетом. Например, следующее сравнение будет беззнаковым:
if((int)left < (unsigned int)right)
С другой стороны, следующее сравнение выполняется как знаковое:
Некоторые операторы (такие, как sizeof()) являются беззнаковыми. Показанное ниже сравнение выполняется как беззнаковое, несмотря на то, что результат оператора sizeof является константой:
if((int) left < sizeof(buf))
Однако следующее сравнение является знаковым, потому что оба коротких целых перед
сравнением преобразуются в знаковые:
if ((unsigned short)a < (short)b)
В большинстве случаев, особенно при использовании 32-разрядных цел их, для обхода проверки размерен необходима возможность напрямую задать целое число. Например, на практике невозможно заставить функцию strlen() вернуть значение, которое может быть преобразовано в отрицательное число, но если целое напрямую читается из пакета, часто удается сделать его отрицательным.
Знаковые сравнения лежат в основе уязвимости Apache, обнаруженной в 2002 г. Причина кроется в следующем фрагменте:
len_to_read = (r->remaining > bufsiz) ? bufsiz : r->remaining;
len_read = ap_bread(r->connection->client, buffer, len_to_read);
Здесь bufsiz — знаковое целое число, определяющее объем свободного места в буфере, a r->remaining — знаковое число типа off_t. определяющее размер фрагмента и читаемое непосредственно из запроса. Предполагается, что переменной len_to_read будет присвоено
наименьшее значение из bufsiz и r->remaining, но если размер фрагмента отрицателен, эту
проверку удается обойти. При передаче отрицательного размера фрагмента ap_bread значение преобразуется в очень большое положительное число, и происходит очень большая операция memcpy. Дефект просто и очевидно эксплуатировался в Win32 посредством замены SEH, а группа Gobbles Security Group доказала, что он также может использоваться в BSD из-за ошибки в реализации memcpy.
Дефекты этого типа продолжают встречаться и в современных программах. Будьте внимательны везде, где знаковые целые числа используются в качестве спецификаторов длины.