Переполнение буфера - Что нужно

ОГЛАВЛЕНИЕ

 

Что нужно

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

  • Компилятор Си (32-битный, как и всё прочее в этом списке) - Я использовал Borland C++ Compiler 5.5 (см. линк внизу)

  • Дебугер/Дизассемблер - Сойдёт Borland Turbo Debugger, однако очень удобен в использовании W32Dasm (v 8.9). Народ также рекомендует SoftICE (4.1). Хотя, если вы очень самоуверенны, можно обойтись и без дебугера.

  • Ассемблер - Что-нибудь вроде NASM, MASM или TASM.

  • Смотрелка PE-файлов - напр. PEDump, PEWizard, PEBrowse Professional или подобное.

Поехали!

Попробуем разобраться в том, что же такое переполнение стека и что с ним можно сделать. Рассмотрим следующую программу:

#include <stdio.h>

void show_array(int arrlen, char array[])
{
    char buffer[32];
    int i;

    for (i = 0; i < arrlen; i++) buffer[i] = array[i];
    printf(buffer);
}

int main()
{
    char mystr[] = "To be, or not to be...";
    show_array(23, mystr);
    return 0;
}

Функция show_array получает в качестве параметров размер массива символов и сам массив, копирует этот массив в локальную переменную buffer и выводит buffer на экран. Главная программа main просто вызывает show_array с некими параметрами. Несомненно, не очень разумная программа, но для изучения переполнения стека в самый раз.
Итак, где же здесь ошибка? Ошибка в процедуре show_array. В ней переданный в качестве параметра массив array "слепо" копируется в переменную buffer. Возможность того, что array окажется больше 32 байт (т. е. arrlen > 32) просто не учтена. Да, конечно, в нашей программе никто и не передаёт этой процедуре неподходящих данных, но ведь это только "модель" реальной программы, и на самом деле массив mystr мог бы быть введен и как строка символов с клавиатуры. Просто в дальнейшем нам будет удобнее задавать его прямо внутри программы. Это позволит нам использовать в строке различные непечатаемые символы, в том числе байт 0. По этой же причине (нам понадобится наличие в строке байта 0) я не использую функции strlen и strcpy и задаю длину строки (здесь это число 23) вручную.

Итак, что же будет, если mystr длиннее 32-х байт? Проверим. Меняем функцию main следующим образом:

int main()
{
    char mystr[] = "11111222223333344444555556666677777888889999900000";
    show_array(51, mystr);
    return 0;
}

Компилируем (bcc32 test.c), запускаем.

Не торопитесь закрывать, посмотрите внимательно на значения регистров EIP и EBP. Также прокрутите ниже, и изучите содержание стека (Stack dump):

TEST caused an invalid page fault in
module <unknown> at 00de:38383838.
Registers:
EAX=00000032 CS=0167 EIP=38383838 EFLGS=00010206
EBX=00540000 SS=016f ESP=0064fdc0 EBP=38373737
ECX=0064fd98 DS=016f ESI=0040a15b FS=10b7
EDX=bffc9490 ES=016f EDI=0064fe03 GS=0000
Bytes at CS:EIP:

Stack dump:
39393939 30303039 00003030 0040a0b8 31313131 ...

Теперь если заметить, что 38h - это код символа "8", 37h - код символа "7", а 30h - код символа "0", можно догадаться, что куски строки ("111 ... 00"), которые не влезли в буфер, оказались раскиданными по регистрам, а конец её остался на стеке (тем, кого смущает число 30303039h, напомним, что в памяти оно хранится в обратном порядке в виде 39h 30h 30h 30h. Таким образом содержание стека начинается в точности с "хвоста" нашей строки, а именно "9999900000").

Давайте разберёмся, почему так получилось. Для этого необходимо понять, как происходит вызов и выполнение процедуры show_array. Для вызова show_array(51, mystr), её аргументы (51 и адрес строки mystr) пихаются на стек в обратном порядке, и затем управление передаётся процедуре с помощью инструкции CALL show_array. Примерно так:

PUSH mystr
PUSH 51
CALL show_array

Перед тем, как передать управление процедуре show_array, инструкция CALL добавляет на стек значение регистра EIP, т. н. адрес возврата.

Далее управление переходит к show_array и перед началом собственно работы функции выполняется приблизительно следующая последовательность инструкций:

PUSH EBP
MOV EBP, ESP
ADD ESP, -36

Т. е. сначала на стеке сохраняется значение EBP, затем в EBP переносится значение ESP и наконец от ESP вычитается 36. Операции с EBP нас здесь не интересуют; достаточно сказать, что относительно EBP адресуются локальные переменные. Интересует же нас строчка ADD ESP, -36. Тем самым функция резервирует на стеке место для своих локальных переменных. Их у неё две - char buffer[32] и int i. Массив buffer занимает 32 байта, целое число i - 4 байта. Итого 36 байт.

Теперь должно быть понятно, куда попадают байты, не поместившиеся в буфер. Они записываются на место сохранённого ранее EBP, переписывают адрес возврата и так далее пока их хватит. Самое интересное же происходит при возврате из функции. Он происходит следующим образом:

MOV ESP, EBP
POP EBP
RET

Т. е. освобождается место, занятое ранее локальными переменными, затем из стека восстанавливается сохранённое в начале значение EBP и наконец инструкция RET достаёт со стека адрес возврата и передаёт управление по нему. Вспомним наш пример. Мы попробовали скопировать в буфер строку "11111222... 9900000". При этом 32 байта из неё ("11111...6666677") попали по назначению, следующие 4 байта ("7778") переписали сохранённый EBP, ещё 4 байта ("8888") попали на адрес возврата, а остаток ("9999900000") попал на место параметров и далее. При возврате из функции были, таким образом, неверно восстановлены регистры EBP и EIP, и, так как по адресу 0x38383838 исполнимых инструкций не нашлось, произошла ошибка, которую мы и имели удовольствие наблюдать.

Но ведь тот адрес, по которому произошёл возврат из функции, полностью зависит от того, какую строку мы передали функции. Значит если бы на месте байтов "8888", переписавших адрес возврата, был бы какой-нибудь реально существующий адрес, управление перешло бы по нему. Следовательно, правильно подобрав строку, которую мы передаём функции, мы можем перенаправить ход выполнения программы по нашему усмотрению. Конечно же самое интересное то, что мы можем записать прямо в строке несколько инструкций процессору, и, правильно указав адрес возврата, передать управление на этот код. Этим мы сейчас и займёмся.