c-tricks-16h

сишные трюки\\ (16h выпуск)

крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email

сегодня у нас несколько необычный выпуск. своеобразный юбилей. если перевести номер в шестнадцатеричную систему (забыв о том, что он _уже_ записан в ней), мы получим число 10h, в «круглости» которого сомневаться не приходится. ошибка?! конечно! вот и поговорим об ошибках, которые только с виду ошибки, а на самом деле интересные хакерские трюки, срывающие крышу даже опытным программистам! короче, мы немного похулганим… не вздумайте показывать описанные трюки ни преподавателям, ни работодателям!!!

Рассмотрим следующий (см. листинг 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 рабочий пример с неявной инициализацией локальных переменных