Недостатки форматной строки

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

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

Немного о вводе

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

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

char user[13];
char pass[13];
gets(&user[0]);
gets(&pass[0]).

Фрагмент кода кажется безопасным и правильным, но на самом деле его выполнение может привести к критическим последствиям: как минимум, отказу в обслуживании, как максимум, захвату контроля на ПК, который использует уязвимое приложение (если оно сетевое, и вышеприведенный фрагмент кода есть не что иное, как просьба авторизоваться). Все дело тут в том, что функция Gets никак не проверяет количество символов, введенных пользователем, для переменных выделяется по 13 байт, а если пользователь вводит больше символов, чем запланировано, то они выходят за границы буфера и затирают находящиеся за ним значения (другие переменные, иногда даже участки кода, если user и pass объявлены локально, то они хранятся в стеке, а как следствие, можно затереть и адрес возврата из функции, что позволяет выполнить атаку Срыв стека). Ошибка тут вовсе не программиста, а непосредственно разработчиков функции (не сделали проверки).

Вот аналог вышеприведенного кода на Delphi (Pascal) (рис. 1)

var
user:string[13];
pass:string[13];
begin
readln(user);
readln(pass);
end.

Этот код полностью безопасен в отличие от своего собрата на языке С++, функция, которая читает введенные пользователем данные (readln), получает в регистре ecx значение размера буфера, затем сохраняет его и сравнивает с количеством введенных пользователем символов, и берет только первые символы, пока не кончится буфер. Чтобы защититься от данной «проблемы», достаточно использовать безопасный аналог функции gets, а именно fgets.
char user[13];
char pass[13];
fgets(&user[0],13,stdin);
fgets(&pass[0],13,stdin).

Читаем стек

Теперь перейдем непосредственно к форматной строке:

#include "stdafx.h"
int main()
{
char str[17];
fgets(&str[0],17,stdin);

printf(&str[0]);
}

С первого взгляда, все в порядке, но тут кроется одна большая неприятность: если запустить проект и ввести в поле ввода %x %x %x, то в консоль выведется вовсе не это, а:

241fe4 12f7bc 7ffdd000

(У вас результат может отличаться и даже должен, это как повезет).

Обратим внимание на текст, который мы вводим, вообще-то он эквивалентен записи:

printf(“%x %x %x”).

Откомпилируйте проект и снова увидите нечто подобное первому результату, получается, что мы как бы «обманываем программу» (не находите, очень похоже на sql-инъекцию).

%x — говорит о том, что нужно достать значение из стека и записать в буфер, а из буфера вывести на экран (ну а вообще-то переводит переменную типа int в строку).

Нормальный расклад таков:

printf(“%x %x %x”,a,b,c),

где а,b,c – некоторые переменные, тогда на экран будут выведены значения этих переменных (рис. 2).

Состояние стека при нормальном раскладе:

0012FF18 0042001C ASCII “%x %x %x” Спецификаторы
0012FF1C 00000111 Переменная а
0012FF20 00000222 Переменная а
0012FF24 00000333 Переменная с

Погрузившись в отладчике в printf, мы понимаем, что произошло, когда мы не передали никаких аргументов, но передали три спецификатора. Это значит, ей нужно распечатать три аргумента, она «не знает», что аргументов ей не дали и поэтому достает их с того места, где, по ее мнению, они должны быть (рис. 3).

Состояние стека, когда аргументы не были переданы:

0012АА18 00401044 RETURN TO 3.00401044 from 3.printf Адрес возврата из printf
0012FF1C 0012FF6C ASCII “%x %x %x” Спецификаторы
0012FF20 7C910208 ntdll.7C910208 Случайные данные
0012FF24 FFFFFFFF Случайные данные
0012FF28 7FFDD000 Случайные данные

На месте Случайных данных должны быть переменные, но мы их не передали, следовательно, там «валяется» разная информация: значения других переменных, адреса возврата и т.д. (рис. 5)

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

Читаем код программы

Помимо %x, у printf существует еще огромная куча спецификаторов, один из них %s. Спецификатор %s указывает на строку, то есть в переменной хранится адрес на те данные, которые нужно вывести, запустите предыдущий пример и введите %s. Посмотрим, что произошло (рис. 4).

Мы считали данные по адресу 7C910208, потому что функция «подумала» - это аргумент. А теперь задумаемся: что, если бы мы могли заменить этот аргумент на свой собственный, например, на адрес точки входа в программу? Мы получили бы код программы до первого попавшегося ноля, так как ноль завершает строку, но это не всегда можно, как повезет.

Перезапись ячейки памяти

Спецификатор %n для этого как раз и предназначен, запускайте и вводите 2222%%x%x%x%x%n.

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

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

printf(“%x ”,a)

на Pascal выглядит так:

write(inttostr(a).

Можно понять, что тут используется функция inttost(), и, по-моему, такое решение гораздо удачнее.

Как же защититься? Можно, например, фильтровать вводимые пользователем данные, как это делается в случае с sql-инъекцией, но это было бы полным идиотизмом, лучше
printf(str)
заменить на
printf(”%s”,str).

И все будет ОК...

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

Kerny SASecurity gr. http://sa-sec.org


Компьютерная газета. Статья была опубликована в номере 18 за 2009 год в рубрике программирование

©1997-2022 Компьютерная газета