Автор: (c)Крис Касперски ака мыщъх
Сегодня у нас несколько необычный выпуск. Своеобразный юбилей. Если перевести номер в шестнадцатеричную систему (забыв о том, что он уже записан в ней - 16h), мы получим число 10h, в "круглости" которого сомневаться не приходится. Ошибка?! Конечно! Вот и поговорим об ошибках, которые только с виду ошибки, а на самом деле - интересные хакерские трюки, срывающие крышу даже опытным программистам! Короче, мы немного похулиганим... Не вздумайте показывать описанные трюки преподавателям или работодателям!!!
Рассмотрим следующий (см. листинг 1) исходный код, вполне типичный для начинающих, и попробуем ответить - что в нем неправильно?
Листинг 1. Рабочий пример с возвратом указателя на локальную переменную.
Ага! Уже раздаются крики: возвращать указатели на локальные переменные (строка "return buf", выделенная полужирным шрифтом) ни в коем случае нельзя, поскольку они автоматически уничтожаются при выходе из функции. Это же в каждом букваре по Си написано! Ну, сколько можно говорить...
Хм, тогда кто рискнет объяснить - почему же несмотря ни на какие буквари, данный код стабильно работает, независимо от версии компилятора и совместим со всеми операционными системами из линейки NT, Linux, BSD?!
Фокус в том, что при завершении функции локальные переменные не уничтожаются, а освобождаются. Указатель стека опускается вниз и они оказываются в свободной зоне, которую может использовать кто угодно - например, обработчик аппаратного прерывания, однако NT, Linux и BSD сконструированы так, что на стек потока никто не покушается - только он сам. При возникновении прерывания регистры сохраняются на стеке ядра. Стек потока остается в неприкосновенности, а потому после завершения функции содержимое пользовательского стека не может быть "стихийно" разрушено (к тому же, каждый поток имеет свой стек и друг другу они не мешают). Исключение составляет 9x, "засоряющая" пользовательский стек без его ведома и согласия, что, кстати говоря, осложняет разработку некоторых видов exploit'ов.
Естественно, при вызове любой функции сохранность освобожденных переменных уже не гарантируется и тут все зависит от того, сколько стекового пространства "кушает" очередная вызываемая функция, причем некоторые функции могут вызываться неявно (мало ли, что захочется воткнуть в код компилятору!), к тому же стек активно используется для временного сохранения регистров, заталкиваемых туда компилятором. То есть, гарантий, что освобожденные переменные не будут уничтожены, у нас все-таки нет, однако если предпринять ряд предосторожностей, то риск не так уж и велик. Стек растет вверх, а локальные буфера - вниз. Выделяя локальный буфер с запасом хотя бы в пару килобайт, мы на 99% обезопасим себя от затирания актуальных данных.
Конечно, в "промышленном" коде подобные трюки недопустимы и нужно выделять память из кучи (благополучно забывая ее потом освободить), но... возврат указателей на локальные переменные во многих случаях происходит по ошибке и такие ошибки могут годами дремать в коде, неожиданно пробуждаясь при модификации программы или перекомпиляции другим компилятором или с новой версией такой-то библиотеки (скажем, одна из библиотечных функций увеличила свою потребность в стеке и стала затирать освобожденные переменные, приводя программу к краху, источник которого зачастую не так-то просто обнаружить).
Учебники по Си упоминают о трех основных типах памяти, доступных программисту: автоматическая стековая память, динамическая память (куча) и статическая память (секция данных). Автоматическая память хороша тем, что гарантированно освобождается компилятором по выходе из функции, исключая возможность утечек, однако стековый кадр формируется в момент вызова функции и потому размеры локальных буферов задаются на стадии компиляции, что не позволяет обрабатывать данные заранее неизвестного размера, к тому же мы не можем (легальным образом) возвращать указатели на автоматические переменные материнской функции. Куча снимает эти ограничения, но перекладывает заботы по освобождению памяти на плечи программиста и малейшая небрежность ведет к трудноуловимым утечкам. Статическая память наследует худшие черты кучи и стека - размеры буферов задаются на стадии компиляции и не могут быть увеличены во время исполнения программы.
Но есть еще и четвертый тип памяти, о котором умалчивают учебники. Это память, лежащая выше указателя стека. Почему бы ее не использовать для хранения динамических данных?! Естественно, со всеми предосторожностями, упомянутыми выше. А ниже приведен код функции, выделяющей заданное количество килобайт стековой памяти и возвращающей указатель на обозначенный блок памяти:
Листинг 2. Динамический стековый аллокатор (упрощенный "макетный" вариант).
Несколько замечаний по ходу. Во-первых, никакой это не аллокатор, поскольку реального выделения памяти не происходит и она остается свободной. Повторный вызов функции "выделит" новый блок поверх старого (естественно, при желании этот недочет легко обойти, передав функции базовый адрес, с которого начинается "выделение" очередного блока).
Во-вторых, размер выделенного блока всегда чуть больше требуемого, т.к. в стеке кроме буфера сохраняются регистры и адреса возврата, но это не есть проблема. Напротив, определенный запас по размеру снижает риск "стихийного" затирания данных.
В-третьих, оптимизирующие компиляторы наверняка избавятся и от хвостовой рекурсии и от реально неиспользуемого буфера buf, а потому данная функция никакой памяти выделять вообще не будет и вернет указатель, черт знает на что (точнее сказать невозможно, это уже от типа компилятора и ключей компиляции зависит!). Значит нужно переписать функцию так, чтобы компиляторы не смогли "развернуть" рекурсию и не трогали буфер buf (для этого достаточно "загрузить" его работой по хозяйству, имитируя бурную деятельность).
И последнее - не стоит принимать стековый аллокатор всерьез. Это шутка! Но иногда она оказывается очень полезной ("заложить" ее в "промышленном" коде перед увольнением с работы, чтобы кому-то потом сильно аукнулось - не предлагать).
А вот этот трюк можно использовать для запутывания кода, что полезно при создании защитных механизмов. Идея заключается в следующем: вызываем функции foo(), которая что-то записывает в свои собственные локальные переменные, а потом завершается. Указатель стека опускается, но содержимое самих переменных остается нетронутым. Если теперь запустить функцию bar(), то в ее локальных переменных (неинициализированных, конечно) окажутся значения, оставленные функцией foo().
В большинстве случаев это происходит по ошибке, но если немного подумать и все рассчитать - лучшего трюка для скрытой передачи данных, пожалуй, и не придумать. Основная сложность в том, что мы не можем управлять размещением переменных в стеке. Обычно компиляторы располагают их в порядке обращения к ним (не объявления!), при этом часть переменных попадает в регистры, а часть - нет. Другими словами - если у нас больше одной переменной - жди проблем, или же... закладывайся на особенности поведения конкретной версии компилятора с заданным набором ключей трансляции.
Приведенный ниже код достаточно надежен и дружит с оптимизаторами, правда для этого пришлось круто извратиться с глобальными переменными, расплачиваясь наглядностью кода, зато теперь можно быть на 99% уверенным, что компилятор не создаст никаких "служебных" локальных переменных, смещающих кадр стека - ведь нам надо добиться, чтобы переменная buf функции bar() легла в аккурат поверх переменной buf функции foo(), но увы, никакие извращения не дают 100% гарантии. Компилятор - это черный ящик и никто не знает, что у него на уме.
Листинг 3. Рабочий пример с неявной инициализацией локальных переменных.