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

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

#include 
void main()
{
	FILE *psw;
	char buff[32];
	char user[16];
	char pass[16];
	char _pass[16];
	printf(«printf bug demo
»);
	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
»);
	printf(&buff[0]); }

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

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

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

main()
{int a=0xa; int b=0xb;
printf(«%x %x
»,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 »,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
Рис. 1. «Переворот» двойного слова

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

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

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

#include 
#include 
#include 
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 (базовый адрес большинства приложений), поэтому попытка использовать их в качестве указателя в Windows NT приведет к исключению. Злоумышленник может использовать это обстоятельство для блокирования вычислительных систем или нарушения их нормальной работы.

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

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

Однако существует механизм, позволяющий прочитать содержимое практически любой ячейки памяти независимо от ее местоположения (разумеется, при условии, что приложение обладает правами на ее чтение). Если строка, введенная пользователем, помещается в стек (а именно так чаще всего и происходит), существует возможность «вручную» сформировать требуемый указатель и затем вывести строку, на которую он ссылается посредством спецификатора «%s». Поскольку некоторые символы невозможно ввести с клавиатуры, то на искомый указатель наложены некоторые ограничения. В частности, он не должен содержать ни одного нуля - стандартные библиотеки языка Си интерпретируют нуль как символ завершения строки. Но некоторые ухищрения позволяют обойти эти препятствия. Злоумышленник может косвенно воздействовать на содержимое стека различными способами. Использовать в качестве старшего байта указателя нуль, завершающий строку, открыв, тем самым, доступ к коду и данным 32-разрядных приложений, исполняющихся под управлением Windows NT (поскольку большинство из них расположено в памяти по адресу выше 0x00401000).

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

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

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

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

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

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

Об авторе

Крис Касперски — независимый автор. С ним можно связаться по адресу: kpnc@sendmail.ru.