Переполнение буфера. Часть 1
Сегодня мы рассмотрим уязвимость в программном обеспечении такую, как переполнение буфера. «Переполнение буфера является одним из наиболее популярных способов взлома компьютерных систем, так как большинство языков высокого уровня используют технологию стекового кадра — размещение данных в стеке процесса, смешивая данные программы с управляющими данными (в том числе адреса начала стекового кадра и адреса возврата из исполняемой функции)» ©. Чтобы лучше понять сакральный смысл сего действия, мы вспомним о том, как организована передача параметров в процедуры через стек. Статья рассчитана на новичков и, по моему замыслу, должна послужить быстрым стартом в дальнейшее изучение искусства эксплуатации данной бреши. Вообще, взлом чего-либо — дело сугубо творческое, поэтому вы можете не повторять всю последовательность действий, описанных в данной статье байт в байт, главное — идея.
Что нам потребуется:
- Любимый отладчик – 1 шт.
- Любимый HEX-редактор – 1 шт.
- MS Visual Studio (у меня 2015 года) – 1 шт.
- Metasploit – 1 шт.
- Прямые руки – 2 шт. (количество варьируется)
Уточню, что я экспериментировал на Windows 7 x64, поднятой на VMWare. Если у вас всё это есть, то можем приступать. Для того, чтобы изменить ход работы программы нужно вызвать переход (сменить содержимое регистров CS и EIP). Для того, чтобы изменить сценарий работы программы на наш, нужно ещё и внедрить наш код в программу и совершить переход на него. Внедрённый код называется шелл-кодом или байт-кодом и тд. Написание хороших (компактных, многоплатформенных) шел-кодов – отдельная довольно сложная задача, которую мы пока рассматривать не будем, а возьмём готовые варианты. Чтобы «прочувствовать» уязвимость на себе, давайте напишем программу, в которой намеренно допустим ошибку, а затем попробуем изменить ход работы программы. Итак, код тестовой программы будет следующим:
#include <stdio.h>; #include <stdlib.h>; #include <windows.h>; #define BufSize 100 void getData(char *ptr) { char buf[16]; strcpy(buf, ptr); //....................... //всячески обрабатываем входной буфер } void hahaha() { printf("%s\n", "hahahahahahahahahahaha!"); system("pause"); ExitProcess(0); } LPVOID pointerToHahaha = (LPVOID)hahaha; int main() { int n = 20; char *buf = (char *)calloc(BufSize ,1); //инициализируем наш массив буквами for (int i = 0; i < n; i++) buf[i] = 'A'; //добавляем в конец массива указатель на функцию memcpy(&buf[n], (char *)&pointerToHahaha, 4); //вызываем уязвимую функцию getData(buf); if (1 == 0) hahaha(); //никогда не выполнится printf("end of main\r\n"); return 0; }
Не торопитесь сразу компилировать и запускать программу. Сначала, давайте подробно её рассмотрим. Первым делом инициализируется массив buf 20 буквами «A», затем, в его конец добавляется адрес функции hahaha и вызывается функция getData, в которую передаётся адрес массива с нашими данными. Далее вызовется (если конечно 1==0 🙂 ) функция hahaha (то есть никогда не вызовется из main), задача которой — вывести соответствующее сообщение и завершить программу. Естественно данная функция никогда не будет вызвана, так как 1 никогда не будет равен 0. Давайте взглянем на getData. Подразумевается, что она будет обрабатывать переданный буфер, но нам сейчас всё равно, что она делает, самое интересное для нас то, что именно в ней и кроется уязвимость, благодаря которой мы изменим стандартный ход работы программы и заставим вызваться функцию hahaha. Что именно в getData не так? А не так в ней следующее: в ней объявлен локальный массив buf размером в 16 байт. В него копируются входные данные функцией strcpy без проверки длины. Strcpy — функция копирования строк. Предполагается, что конец строки знаменуется завершающим нулём. Перед этим самым нулём может быть сколько угодно данных. Опасность кроется в том случае, если длина копируемой строки ptr превышает размер памяти, отведённой для хранения копии. В программе нет проверки на этот случай. Давайте теперь настроим проект в VS таким образом, чтобы компилятор не ставил палки в колёса, тем самым, препятствуя нашему обучению. В настройках проекта измените следующие параметры:
- C/C++: General: Debug Information Format: Program Database (/Zi)
- C/C++: Code Generation: Security Check: Disable Security Check (/GS-)
- C/C++: Code Generation: Basic Runtime Checks: Uninitialized variables (/RTCu)
- Linker: Advanced: Randomized Base Address: No (/DYNAMICBASE:NO)
- Linker: Advanced: Data Execute Prevention (DEP): No (/NXCOMPAT:NO)
На самом деле программа становится круто защищена при установленных по умолчанию параметрах проекта. В этих настройках включены такие мощные защиты от последствий (произвольного выполнения кода) переполнения буфера, как DEP, ASLR и стековые куки. О них подробно мы будем говорить в следующих циклах статей. Для начального понимания принципов эксплуатации данной уязвимости мы просто выключаем защиту, компилируем программу и запускаем.
Что же произошло? На экране консоли появилась строка «hahahahahahahahahahaha!», означающая, что функция hahaha всё-таки вызвалась! Но это же невозможно, ведь 1 никогда не равно 0! Стоп, давайте рассуждать. Сперва изменим переменную «n» на число поменьше, скажем, на 5. Компилируем, запускаем. Всё работает нормально! Функция hahaha не вызывается, как и должно быть. Очевидно, что вызвалась она не из-за того, что в какой-то момент единица перестало быть равной самой себе. Вызов hahaha произошёл после выхода из функции getData. Вместо того, чтобы вернуться из функции getData на адрес следующей инструкции после call getData, мы оказались по адресу функции hahaha. Чтобы понять, почему так происходит при «n» равным 20, придётся вспомнить ассемблер и то, как работают локальные переменные в подпрограмме.
Работа со стеком
Стек ничем не отличается от обыкновенного блока памяти, за исключением способа обращения с ним. Стек – эта такая структура данных, которые можно сохранять и извлекать только по очереди, то есть структура данных с последовательным доступом. Причём, последний положенный элемент будет считан первым. Стек можно ассоциировать с тетрисом :-). Стек предназначен для хранения таких данных как локальные переменные, адреса возвратов. Также большинство подпрограмм работает по принципу стандартного вызова (STDCALL/WINAPI/WINENTRY), в таком случае, параметры передаются в функцию таким образом, чтобы ближе был самый первый параметр, то есть с конца. При вызове функции, как уже было сказано, это делается командой call, процессор помещает в стек адрес следующей команды, который затем извлекается командой ret, и мы возвращаемся на следующую команду, после call. Регистр общего назначения SP всегда указывает на вершину стека. Для того, чтобы иметь возможность пользоваться локальными переменными, следует зарезервировать для них место в стеке. Это делается путём искусственного уменьшения указателя вершины стека SP на размер локальных переменных. Далее, если подпрограмма будет что-то закидывать в стек, то данные будут лежать «сверху» локальных переменных. Тут возникает вопрос: как, тогда можно обращаться к локальным переменным, если обычный доступ к элементам стека является последовательным? Не делать же pop столько раз, пока не доберёмся до локальных переменных!? Тут стоит вспомнить, что стек – это такой же блок памяти, как и везде, так что, обратиться можно к любой его ячейке и напрямую. Для этого нужно всего лишь знать указатель. В случае с подпрограммами делается следующая штука: сразу после того, как мы вошли в функцию, мы запоминаем в какой-нибудь регистр адрес вершины стека, далее резервируем для локальный переменных место, сдвинув вверх указатель стека SP. После чего, мы можем спокойно обращаться к локальным переменным, так как знаем их адрес расположения. Когда подпрограмма отработала, прежде чем выйти, нужно восстановить указатель стека на прежнее место, восстановить регистр, в который мы запомнили указатель стека, и уже потом выйти. Таким образом решается проблема доступа как к локальным переменным, так и к аргументам функции. Стек должен быть сбалансирован, это значит, что сколько раз мы сделали push, столько раз мы должны в конечном итоге сделать pop.За этим должен следить программист. Итак, рассмотрим следующую ситуацию: Допустим вызывается функция стандартного вызова test(DWORD arg1,DWORD arg2) у которой есть две локальный переменных также, типа DWORD. Псевдокод функции такой:
void test(DWORD arg1,DWORD arg2) { DWORD var1 = 0x11111111; DWORD var2 = 0x22222222; Eax = var1; Ebx = var2; Push data } //main Test(0x77777777, 0x99999999); push arg2; //arg2 = 0x99999999; push arg1; //arg1 = 0x77777777; 100: call test //закинуть в стек 105 и перейти по адресу test 105: xor eax,eax
Рассмотрим четыре стадии стека: 1 – состояние стека до входа в функцию test, 2 – момент, когда мы только что выполнили инструкцию call test, 3 – момент, когда в подпрограмма резервирует место в стеке под локальные переменные, 4 – push data в подпрограмме.
- Как мы знаем, тип DWORD занимает 4 байта. Перед входом в функцию стандартного вызова, предварительно закидываются в стек все аргументы функции в обратном порядке.
- После выполнения инструкции call, автоматически закидывается адрес возврата в стек. Вот как будет выглядеть картина, после вызова инструкции call test. Мы окажемся в самом начале функции.
- Резервирование места под локальные переменные происходит следующим образом: В качестве регистра, запоминающего адрес вершины стека, по стандарту всегда используют ebp, хотя можно использовать и любой другой. Компиляторы всегда используют для этой цели именно ebp, по крайней мере, я никогда не видел, что для такой цели поступали как-то по-другому. Перед перезаписью ebp, его нужно предварительно сохранить. Делается это при помощи стека. Далее происходит копирование адреса вершины стека в ebp. После чего выполняется сдвиг вверх вершины стека на размер локальных переменных вычитанием из esp localsize — общего размера всех локальных переменных, в нашем случае — двух, размером по 4 байта. Кстати говоря, если бы мы не выставили C/C++: General: Debug Information Format: Program Database (/Zi), то компилятор выравнивал бы ещё дополнительно байт 40 (точно не уверен).Эти три действия называются прологом функции. На ассемблере, пролог будет выглядеть это так:
void test(DWORD arg1,DWORD arg2) { push ebp //сохраняю содержимое ebp mov ebp,esp //запоминаю вершину стека sub esp,8 //резервирую место в стеке для лок-х переменных размером 8 байт ……………………… // }
После этого, картина в стеке будет следующей:
Обратите внимание, локальные переменные ещё не инициализированы.
- Как правило, сразу, после выполнения стандартного пролога, выполняется инициализация локальных переменных.Если в процессе работы функции используется стек, то данные записываются выше локальных переменных, благодаря тому, что мы специально приподняли вершину стека. Обращаться к локальным переменным можно относительно адреса, занесённого в регистр EBP. После push data картина будет такой:
Как вы видите, регистр EBP указывает на адрес, который находится между адресами локальных переменных и адресами аргументов (после сохранённого EBP и адреса возврата, конечно).
Рассмотрим далее, как именно происходит обращение к переменным.
Как мы видим, локальные переменные находятся выше, чем указывает EBP, так, что смещение будет отрицательным. То есть:
Eax = Var1 будет выглядеть как mov eax, DWORD ptr[ebp-4]; Ebx = Var2 будет выглядеть как mov ebx, DWORD ptr[ebp-8];
Если нам нужно обратиться к параметрам функции, то, так как они находятся ниже, чем указывает указатель в EBP, то обращение будет положительным относительно регистра EBP.Причём, нам нужно прибавить дополнительные восемь байта, чтобы добраться до первого аргумента функции, так как иначе, мы прочитаем адрес возврата.
Eax = arg1 будет выглядеть как mov eax, DWORD ptr[ebp+8]; Ebx = arg2 будет выглядеть как mov ebx, DWORD ptr[ebp+12];
Иногда встречаются ситуации, когда доступ к переменным производится относительно регистра ESP. Это не совсем удобно, но имеет место быть. В таком случае, делается всё тоже самое, что и относительно регистра EBP, только добавляется разница указателя EBP и указателя ESP. Пусть программа не использует стек (не использует push/pop), имеет также два аргумента и две локальных переменных. Тогда, разница между указателем на EBP и ESP будет равна размеру локальных переменных, то есть 8 байт. Адресация относительно ESP всегда положительная!
Обращение к локальным переменным будет такое:
Eax = Var1 будет выглядеть как mov eax, DWORD ptr[esp-4+8]; Ebx = Var2 будет выглядеть как mov ebx, DWORD ptr[esp-8+8];
Обращение к аргументам:
Eax = arg1 будет выглядеть как mov eax, DWORD ptr[esp+8+8]; Ebx = arg2 будет выглядеть как mov ebx, DWORD ptr[esp+12+8];
Контролируем переход в тестовой проге
Вам, должно быть, стало интересно, каким образом я выбрал значение «n» равное 20? Почему не 19, или не 15, а именно 20? Объясняю, это число подбирается эмпирически. Сейчас я покажу, как это делается. Попробуем в main забить полностью массив buf.
int main() { int n = 20; char *buf = (char *)calloc(BufSize ,1); //инициализируем наш массив буквами memset(buf, 'A', BufSize ); //вызываем уязвимую функцию getData(buf); if (1 == 0) hahaha(); //никогда не выполнится printf("end of main\r\n"); return 0; }
Компилируем, запускаем. Смотрим, что получилось.

Access Violation
Ясно, что при выходе из getData вместо адреса, идущего после call getData, ret извлекла из стека «AAAA» (0x41414141). Теперь, нам нужно запихнуть в стек такую строку, чтобы ret извлекла из стека адрес нужной нам инструкции. Таким образом, мы контролируем выполнение кода. Сейчас мы хотим, чтобы выполнилась функция hahaha. Для этого, нам нужно знать, какие именно «A» затёрли адрес возврата, чтобы вместо них подставить адрес функции hahaha. Делается это следующим образом: закидывается в стек строка с уникальными квартетами символов. Сгенерировать такую строку нам поможет metasploit. Есть у него в комплекте скрипт pattern_create. В качестве параметра передаём ему длину строки, которую мы хотим получить. В нашем случае — это 100.

pattern_create.rb 100
Как видите, я вывод перенаправил в файл, советую вам делать также, так как это удобно. Вот что мне нагенерила тулза:
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A
Меняем теперь 6 строку, думаю, вы догадались как 🙂 Ну, если нет, то смотрите, что получится:
int main() { int n = 20; char *buf = (char *)calloc(BufSize ,1); //инициализируем наш массив буквами memcpy(buf, "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2A", BufSize); //вызываем уязвимую функцию getData(buf); if (1 == 0) hahaha(); //никогда не выполнится printf("end of main\r\n"); return 0; }
Снова компилируем, запускаем, смотрим, какие именно данные затёрли адрес возврата:

0x37614136 — это «7aA6»
Теперь, если мы знаем, какое именно место затёрло адрес возврата. Затирает его подстрока «6Aa7» (вы же знаете, что комп хранит
данные в перевёрнутом виде). Если заменить эту подстроку на адрес hahaha, то она выполнится. Осталось посчитать, какой именно
по счёту байт затирает адрес возврата. Найти его смещение можно вручную, техника — Ctrl+F, но можно заюзать pattern_offset,
что мы и сделаем. В качестве параметров тулза принимает найденную подстроку 0x37614136 («7aA6») и длину строки — 100.

Результат работы pattern_offset
Теперь, мы знаем, что строку нужно составлять так: забиваем хоть чем, главное, ровно 20 байт, далее вставляем нужный нам адрес
возврата. Всё! Теперь взгляните, на первоначальный код. Там, как раз и представлена реализация данного метода. Можно было сделать
следующее: вставить в строку шел код, например, запуска калькулятора, и адрес возврата поменять на него.
На этом,я завершаю первую часть данной статьи. В следующей части, мы будем исследовать реальную программу и писать к ней эксплойт!
If you found an error, highlight it and press Shift + Enter or click here to inform us.
Heap spray — это техника (метод) эксплуатации! 🙂
Привет. Пытаюсь разобраться в адресации относительно ESP. Не совсем понятно, на что указывает ESP. Если на блок data, как на рисунке, то обращения к переменным и аргументам в твоих примерах не сходятся с картинкой. Если ESP указывает на VAR2, то все встает на свои места, но это не совсем очевидно, если следовать примеру.
Благодарю за комментарий! «Пусть программа не использует стек (не использует push/pop), имеет также два аргумента и две локальных переменных. Тогда, разница между указателем на EBP и ESP будет равна размеру локальных переменных, то есть 8 байт.»
В примере на картинке, подпрограмма не использует стек, так что блока data там и нет! Соответственно, в этом случае (повторюсь, когда подпрограмма не использует в своей работе стек совсем), ESP будет указывать на VAR2.