buf.C.printf

спецификаторы под арестом\\ или дерни printf за хвост

крис касперски ака мыщъх

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

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

Язык Си выгодно отличается от Паскаля поддержкой спецификаторов, представляющих собой мощный инструмент форматного вывода/вывода. Настолько мощный, что фактически образующий язык внутри языка. Идея была позаимствована из Фортрана, создатели которого учли главный недостаток его предшественника – Алгола. Языка, сосредоточившегося на алгоритмизации (отсюда и название) и пренебрежительно относящегося к вводу/выводу, считая его побочным продуктом основной деятельности. Наивные! Генерация отчетов всегда доставала программистов, отнимания уйму времени и сил, оставаясь наиболее нудной и рутиной частью программы. А давайте ее автоматизируем! – решили патриархи. Сказано – сделано. Так в языке Си появился полноценный интерпретатор форматных символов, сразу же завоевавший бешенную популярность. А вместе с ним появились и проблемы: небрежное обращение со спецификаторами породило новое тип ошибок переполнения, или даже поколение. Если это поколение, то оно будет третьим по счету. Первые два – последовательное и индексное переполнение уже были рассмотрены в предыдущей статье.

Ошибки форматного вывода достаточно малочисленны и встречаются главным образом в UNIX-приложениях, где традиции терминального режима все еще остаются сильны. По некоторым оценкам, в 2002 году было обнаружено порядка 100 уязвимых приложений, а в 2003 – свыше 150! Атаке подверглись сервера баз данных, вращающихся под Oracle и сервисы UNIX, такие, например, как syslog или ftp. Ни одной атаки на приложения Windows NT до сих пор не зафиксировано. Это не значит, что Windows NT лучше, просто графический интерфейс не располагает к интенсивному использованию форматного вывода, да и количество консольных утилит под NT очень невелико, тем не менее только глупец может считать, что он находится в безопасности. Не верите? Вот щас мы вам покажем!

Услугами интерпретатора форматного ввода/вывода пользуется множество функций, не только printf и не только в консольных программах. Графические приложения и серверное программное обеспечение, исполняющееся под Windows NT, активно использует функцию sprintf, выводящую отформатированную строку в оперативный буфер.

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

функцияназначение
fprintfASCIIформатированный вывод в файл
fwprintfUNICODE
fscanfASCIIформатированный ввод с потока
fwscanfUNICODE
printfASCIIформатированный вывод в stdout
wprintfUNICODE
scanfASCIIформатированный ввод с stdin
wscanfUNICODE
_snprintfASCIIформатированный вывод в буфер с ограничителем длины
_snwprintfUNICODE
sprintfASCIIформатированный вывод в буфер
swprintfUNICODE
sscanfASCIIформатированный ввод из буфера
swscanfUNICODE
vfprintfASCIIформатированный вывод в поток (stream)
vfwprintfUNICODE
vprintfASCIIформатированный вывод в stdout
vwprintfUNICODE
_vsnprintfASCIIформатированный вывод в буфер с ограничителем длины
_vsnwprintfUNICODE
vsprintfASCIIформатированный вывод в буфер
vswprintfUNICODE

Таблица 1 основные функции форматного вывода с кратким описанием

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

snprintf(syslog_str, sizeof(syslog_str),

«%s fingered (internal) from %s», username, ident_user);

- syslog(LOG_NOTICE, (char *) syslog_str); пользовательскийввод в форматном аргументе

+ syslog(LOG_NOTICE, «%s»,(char *) syslog_str); явнаяспецификация формантного аргумента

Листинг 1 патч cfingerd'а

Основных источников угрозы всего три. Это: а) навязывание уязвимой программе собственных спецификаторов; б) врожденный дисбаланс спецификаторов; и в) естественное переполнение буфера-приемника при отсутствии проверки на предельно допустимую длину строки.

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

Рассмотрим следующий пример, к которому мы не раз будет обращаться в дальнейшем. Как вы думаете, где здесь лыжи?

f()

{char buf_in[32], buf_out[32];

printf(«введиимя:»); gets(buf_in);

sprintf(buf_out, «hello, %s!\n», buf_in);

printf(buf_out);

}

Листинг 2 демонстрационный пример, подверженный ошибкам переполнения различных типов

Рисунок 1 состояние стека на момент вызова функции printf

реализация DoS

Для аварийного завершения программы достаточно вызвать нарушение доступа, обратившись к невыделенной, несуществующей или заблокированной ячейке памяти. Это легко. Встретив спецификатор «%s» интерпретатор форматного вывода извлекает из стека парный ему аргумент, трактуя его как указатель на строку. Если же тот отсутствует, интерпретатор хватает первый попавшийся указатель и начинает читать содержимое памяти по этому адресу до тех пор, пока не встретит нуль или не нарвется на запрещенную ячейку. Политика запретов варьируется от одной операционной системы к другой, в частности, при обращении по адресам: 00000000h – 0000FFFFh и 7FFF000h – FFFFFFFFhWindowsNT всегда возбуждает исключение. Остальные же адреса в зависимости от состояния кучи, стека и статической памяти могут быть как доступными, так и нет.

Откомпилируем пример, приведенный в листинге 2, и запустим его на выполнение. Вместо своего имени введем строку «%s» (естественно, без кавычек). Программа ответит:

введиимя:%s

hello, hello, %s!\n!«

Листинг 3 реакция программы на навязанный ей спецификатор %s

Чтобы понять, что такое «hello, %s!» и откуда оно здесь взялось, необходимо проанализировать состояние стека на момент вызова printf(buf_out), в чем нам поможет отладчик, например, тот, что интегрирован в MicrosoftVisualStudio (см. рис. 1)

Первым идет двойное слово 0012FF5Ch (на микропроцессорах архитектуры Intel младший байт располагается по меньшему адресу, т. е. все числа записываются в памяти задом наперед). Это указатель, соответствующий аргументу функции printf, которому в свою очередь соответствует буфер buf_out, содержащий непарный спецификатор »%s«, заставляющий функцию printf извлекать следующее двойное слово из стека, представляющее собой обыкновенный мусор, оставленный предыдущей функцией. По воле обстоятельств он (мусор и указатель в одном лице) указывает на тот же самый buf_out и потому нарушения доступа не происходит, зато слово «hello» выводится дважды.

Будем рыть дальше, снимая со стека следующую последовательность адресов: 00408000h (указатель на строку «hello, %s!\n»), 0012FF3Ch (указатель на buf_out), 0012FF3Ch (снова он), 0040800Ch (указатель на строку «введи имя:»), 73257325h (содержимое буфера buf_in, трактуемое как указатель, между прочим указывающий на невыделенную ячейку памяти).

Таким образом, первые пять спецификаторов »%s« проходят сквозь интерпретатор форматного вывода вполне безболезненно, а вот шестой посылает его в «космос». Процессор выбрасывает исключение и выполнение программы аварийно прекращается (см. рис. 2). Разумеется, спецификаторов не обязательно должно быть ровно шесть – до остальных все равно не дойдет управление. Обратите внимание, что Windows NT приводит именно тот адрес, который мы и планировали.

Рисунок 2 реакция программы на 6 спецификаторов %s

реализация peek

Для просмотра содержимого памяти уязвимой программы можно воспользоваться спецификаторами »%X«, »%d« и »%c«. Спецификаторы »%X« и »%d« извлекают парное им двойное слово из стека и выводят его в шестнадцатеричном или десятичном виде соответственно. Спецификатор »%c« извлекает парное двойное слово из стека, преобразует его к однобайтовому типу char и выводит в символьном виде, отсекая три старших байта. Таким образом, наиболее значимым из всех является спецификаторы »%X« и »%c«.

Каждый спецификатор »%X« отображает всего лишь одно двойное слово, лежащее в непосредственной близости от вершины стека (точное расположение зависит от прототипа вызываемой функции). Соответственно, N спецификаторов отображают 4*N байт, а максимальная глубина просмотра равна – 2*C, где C – предельно допустимый размер пользовательского ввода в байтах. Увы! Читать всю память уязвимого приложения нам никто не даст, отдавая на растерзание лишь крошечный кусочек, в котором, если повезет, могут встретиться секретные данные (например, пароли) или указатели на них. Впрочем, узнать текущее положение указателя тоже неплохо. Но обо всем по порядку.

Запустим нашу демонстрационную программу и введем спецификатор »%X«. Она ответит:

введи имя:%X

hello, 12FF5C!

Листинг 4 реакция программы на спецификатор %X

Почему 12FF5C? Откуда оно взялось? Обращаясь к дампу памяти (см. рис.1), мы видим, что это – двойное слово, следующее за аргументом buf_out и представляющее собой результат жизнедеятельности предыдущей функции или попросту говоря мусор. Ну и какая нам радость от этого знания? Буфер содержит наш собственный ввод, в котором заведомо нет ничего интересного. Но это лишь часть айсберга. Как уже говорилось в статье, посвященной переполняющимся буферам, для передачи управления на shell-код необходимо знать его абсолютный адрес, который в большинстве случаев неизвестен и спецификатор »%X« как раз и выводит его на экран!

Теперь введем несколько спецификаторов »%X«, для удобства разделив их пробелами, хотя последнее и не обязательно. Программа ответит:

введи имя:%X%X%X%X%X%X%X

hello, 12FF5C 408000 12FF3C 12FF3C 40800C 25205825 58252058!

Листинг 5 просмотр дампа памяти с помощью спецификаторов

Обратите внимание на два последних двойных слова, для наглядности выделенных в тексте жирным шрифтом. Да это же… содержимое буфера пользовательского ввода! (ACSII-строка »%X « в шестнадцатеричном представлении выглядит как «25 58 20»).

Идея – сформировать указатель на интересующую нас ячейку памяти, положить его в буфер, а затем натравить на него спецификатор »%s«, читающий память вплоть до встречи с нулевым байтом или запрещенной ячейкой. Нулевой байт – не помеха, достаточно сформировать новый указатель, расположенный за его хвостом. Запрещенные ячейки намного коварнее – всякая попытка доступа к ним вызывает аварийное завершение программы, и до тех пор, пока администратор не поднимет упавший сервер, атакующему придется сидеть, пить пиво, смотреть анимэ, материться и скучать, а после перезапуска расположение уязвимых буфером данных оказаться совсем иным, что обесценит все ранее полученные результаты. Конечно, волков бояться – в лес не ходить, но и соваться в воду не зная броду тоже не стоит. В общем, со спецификатором »%s« следует быть предельно осторожным, а то недолго и DoS схлопотать.

Допустим, мы хотим прочитать содержимое памяти по адресу 77F86669h (по ней можно определить версию операционной системы, т. к. у всех она разная). Расположение буфера пользовательского ввода нам уже известно – актуальные данные начинаются с шестого двойного слова (см. листинг 3). Остается подготовить боевую начинку. Вводим целевой адрес, записывая его в обратном порядке и набирая непечатные символы с помощью <ALT> и цифровой клавиатуры, добавляем к ним шесть спецификаторов »%X«, »%d« или »%c« (поскольку, содержимое этих ячеек нас никак не волнует, подойдут любые), добавляем опознавательный знак, например, звездочку или двоеточие, за которым будет идти спецификатор вывода строки »%s« и скармливаем полученный результат программе (опознавательный знак необходим для того, чтобы быстро определить – где кончается мусор, а где начинается актуальные данные):

введи имя:if<ALT-248>w%с%с%с%с%с:%s

hello, if°w \ «♀:ЛF¶╟@►♥!

Листинг 6 просмотр дампа памяти по вручную сформированному указателю

Если перевести «ЛF¶╟@►♥» в шестнадцатеричную форму, получится 8B 46 B3 40 3E B3 00. Откуда взялся нуль? Так ведь это ASIIZ-строка, и нуль (по-английски Zero) служит ее завершителем. Если бы его здесь не оказалось, спецификатор »%s« вывел бы на экран намного больше информации.

Фактически мы реализовали аналог БЕЙСИК функции peek, судьбоносность которой уже обсуждалась в предыдущей статье, однако, не спешите открывать пиво на радостях. Данная реализация peek'а очень ограничена в своих возможностях. Указатель, сформированный в начале буфера, не может содержать в себе символа нуля и потому первые 17 Мбайт адресного пространства недоступны для просмотра. Указатель, сформированный в конце буфера, может указывать практически на любой адрес, поскольку старший байт адреса удачно совпадает с символом завершающего нуля, однако, чтобы дотянуться до такого указателя, потребуется пересечь весь буфер целиком, а это не всегда возможно.

Дизассемблер утверждает, что по адресу 004053B4h в нашей демонстрационной программе расположен копирайт фирмы Microsoft (см. листинг 7). Давайте выведем его на экран! Как мы помним, начало буфера соответствует шестому спецификатору. Каждый спецификатор занимает два байта и снимает со стека четыре. Еще два байта уходят на спецификатор »%s«, выводящий строку. Так сколько всего надо передать спецификаторов программе? Составляем простенькое линейное уравнение и сходу решаем его, получая в ответе двенадцать. Одиннадцать из них – выгребают со стека все лишнее, а двенадцатый выводит содержимое расположенного за ним указателя.

.rdata:004053B4 aMicrosoftVisua db 'Microsoft Visual C++ Runtime Library',0

Листинг 7 дизассемблерный фрагмент нашей тестовой программы

Указатель формируется тривиально: открываем ASCII-таблицу символов (как вариант – запускаем HIEW) и переводим 4053B4h в символьное представление. Получается: »@S┤«. Выворачиваем его наизнанку и вводим в программу, при необходимости используя цифровую клавиатуру и клавишу ALT.

введи имя:%c%c%c%c%c%c%c%c%c%c%c%s<Alt-180>S@

hello, \ «♀%%Microsoft Visual C++ Runtime Library┤S@!

Листинг 8 формирование указателя в конце буфера и вывод его на экран

Мы сделали это! У нас получилось! Действуя и дальше таким Макаром, мы сможем просмотреть практически всю доступную память программы! Кстати говоря, Unicode-функции, работающие с широкими (wide) символами, используют для завершения строки двойной символ нуля и к одиночным нулям относятся довольно лояльно.

реализация poke

Спецификатор »%n« записывает в парный ему указатель количество выведенных на данный момент байт, тем самым позволяя нам модифицировать содержимое указателей по своему усмотрению. Обратите внимание: модифицируется не сам указатель, а то, на что он указывает! (Естественно, модифицируемая ячейка должна принадлежать странице с атрибутом PAGE_READWRITE, в противном случае процесс сгенерирует исключение).

Перед демонстрацией нам необходимо найти в стековом хламе подходящий указатель, предварительно прочитав его содержимое строкой типа »%X %X %X…« (см. листинг. 5). Допустим, мы выбрали 12FF3Ch, указывающий на буфер пользовательского ввода buf_in, для достижения которого необходимо снять со стека два двойных слова – этим займутся спецификаторы »%c%c«.

Теперь определимся с числом, которое мы хотим записать. Записывать можно только маленькие числа, на большие просто не хватит размера буфера! Для определенности сойдемся на числе 0Fh (это нечетное число, четные приносят несчастье). Считаем: два символа выводят спецификаторы, снимающие лишние двойные слова с верхушки стека, семь приходится на строку «hello, » (да! да! она тоже в доле!), тогда у нас остается: 0Fh – 02h – 07h == 06h. Шесть символов, которые мы должны ввести самостоятельно. Они могут быть любыми, например, «qwerty» или что-то в этом роде. Остается добавить спецификатор »%n« и сформированную строку можно передать программе:

введи имя:qwerty%c%c%n

hello, qwerty\ !

Листинг 9 перезапись ячейки спецификатором %n

Поскольку модификация буфера осуществляется после его вывода на экран, доказательства перезаписи памяти приходится добывать в отладчике. Загрузив подопытную программу в MicrosoftVisualStudio (или любой другой отладчик по вашему вкусу), установите точку останова по адресу 401000 (адрес функции main) или подогнав к ней курсор (Ctrl+G, Address, «401000», <Enter>) нажмите Ctrl+F10 для пропуска инструкций стартового кода, совершенно не интересующего нас в настоящий момент.

Пошагово трассируя программу по F10 (Step Over – трассировка без захода внутрь функций), введите заданную строку, когда вас об этом попросят (экран консоли начнет призывно мигать) и продолжайте трассировку вплоть до достижения строки 0040103Сh, вызывающей функцию printf. Теперь перейдите в окно дампа памяти и введите в адресной строке «ESP», сообщая отладчику, что нам угодно просмотреть содержимое стека, а затем вернитесь к дизассемблерному коду и нажиме F10 еще раз.

Содержимое буфера пользовательского ввода немедленно изменится, подсвечивая ядовито-красным цветом число «0F 00 00 00», записанное в его начале. Перезапись выбранной ячейки памяти успешно состоялась!

Рисунок 3 демонстрация перезаписи ячейки памяти

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

Нижняя граница определяется количеством уже выведенных символов (в данном случае длинны строки «hello, »), верхняя же формально неограниченна – достаточно лишь подобрать пару указателей на строки подходящей длины и натравить на них спецификаторы »%s«, однако, никакой гарантии того, что они там будут у нас нет и осуществить захват управления удаленной машиной с помощью форматированного вывода практически нереально. А вот DoS можно устроить хороший. Строка вида »%n%n%n%n%n…« роняет систему покруче чем »%s%s%s%s%…«!

дисбаланс спецификаторов

Каждому спецификатору должен соответствовать парный аргумент. Но «должен» еще не обозначает «обязан». Ведь спецификаторы и аргументы программисту приходится набивать вручную и ему ничего не стоит ошибиться! Транслятор откомпилирует такую программу вполне нормально, возможно, негромко выругавшись при этом и выдав на экран предупреждающий warning (да только кто те warning'и читает…). Но что произойдет потом?

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

Ошибки этого типа встречаются лишь в «студенческих» программах, а потому совершенно не актуальны. Короче говоря, их описание не стоит переведенной бумаги.

переполнение буфера-приемника

Функция sprintf относится к числу самых опасных и все руководства по безопасности в один голос твердят, что лучше пользоваться ее безопасным аналогом – snprintf. Почему? Природа форматированного вывода такова, что предельно достижимую длину результирующей строки очень трудно рассчитать заранее. Рассмотрим следующий код:

f()

{

char buf[???];

sprintf(buf,»имя:%s возраст:%02d вес:%03d рост:%03d\n«,

name, age, m, h);

}

Листинг 10 пример, демонстрирующий переполнение буфера-приемника

Как вы думаете, буфер каких размеров нам потребуется? Из неизвестных факторов здесь присутствуют: длина строки name и «длина» целочисленных переменных age, m, h, преобразуемых функцией sprintf в символьное представление. Кажется логичным, коль скоро мы отводим 2 столбца на возраст и по 3 на рост и вес, то за вычетом имени и длины форматной строки нам потребуется всего 8 байт. Правильно? А вот и нет! Если строковое представление переменных не умещается в отведенных их позициях, оно автоматически расширяется, дабы избежать усечения результата. В действительности же, десятичное представление 32-разрядных переменных типа int, требует резервирования 11 байт памяти, в противном случае возникает угроза переполнения буфера.

Переполнения данного типа подчиняются общим правилам всех переполняющихся буфером и потому здесь не рассматриваются.

Ошибки обработки спецификаторов – частный случай более обшей проблемы интерполяции строк. Некоторые языки, например, Perl, позволяют не только форматировать вывод, но и внедрять переменные и даже функции (!) непосредственно в саму выводимую строку, что существенно упрощает и ускоряет программирование. К сожалению, хорошие идеи становится фундаментом воинствующего вандализма. Удобство не сочетается с безопасностью. Что удобно программировать – удобно и ломать, хотя обратное утверждение неверно.

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