Сообщений 2    Оценка 131 [+0/-1]         Оценить  
Система Orphus

Строка формата строки

Ошибки, возникающие при использовании printf()-подобных функций

Автор: Алексей Серебряков
Источник: RSDN Magazine #2-2008
Опубликовано: 27.08.2008
Исправлено: 10.12.2016
Версия текста: 1.0
Проблема
Общая ошибка при использовании printf()-подобных функций
Описание
Любопытный User Input – кавычки %x %d %n
Читаем стек
Немного больше о printf()
Смотрим стек
Защита
Пример
Послесловие
Источники

Кто бы мог подумать, что столь любимая всеми функция printf() может стать причиной проблем? Воспользовавшись программой, которая ошибочно использует printf()-подобные функции, злоумышленник может получить доступ к памяти потока/процесса/приложения. Это, конечно же, может помочь ему получить контроль над системой, если например, процесс запущен с правами root или администратора. После ошибки с переполнением буфера, ошибки, связанные с форматом строки, являются следующими по важности проблемами в программировании, поскольку позволяют злоумышленнику получить контроль над системой. Но если проблемы переполнения буфера широко обсуждали с 1996 года, на проблему с форматом строки обратили внимание, только начиная с 2000 года.

Проблема

Думаю, всем известна такая программа как SORT.EXE в Windows. Тогда сразу и покажем проблему. В данном случае нам не так важно, что делает это программа, важно лишь, что на входе она ожидает в качестве параметра текстовую строку. Проведем два эксперимена

C:\> sort.exe “%x%x%x”

На моей машине с русской Windows XP SP2 результат выглядит так:

7c812f3900Не удается найти указанный файл.

Не совсем очевидно, что же это за результат такой.

А затем такую:

C:\> sort.exe “Hello world!%n”

Результат этой команды более понятен, но до сих пор непонятно, почему же так происходит:


В обоих случаях внимательно посмотрите на результат выполнения! Далее я покажу, почему так происходит.

Общая ошибка при использовании printf()-подобных функций

        // правильно: “%s” – формат строки buffer
printf(“%s”, buffer); 

// неправильно: buffer (сама строка) интерпретируется как формат строки
printf(buffer); 

К сожалению, неверный вариант прекрасно компилируется даже без предупреждений. Компилятор «уверен», что в функции присутствует более одного аргумента. Но что еще печальней, этот неверный вариант еще и «работает». Программа будет запускаться и работать до какого-то момента. printf()-подобные функции предполагают, что присутствует аргумент, определяющий формат строки. Обычно это первый аргумент, заключённый в кавычки.

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

Описание

Давайте подумаем, что же всё-таки происходит, когда в программе используется неверный вариант использования printf()-подобных функций? В таком случае функция printf() предполагает, что buffer – это и есть формат строки. Это значит, например, что если злоумышленник загрузит в buffer строку типа «%d», функция будет предполагать, что это и есть строка формата, говорящая о том, что нужно напечатать целое десятичное число. ОК! Она так и попробует сделать. Но вот проблема, а где же взять-то это число? Правильно, функция printf() ожидает, что число это будет передано следом. Учитывая, что аргументы, с которыми вызывается функция, находятся в стеке, функция printf() должна «заглянуть» в стек и «взять» содержимое памяти, идущее следом за переменной buffer. Она так и делает. В результате выводится какой-то мусор, напоминающий адрес в памяти. Ура, мы напечатали мусор! Но подождите, что же это всё-таки за мусор? Посмотрим!

Любопытный User Input – кавычки %x %d %n

Чтобы более детально понять, почему так происходит, придется поплясать немного вокруг стека. Однако многие из нас не танцоры и не эксперты по стеку. Что ж, в данном случае нужно всего лишь усвоить, что там, где в программах ожидается пользовательский ввод строк с использованием кавычек (%x, %d и/или %n), всегда возникают проблемы, которые на руку злоумышленникам. В качестве примера возьмём функцию snprintf() и рассмотрим ее поближе. Почему я взял именно эту функцию? Потому что она позволяет контролировать количество выводимых символов. Итак:

        // синтаксис функции snprintf 
snprintf(char *str, size_t size, constchar *format, …); 
// вот так её написал программист! 
// user_input интерпретируется как формат строки
snprintf(buffer, sizeof buffer, user_input); 

Здесь первый аргумент – строка, куда будут записаны результирующие данные. Следующий аргумент – размер буфера, то есть количество символов, которые будут записаны. Ожидается, что следующим аргументом будет строка формата, выглядящая как-нибудь так: ”%d %c”, и поясняющая, как должны быть напечатаны символы. И далее следует произвольное количество аргументов, которые должны быть записаны в выводимую строку. Программист забыл указать формат строки, но программа будет работать, как ни в чём не бывало. Посмотрим какой неприятный эффект проявляется при этом.

main()
{
  char user_input[100];
  char buffer[100];
  int x;
  …
  /* в user_input какие-то введённые данные */
  …
  snprintf(buffer, sizeof buffer, user_input);
}

Читаем стек

Злоумышленик может записать данные в переменную user_input и прочитать данные из стека, например, записав в переменную строку вида ”%x %x %x”. Когда в программе дойдёт очередь до вызова функции snprintf(), эта строка будет передана в функцию, интерпретирующую user_input как строку формата. А строка формата говорит следующее: напечатай три шестнадцатеричных числа в переменную buffer. Функция snprintf() «достанет» три значения из стека и загрузит их в переменную user_input. Соответственно, если где-то далее в программе осуществляется печать строки на экране, злоумышленник может увидеть содержимое буфера. Т.е. это означает, что мы можем просматривать содержимое памяти (стека) посредством ввода строки. Чудесно!

Немного больше о printf()

Следует сделать несколько замечаний по поводу функции printf(). Если где-либо в строке формата встречается директива ”%n”, функция сохраняет число символов, которые будут напечатаны в результате вызова. Но вот вопрос: а куда? Где будет сохранена эта информация? Давайте посмотрим на рисунок.


Правильно! Это число будет записано в памяти по адресу, переданному в качестве аргумента функции. Как видно на рисунке, в случае строки “Hello world!” это будет число 12. Т.е. мы просто передали функции в качестве аргумента адрес переменной x, затем функция напечатала строку и потом записала в переменную x число 12 – длину строки. Что же это нам даёт? Это означает, что мы можем писать в память, используя функцию printf(). Кто бы мог такое предположить?!

Попробуем, как же это будет примерно выглядеть в реальности. Предположим, злоумышленник в программе хочет прочитать/модифицировать значения переменных в памяти по адресу, например 0xbffffac0. По этому адресу может находиться достаточно важная информация: userID, trial period и т.д. Достаточно записать в буфер user_input следующую строку: \xc0\xfa\xff\xbf\%d%n, чтобы модифицировать данные в памяти по указанному адресу.

Смотрим стек

Но вернёмся к нашему исходному коду и очень подробно рассмотрим, что же будет происходить в стеке.


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


На следующем рисунке изображён вызов функции snprintf(). Аргументы функции в стеке размещены в обратном порядке согласно конвенции о вызовах. Теперь посмотрим, что будет, если мы запишем в user_input строку, указанную выше.


Функция snprintf() сначала просматривает свои аргументы, чтобы найти строку формата. Но поскольку программист забыл указать строку формата, функция snprintf() думает, что user_input – это и есть строка формата. После того, как функция snprintf() нашла строку формата, она начинает сканировать её и находит нашу «странную» строку \xc0\xfa\xff\xbf, в которой говорится, что надо записать 4 ASCII-символа в buffer. Функция snprintf() интерпретирует символ (\) как escape-код. (x) означает, что число шестнадцатеричное, c0 – это эквивалент одного из ASCII-символов, которые будут записаны в buffer. Итак, мы имеем 4 группы по 2 шестнадцатеричных знака, что представляет собой 4 ASCII-символа. Далее, функция snprintf() находит в нашей строке %d, что говорит о том, что надо печатать десятичное число. Но какое?! Это число будет взято из стека. Пусть это будет число длиной в один знак, например, 8. Т.е. в buffer у нас уже должно быть 5 символов: 4 ASCII символа + целое число 8. На следующем рисунке наша строка записана в user_input.

        // вот так раскроется функция
snprintf(buffer, sizeof buffer, “\xc0\xfa\xff\xbf%d%n”);


Теперь шестнадцатеричные числа записаны в buffer из user_input, как и ожидалось, то есть теперь у нас в buffer содержится c0faffbf.


Поскольку в нашей строке есть %d, то в результирующую строку также будет записано значение переменной x.

Ну а теперь немного магии! Следующая директива %n в user_input сообщает функции, что нужно загрузить число печатаемых символов – 5. Но в какую память?! Куда?! Логичный ответ: в переменную-аргумент, переданную функции snprintf(). Но вот загвоздка, а в какой аргумент? У нас нет дальше таких аргументов. Мы ничего не передавали больше. Но как было сказано выше, функция snprintf() решит, что это следующий адрес в стеке. А там у нас уже записано значение переменной buffer, в которой содержится наши шестнадцатеричные символы. Итак, функция snprintf() запишет число 5 в память по адресу 0xbffffac0. Ура! Получилось! С помощью строки ввода мы изменили содержимое памяти!

Так куда же теперь у нас указывает buffer? На память «Данные для изменения»!


Мы вывели 5 ASCII символов. Заметьте, что значение x всегда интерпретируется как один символ.


Итак, по сути, мы записали число 5 куда-то в произвольную память. А как быть, если надо записать большее число по определённому адресу? Выше описанная техника позволяет сделать и это. Фактически, эта техника позволяет записать любое (почти) значение в любое место памяти. Для этого можно использовать следующую директиву: ”%.[number]d” в переменной, которая будет использоваться в качестве строки формата. Функция snprintf() будет интерпретировать [number] как число записанных символов. Например, в случае строки ”c0faffbf%.255d”, функция snprintf() будет думать, что выведено 255 символов + наш адрес, т.е. всего 259 символов. Таким образом, мы можем загрузить число 259 в память по почти любому адресу! Заметьте, что адрес в памяти не может быть равен 0x00, т.к. printf()-подобные функции прекращают обработку строки, встречая null-символ (0x00).

Неплохой подарок хакеру всего лишь в одной функции printf()! Итак, подобная техника позволяет злоумышленнику:

Подобная техника была проверена на популярной программе WinRAR, а также на одной из моделей телефона Nokia – практически его «убивает» “%n”.

Защита

Собственно, защита от этого довольно проста: программист везде и всюду обязан явно указывать строку формата.

Пример

Приведу один из примеров атаки rpc.statd с использованием строки формата. Так получилось еще с середины 2000 года, что rpc.statd был просто окружён проблемами: rpc.statd обычно запускается с привилегиями root, что приводило передачу данных из сети пользовательской нагрузки в syslog без использования форматной строки.

Вот файл протокола:

Aug XX 17:13:08 victim rpc.statd[410]: SM_MON request for hostname containing ‘/’: ^D^D^E^E^F^F^G^G08049f10
Bffff754 000028f8 4d5f4d53 72204e4f 65757165 66207473 6820726f 6e74736f 20656d61 746e6f63 696e6961 2720676e 203a272f
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bffff7
0400000000000000000000000000000000000bfff70500000000000000000000000000000000000000000000000000000000000
<90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90>
<90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90><90>
K^<89>v
<83><8D>/bin
/sh –c echo 9704 stream tcp
Nowait root /bin/sh sh –I >> /etc/inetd.conf; killall –HUP inetd

Здесь злоумышленник с помощью строки формата перенаправил выполнение программы. Также видим инструкции NOP (салазки), запуск шелла, и строку переконфигурирования inetd.

Защита: если вы не используете NFS, удаляйте rpc.statd.

Послесловие

Теперь давайте вернёмся к началу статьи, и посмотрим, что же там у нас происходит с запуском SORT.EXE под Windows?

Давайте попробуем расширить строку и сделать следующее (а во время того, как Вы вводите эту длинную команду, задумайтесь об аргументе ”%n”):

C:\> SORT.EXE “%x%x%x%x%x%x%x%x%x”

Эта команда выводит:

7c812f39007825782578257825782578257825782578256df8cНе удается найти указанный файл.

А теперь еще раз, просто подумайте, что будет, если передать в качестве аргумента ”n”? Сколько символов будет напечатано? И куда будет записано количество выводимых символов? Подсказка: думайте о стеке! Ладно, хватит мучиться! Вперёд, вводим команду: C:\> SORT.EXE “%n”

Источники

  1. Один из поздних удачных документов от Tim Newsham http://muse.linuxmafia.org/lost+found/format-string-attacks.pdf
  2. Учебник из курса SANS Institute Security 504.3 (Hacker Techniques, Exploits & Incident Handling, Part 2). Это классный учебник, всем советую, но, к сожалению, в свободной продаже или в электронном виде его не существует.


Эта статья опубликована в журнале RSDN Magazine #2-2008. Информацию о журнале можно найти здесь
    Сообщений 2    Оценка 131 [+0/-1]         Оценить