Информационная безопасность

       

Неизвестная уязвимость функции printf



Статья была опубликована в журнале

"Машинная программа выполняет то, что вы ей приказали делать, а не то, что бы вы хотели, чтобы она делала".

Третий закон Грида

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

У разработчиков существует шутливое высказывание: "Программ без ошибок не бывает. Бывает – плохо искали". Пример программы, приведенной ниже, позволяет убедиться, насколько эта шутка порой близка к истине. На первый (и даже на второй!) взгляд здесь нет ни одной ошибки, способной привести к несанкционированному вторжению в систему.

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

#include <string.h>

void main() { FILE *psw; char buff[32]; char user[16]; char pass[16]; char _pass[16];

printf("printf bug demo\n"); if (!(psw=fopen("buff.psw","r"))) return; fgets(&_pass[0],8,psw);

printf("Login:");fgets(&user[0],12,stdin); printf("Passw:");fgets(&pass[0],12,stdin);

if (strcmp(&pass[0],&_pass[0])) sprintf(&buff[0],"Invalid password: %s",&pass[0]); else sprintf(&buff[0],"Password ok\n");

printf(&buff[0]);

}

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


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

Один из недостатков языка Си заключается в отсутствии штатных механизмов подсчета количества аргументов, переданных функции. Поэтому функциям с переменным числом аргументов приходится самостоятельно определять сколько параметров находится в их распоряжении. Для решения этой задачи функция printf использует специальную управляющую строку, которая состоит из служебных комбинаций символов – спецификаторов. Спецификаторы описывают тип и количество аргументов. Каждому из спецификаторов должна соответствовать "своя" переменная, но что произойдет, если такое равновесие нарушится?.

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

Но если спецификаторов окажется больше, чем переданных переменных, то при попытке извлечь из стека очередной аргумент произойдет обращение к "чужим" данным, находящимся в этой области стека!

Такую ситуацию позволяет продемонстрировать следующий пример: “main(){int a=0xa;int b=0xb;printf("%x %x\n",a);}”, в котором присутствует один "беспарный" спецификатор “%x”. Поскольку содержимое стека на момент вызова функции “printf” зависит от используемого компилятора, то поведение данного кода неопределенно. Например, результат работы программы, полученной с помощью Microsoft Visual C++ 6.0, выглядит так: “a b”

Функция вывела два числа, несмотря на то, что ей передавали всего одну переменную “a”. Каким же образом она сумела получить содержимое переменной “b”? Ответить на этот вопрос поможет дизассемблирование машинного кода программы, в результате которого удается установить содержимое стека на момент вызова функции printf (сам дизассемблерный листинг в журнальном варианте статьи опущен, полный текст содержится в книге "Техника сетевых атак", которую планируется выпустить в скором будущем):



off aXX ('%x %x') (строка спецификаторов) var_4 ('a') (аргумент функции printf)

var_8 ('b') (локальная переменная) var_4 ('a') (локальная переменная)

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

Для поддержки функций с переменным количеством аргументов в языке Си был принят обратный порядок заталкивания параметров в стек, т.е. самый левый аргумент заносится в последнюю очередь и оказывается на верхушке стека. Было бы замечательно, если бы компилятор напоследок передавал бы функции число используемых аргументов или, по крайней мере, сообщал бы их суммарный размер (тем более, что технически в этом нет ничего затруднительного). Но, увы! Разработчики языка не реализовали такой механизм, и отсюда следует неутешительное заключение о принципиальной невозможности защиты содержимого стека материнской функции. Дочерняя функция может беспрепятственно обращаться к любой ячейке стека – от верхушки до самого низа, читая как "свои", так и "чужие" данные.

При вызове “printf("%x %x\n",a)“ функция извлекает из стека на одно слово больше, чем было ей передано, и в результате происходит вторжение в область памяти, занятой локальными переменными материнской функции. Переменная “b” принимается за аргумент функции и выводится на экран. (В зависимости от используемого компилятора в заданном месте стека может оказаться все что угодно, например переменная “a”, сохраненные значения регистров общего назначения, "черная дыра" – область памяти, отведенная для выравнивания данных и т.д.).

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


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

В тех случаях, когда функция printf используется для вывода единственной символьной строки, строку спецификаторов обычно опускают, т.е. вместо “printf (“%s”, &buff[0])” пишут “printf(&buff[0])”. На первый взгляд обе формы записи равносильны, но это не так! Самый левый аргумент всегда проверяется функцией printf на наличие спецификаторов, даже если он передан функции в единственном числе. Поэтому использовать его для вывода строки можно в том и только том случае, когда она гарантированно не содержит никаких "внеплановых" спецификаторов, в противном случае, работа приложения окажется нестабильной. Особенно опасно полагаться на отсутствие спецификаторов в данных, введенных пользователем, и недопустимо передавать их функции printf в первом слева аргументе.

Возможные последствия такого подхода позволяет продемонстрировать программа, приведенная в начале статьи: если злоумышленник введет вместо пароля один или несколько спецификаторов, на экране появится содержимое локальных переменных, в том числе и буфера, хранящего эталонный пароль. Компилятор Microsoft Visual C++ 6.0 располагает этот буфер на вершине стека и просмотреть его можно следующим образом (предполагается, что файл “buff.psw” содержит строку “K98PN*”):

printf bug demo Login:kpnc Passw:%x %x %x Invalid password: 5038394b a2a4e 2f4968

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



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


Такое поведение объясняется тем, что, встретив спецификатор “%s”, функция printf ожидает увидеть указатель на строку, но не саму строку. В результате происходит обращение по адресу 0x5038384B (“K98PN” в символьном представлении), который находится вне пределов досягаемости программы, что и вызывает исключение.

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

#include <stdio.h>

#include <string.h>

#include <malloc.h>

void main() { FILE *f; char *pass; char *_pass; pass= (char *)malloc(100); _pass=(char *)malloc(100); if (!(f=fopen("buff.psw","r"))) return; fgets(_pass,100,f); _pass[strlen(_pass)-1]=0; printf("Passw:");fgets(pass,100,stdin); pass[strlen(pass)-1]=0; // Код, проверяющий истинность пароля, введенного // пользователем, для упрощения понимания, опущен printf(pass); }

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

В приведенном примере указатель _pass оказался расположен на верхушке стека, поэтому использование спецификатора “%s” приводит к выводу на экран эталонного пароля, в чем позволяет убедиться следующий эксперимент:

Passw:%s K98PN*

Используя спецификатор “%s”, необходимо доподлинно знать, в каком именно месте стека находится искомый указатель. В противном случае произойдет обращение к незапланированной области памяти. Большинство локальных переменных, находящихся в стеке, содержат значения, не превышающие 0x40000, поэтому попытка использовать их в качестве указателя под операционной системой Microsoft Windows NT приведет к исключению.


Злоумышленник может использовать это обстоятельство для блокирования вычислительных систем или нарушения их нормальной работы.

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

Операционная система Microsoft Windows NT выделяет каждому процессу непрерывный регион адресного пространства, в котором уживаются код, данные и стек выполняющегося приложения. Поэтому теоретически возможно "дотянуться" до любой ячейки памяти и "подсмотреть" ее содержимое. Однако на практике осуществлению такой операции препятствует ограничение длины вводимой строки. Потребовалось бы ввести миллионы спецификаторов, прежде чем удалось бы достичь области памяти, занятой локальными переменными. Никакое приложение не допускает использование строк такой длины. Злоумышленник чаще всего ограничен не более сотней-другой символов, что позволяет просмотреть ему приблизительно четыреста-пятьсот байт содержимого стека. Попадание секретных данных в столь непротяженную область настолько маловероятно, что трудно найти хотя бы одно приложение, подверженное подобной атаке.

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



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

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

Описанную выше уязвимость можно было бы отнести к забавным курьезам, если бы она ограничивалась одной лишь функцией printf. Но функции с переменным количеством аргументом – не редкость в программистской практике, и многие из них используют ненадежные алгоритмы определения числа переданных параметров. Кроме того, существует множество "швейцарских" функций многоцелевого назначения. Например, функция “open” языка Perl в зависимости от символов, содержащихся в имени файла, может не только открывать файлы, но и запускать другие приложения, клонировать манипуляторы, выдавать содержимое директории и т.д.

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

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


Такая путаница объясняется тем, что термин "машинное слово" в одних случаях равен разрядности процессора, а в других приравнивается к двум байтам. Поэтому использование nargs порождает совершенно непереносимый код, работоспособность которого может быть нарушена даже изменением некоторых опций компилятора!

Некоторые разработчики предлагают собственный вариант реализации nargs, который сводится к следующему алгоритму: из стека извлекается адрес возврата из функции и, исходя из предположения, что он указывает на команду наподобие ADD SP, xx, производится попытка определить значение ‘xx’, равное количеству байт, помещенных в стек перед вызовом функции. Недостатки такого приема следующие: он не переносим на отличные от Intel 80x86 платформы; современные компиляторы ведут себя не так, как пять-десять лет назад, и генерируют чрезвычайно запутанный код, допускающий дисбаланс стека на некотором промежутке, отчего процедура анализа количества переданных функции аргументов по сложности приближается к самому компилятору.

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

Существует и другой вариант – вручную подсчитывать число аргументов и через специальный параметр передавать их функции. Но это слишком утомительно да и ненадежно.

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

1 Здесь и далее первый аргумент функции printf называется "строкой спецификаторов", а все последующие "переменными"   

2 Ну чем не трюк для соревнований в "магическом программировании"?   

3 Базовый адрес загрузки большинства приложений равен 0x400000   

4 Спецификатор "%f" "съедает" восемь байт, но сам занимает два байта, таким образом, в ста байтах вводимой строки можно расположить не более пятидесяти спецификаторов "%f", которые выведут четыреста байт.   

5 Тем самым он откроет доступ к коду и данным 32-разрядных приложений, исполняющихся под управлением операционной системы Windows NT, поскольку большинство из них расположено в памяти по адресу выше 0x00401000   


Содержание раздела