С-шные трюки от мыщъх'а (выпуск 16h)

Автор: (c)Крис Касперски ака мыщъх

Сегодня у нас несколько необычный выпуск. Своеобразный юбилей. Если перевести номер в шестнадцатеричную систему (забыв о том, что он уже записан в ней - 16h), мы получим число 10h, в "круглости" которого сомневаться не приходится. Ошибка?! Конечно! Вот и поговорим об ошибках, которые только с виду ошибки, а на самом деле - интересные хакерские трюки, срывающие крышу даже опытным программистам! Короче, мы немного похулиганим... Не вздумайте показывать описанные трюки преподавателям или работодателям!!!

Трюк 1 - возврат указателей на локальные переменные

Рассмотрим следующий (см. листинг 1) исходный код, вполне типичный для начинающих, и попробуем ответить - что в нем неправильно?

char *foo(int a, int b)
{
        char buf[69];

        if (a - b) strcpy(buf, "nezumi"); else strcpy(buf, "souriz");
        return buf; // <<< трюк? или... ошибка? или все-таки трюк?!
}

main(int c, char **v)
{
        char *s, *p; if (c < 3) return 0;
        s = foo(atol(v[1]), atol(v[2]));

        if (strcmp(s, "souriz")) p = "japlish"; else p = "franglais";
        printf("%s - it's %s\n", s, p);

        // мы не освобождаем s, т.к. она указывает на локальную переменную
}

Листинг 1. Рабочий пример с возвратом указателя на локальную переменную.

Ага! Уже раздаются крики: возвращать указатели на локальные переменные (строка "return buf", выделенная полужирным шрифтом) ни в коем случае нельзя, поскольку они автоматически уничтожаются при выходе из функции. Это же в каждом букваре по Си написано! Ну, сколько можно говорить...

Хм, тогда кто рискнет объяснить - почему же несмотря ни на какие буквари, данный код стабильно работает, независимо от версии компилятора и совместим со всеми операционными системами из линейки NT, Linux, BSD?!

Фокус в том, что при завершении функции локальные переменные не уничтожаются, а освобождаются. Указатель стека опускается вниз и они оказываются в свободной зоне, которую может использовать кто угодно - например, обработчик аппаратного прерывания, однако NT, Linux и BSD сконструированы так, что на стек потока никто не покушается - только он сам. При возникновении прерывания регистры сохраняются на стеке ядра. Стек потока остается в неприкосновенности, а потому после завершения функции содержимое пользовательского стека не может быть "стихийно" разрушено (к тому же, каждый поток имеет свой стек и друг другу они не мешают). Исключение составляет 9x, "засоряющая" пользовательский стек без его ведома и согласия, что, кстати говоря, осложняет разработку некоторых видов exploit'ов.

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

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

Трюк 2 - выделение памяти из стека

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

Но есть еще и четвертый тип памяти, о котором умалчивают учебники. Это память, лежащая выше указателя стека. Почему бы ее не использовать для хранения динамических данных?! Естественно, со всеми предосторожностями, упомянутыми выше. А ниже приведен код функции, выделяющей заданное количество килобайт стековой памяти и возвращающей указатель на обозначенный блок памяти:

char *stack_alloc(int s_z)
{
        char buf[1024]; if (s_z) return stack_alloc(s_z - 1); return buf;
}

Листинг 2. Динамический стековый аллокатор (упрощенный "макетный" вариант).

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

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

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

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

Трюк 3 - неявная инициализация стековых переменных

А вот этот трюк можно использовать для запутывания кода, что полезно при создании защитных механизмов. Идея заключается в следующем: вызываем функции foo(), которая что-то записывает в свои собственные локальные переменные, а потом завершается. Указатель стека опускается, но содержимое самих переменных остается нетронутым. Если теперь запустить функцию bar(), то в ее локальных переменных (неинициализированных, конечно) окажутся значения, оставленные функцией foo().

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

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

int a, b, c, d;
#define S "nezumi has you!\n"

// функция foo() инициализирует переменную buf,
// а затем завершает свое выполнение

foo()
{
        char buf[0x60]; d = strlen(S);
        for (a = 0; a <= d; a++) { c = S[a]; buf[a] = c; }
        return buf[a];
}

// функция bar(), вызываемая следом за функцией foo(),
// объявляет переменную buf и выводит ее на экран,
// "подхватывая" содержимое, оставленное в стеке
// функцией foo(), создавая иллюзию того, что
// переменная buf не инициализирована

bar()
{
        char buf[0x60];
        printf(buf);
}

main()
{

        foo(); bar();
        printf("***\n");        // если убрать этот вызов, то оптимизатор
                                // может заменить call bar на jmp bar,
                                // что сдвинет стековый фрейм функции bar
}

Листинг 3. Рабочий пример с неявной инициализацией локальных переменных.