ТехноСаратов → Блог

Переполнение буфера. Часть первая.БлогПрограммирование

Онлайн инструменты Multitoolbox — новый проект от создателей этого поратала

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

Но в виду большого объема материала и ограниченного времени, которое я могу выделять на это каждый день, я решил разделить все на несколько частей. Как говорил Джеки Чан в одном из своих фильмов — «Это удар. Завтра научу его отражать».

1. Что такое переполнение буфера?

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

При программировании микропроцессорных систем под буфером понимается условно ограниченный участок памяти в ОЗУ. В разных языках возможно существование разных абстракций буфера: массив, стек (LIFO), очередь(FIFO)…

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

Например, язык программирования С++, который имеет одновременно свойства и высокоуровневого и низкоуровневого, имеет в стандартной библиотеке абстракцию буфера — vector, реализующую безопасную работу с массивами, а незащищенный вариант массивов встроен в сам язык.

Язык Си вообще не имеет никаких стандартных высокоуровневых абстракций позволяющих безопасно работать с массивами, и гарантировать что в операции array[i] индекс i будет всегда находиться в допустимых пределах.

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

2. Как программа распределяет память для буфера?

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

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

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

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

push — положить значение на вершину стека и сдвинуть указатель на следующую позицию (на элемент ниже)
pop — снять значение с вершины стека и сдвинуть указатель вершины обратно (на элемент выше)

http://technosaratov.ru/galleries/29/456.jpg

Предположим у нас в программе есть следующая бестолковая функция (подсветка синтаксиса нужная вещь, да):

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

кусочек декомпилированного main:

1. 004012F2 |. C74424 08 C0C0>MOV DWORD PTR SS:[ESP+8],0C0C0
2. 004012FA |. C74424 04 B0B0>MOV DWORD PTR SS:[ESP+4],0B0B0
3. 00401302 |. C70424 A0A0000>MOV DWORD PTR SS:[ESP],0A0A0
4. 00401309 |. E8 9CFFFFFF CALL 4.004012AA

Здесь видно, что происходит два вызова функции myfunc, которая после компилирования расположилась по адресу 0x004012AA.

Строки 1,2,3 помещают параметры функций в адреса выше указателя стека ESP. Так как мы рассматриваем переполнение буфера, то нас сейчас мало интересует как компилятор распределил пмять для параметров функций, главное, что они расположены в стеке и их адреса, на данном этапе, вычисляются как ESP+смещение в стеке. Перед вызовом первой функции в стеке имеем примерно следующую картину:

0022FF50 0000A0A0
0022FF54 0000B0B0
0022FF58 0000C0C0

Инструкция CALL 4.004012AA перед передачей управления на адрес 4.004012AA сохраняет в стеке адрес возврата, то есть адрес следующей инструкции после CALL 4.004012AA, чтобы программа могла правильно продолжить выполнение после возвращения управления из вызываемой функции. Картина в стеке меняется следующим образом:

0022FF4C |0040130E RETURN to 4.0040130E from 4.004012AA
0022FF50 |0000A0A0
0022FF54 |0000B0B0
0022FF58 |0000C0C0

И так управление передано на начало функции myfunс, рассмотрим ее дизассемблерованный листинг и разберемся как компилятор распределил int array[3] в стеке и как инструкции адресуются к элементам массива.

1. 004012AA /$ 55 PUSH EBP
2. 004012AB |. 89E5 MOV EBP,ESP
3. 004012AD |. 83EC 18 SUB ESP,18
4. 004012B0 |. 8B45 08 MOV EAX,DWORD PTR SS:[EBP+8]
5. 004012B3 |. 8945 E8 MOV DWORD PTR SS:[EBP-18],EAX
6. 004012B6 |. 8B45 0C MOV EAX,DWORD PTR SS:[EBP+C]
7. 004012B9 |. 8945 EC MOV DWORD PTR SS:[EBP-14],EAX
8. 004012BC |. 8B45 10 MOV EAX,DWORD PTR SS:[EBP+10]
9. 004012BF |. 8945 F0 MOV DWORD PTR SS:[EBP-10],EAX
10. 004012C2 |. 8B45 E8 MOV EAX,DWORD PTR SS:[EBP-18]
11. 004012C5 |. C9 LEAVE
12. 004012C6 . C3 RETN

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

0022FF48 /0022FF78 — — Это сохранённое значение EBP
0022FF4C |0040130E RETURN to 4.0040130E from 4.004012AA
0022FF50 |0000A0A0
0022FF54 |0000B0B0
0022FF58 |0000C0C0

В строке 2 дизассемблированного листинга сохраняем в регистр EBP указатель на вершину с тека ESP. С помощьу вычитания в строке 3 распределяем память в стеке под локальные переменные. Теперь, если бы мы вызывали из myfunc еще одну функцию, то она получила бы новое значени указателя на вершину стека ESP, и исходя уже из этого значения распределяла память под свои нужды. Стек изменился следующим образом:

0022FF30 77C24E42
0022FF34 00401360 |
0022FF38 0022FF48 | Память распределенная в стеке для
0022FF3C 00401466 | локальных переменных, содержит мусор, от
0022FF40 00401380 | предыдущих вызовов
0022FF44 00004000 /
0022FF48 /0022FF78 — EBP
0022FF4C |0040130E RETURN to 4.0040130E from 4.004012AA
0022FF50 |0000A0A0
0022FF54 |0000B0B0 | — параметры передаваемые при вызове
0022FF58 |0000C0C0 /

Как несложно заметить из листинга, для адресации к параметрам функции используется суммирование смещения переменной со значением EBP:

4. 004012B0 |. 8B45 08 MOV EAX,DWORD PTR SS:[EBP+8]

Т.к. параметры распологаются выше того места в стеке на которое указывает в данный момент EBP (0022FF48). А для вычисления адреса элементов массива array вычитание:

5. 004012B3 |. 8945 E8 MOV DWORD PTR SS:[EBP-18],EAX

Т.к. все локальные переменные располагаются ниже этого адреса, в том числе и массив array[]. По этим смещениям мы можем точно установить соответствие элементов стека элементам массива:

array[0] -> [EBP-18] = 0022FF48-0x18 = 0x0022FF30
array[1] -> [EBP-14] = 0022FF48-0x14 = 0x0022FF34
array[2] -> [EBP-10] = 0022FF48-0x10 = 0x0022FF38

То есть:

0022FF30 77C24E42 -> array[0]
0022FF34 00401360 -> array[1]
0022FF38 /0022FF48 -> array[2]
0022FF3C 00401466
0022FF40 00401380
0022FF44 00004000
0022FF48 /0022FF78 — сохраненный EBP
0022FF4C |0040130E RETURN to 4.0040130E from 4.004012AA

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

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