Коллектив авторов - Защита от хакеров корпоративных сетей
Примечание
Все приведенные в главе примеры откомпилированы в операционной системе Windows 2000 компилятором VC++ 6 SP5 (Msdn.microsoft.com), если об этом ничего не сказано. Для большей ясности и простоты при компиляции примеров использовалась возможность построения выполнимой версии программы с отключенными опциями оптимизации программного кода. Примеры дизассемблирования подготовлены с использованием дизассемблера IDA pro 4.18 (www.datarescue.com). Все примеры предполагают использование стандартного чипсета x86.
Рассматриваемые в главе стеки процессора Intel x86 инвертированы в том смысле, что области памяти с меньшими адресами находятся на «вершине» стека. Операция push перемещает указатель вершины стека ниже (проталкивает запись в стек), в то время как операция pop – выше (выталкивает данные из стека). Данные располагаются в области памяти, отведенной под стек, начиная со дна стека, то есть с его максимального адреса, по последовательно уменьшающимся адресам памяти. Отчасти этим объясняется переполнение буфера: при записи в буфер от младших адресов к старшим возможно затирание данных, ранее сохраненных в области памяти со старшими адресами, например подмена сохраненного в стеке содержимого расширенного регистра команд EIP (Extended Instruction Pointer). Адрес доступного верхнего элемента хранится в регистре-указателе стека ESP.
...Ошибки и защита
Изучение языка ассемблера
Для того чтобы лучше понять устройство стека, нужно знать ассемблер. Прежде всего использование регистров для работы с данными стека. Как правило, при работе со стеком используются следующие три регистра:
• EIP (Extended Instruction Pointer) – расширенный регистр указателя инструкции. Содержимое регистра указывает на следующую исполняемую машинную команду (текущий программный код). При вызове функций содержимое регистра сохраняется в стеке для дальнейшего использования;
• ESP (Extended Stack Pointer) – расширенный регистр указателя вершины стека. Содержимое регистра указывает на вершину стека (текущее положение в стеке). Добавление данных в стек и их удаление из стека осуществляются командами push и pop или с помощью непосредственных операций над содержимым регистра указателя вершины стека;
• EBP (Extended Base Pointer) – расширенный регистр базового указателя (указателя основной точки стека). Во время работы функции содержимое регистра должно оставаться неизменным. Содержимое регистра и смещение позволяет адресовать хранимые в стеке переменные и данные. Почти всегда регистр указывает на вершину стека выполняющейся функции.
В последующих секциях главы будет рассказано о записи локальных переменных в стек, использовании стека для передачи параметров функции, и показано, каким образом злоумышленник может воспользоваться переполнением буфера, чтобы выполнить злонамеренный код.
Большинство компиляторов в начале функции вставляют служебный программный код, который иногда называют прологом (prologue) функции. Назначение пролога, помимо всего прочего, – подготовить стек для работы функции. Часто именно эта часть программного кода сохраняет старое содержимое регистра EBP и загружает в него указатель текущего положения в стеке. После этих действий регистр EBP содержит указатель на вершину стека выполняющейся функции. Зная содержимое регистра EBP и добавляя к нему смещение, получают ссылку на размещенные в стеке данные. Обычно регистр EBP адресует переменные, хранимые в стеке.
Приведенный ниже пример простой программы с несколькими локальными переменными демонстрирует сказанное. Подробные комментарии в исходном тексте программы позволят читателю лучше понять, что она делает.
Пример программы
Приведенная на рис. 8.1 написанная на языке C программа (C-программа) очень проста. Она присваивает своим переменным некоторые значения.
Рис. 8.1. Пример простой программы, иллюстрирующий работу стека
В программе создаются три локальные переменные, которые будут помещены в стек: 15-байтовый буфер символов buffer и две целые переменные intl и int2. Во время инициализации главной функции программы этим переменным присваиваются значения, а по завершении своей работы программа возвращает 1. Несмотря на простоту, программа полезна для изучения машинного кода оттранслированной функции на языке C вместе с прологом, эпилогом и стеком. Рассмотрим дизассемблерный вид приведенной на рис. 8.1 программы, которая была скомпилирована как консольное приложение Windows в режиме построения окончательной версии Release. Дизассемблирование
Дизассемблирование приведенной на рис. 8.1 программы показывает, как компилятор решил несложную задачу определения, инициализации локальных переменных и записи их значений в стек. Результаты дизассемблирования приведены на рис. 8.2.
Рис. 8.2. Результаты дизассемблирования простой программы на языке CИз рисунка 8.2 видно, что в прологе функции _main компилятор сначала сохранил старое значение регистра EBP в стеке, а затем записал в EBP адрес вершины стека функции (текущее положение в стеке). Эти стандартные действия делаются для того, чтобы каждая функция использовала свой собственный стек. Большинство, если не все, функций выполняют подобные операции в начале, а обратные им – в конце, в заключительной части программы – эпилоге.
Дамп стека
Для того чтобы можно было просмотреть область стека, после его инициализации в отладчике была установлена точка прерывания. При просмотре стека видно, что в нем хранится в начале работы функции, и легче понять, что происходит со стеком в процессе ее выполнения. Дамп стека показан на рис. 8.3.
Рис. 8.3. Дамп стека после инициализацииИз рисунка видно следующее. Буфер памяти, инициализированный строкой «Hello World», занимает 16 байт, а каждое целое число занимает область памяти размером по 4 байта. Шестнадцатеричные числа слева от дампа – статические адреса стека, которые определяются компилятором во время трансляции и которые Windows редко использует. Статические адреса стека потребуются для задания точек перехода при изучении возможности использования переполнения буфера в своих целях. Из рисунка 8.3 видно, что в области стека буфер памяти занимает 16 байт, а не 15, как определено в программе. Большинство компиляторов выравнивают область стека и области данных в стеке на границу двойного слова, поэтому области стека и данных начинаются с адреса памяти, кратного четырем. Это является обязательным условием повышения производительности процессора, и многие программы предполагают, что выравнивание обязательно выполняется. Поэтому буфер памяти занимает 16 байт в области стека, а не 15.
Разнообразие стеков
После инициализации стек может измениться по многим причинам. Опции компилятора влияют на размер и выравнивание стека программы, а настройки оптимизации генерируемого компилятором кода могут сильно изменить механизм создания стека и получения к нему доступа.
Некоторые функции в прологе сохраняют в стеке содержимое отдельных регистров. Во-первых, это делать необязательно, а во-вторых, это полностью зависит от компилятора и функции. В генерируемом компилятором коде может быть или несколько команд сохранения регистров, или одна команда pusha, которая сразу сохраняет содержимое всех регистров. К тому же в прологе могут быть изменены размеры стека и некоторые смещения.
Многие современные компиляторы языков С и С++ оптимизируют генерируемый код различными способами, что сказывается на работе со стеком и стековыми переменными. Например, в одном из наиболее часто встречающихся вариантов оптимизации генерируемого программного кода для доступа к данным в стеке вместо регистра EBP используются ESP. Код получается сложным и трудно поддается анализу, но при этом освобождается лишний регистр, который компилятор использует для генерации более быстрого кода. Другим примером влияния оптимизации кода на работу со стеком служит размещение компиляторами временных переменных в области стека. По разным причинам компиляторы размещают временные переменные в области стека, например для сокращения времени выполнения циклов в программе. Всегда подобные действия сопровождаются тщательной настройкой указателя смещения для доступа к данным стека.
Рассматривая проблемы работы со стеками, нельзя не упомянуть о новых способах защиты стеков программным кодом, генерируемым компиляторами. На них основан проект Crispin Cowen's Immunix (www.immunix.com). В проекте используется модифицированный GCC компилятор с языка C для генерации программного кода, практически не позволяющего выполнить злонамеренную программу в результате подмены содержимого регистра EIP при переполнении буфера. Как правило, используется способ, получивший название проверочных величин (canary values). Он основан на записи в прологе дополнительной величины в стек и проверке ее значения в эпилоге функции. При совпадении обоих значений гарантируются целостность стека и неизменность значений сохраненных в стеке регистров EIP и EBP.