c-tricks-16h
сишные трюки\\ (16h выпуск)
крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email
сегодня у нас несколько необычный выпуск. своеобразный юбилей. если перевести номер в шестнадцатеричную систему (забыв о том, что он _уже_ записан в ней), мы получим число 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 рабочий пример с неявной инициализацией локальных переменных