Переполнение буфера (уязвимость)

Раз уж на то пошло, пусть будет.
Не скажу, что в этой статье будет что-то красиво написано, не скажу, что тут всё супер идеально точно.
Я другу вкратце объяснял суть уязвимости при переполнении буфера и вообще суть самого процесса, и мне кажется, что из этих сообщений можно что-то подчерпнуть для себя, поэтому это просто Ctrl + C, Ctrl + V моих сообщений из Телеграма. Уверен, что может быть что-то указано немного неверно или неточно, в таком случае указывайте на ошибки - исправлю.
Значит, у нас есть процесс (когда мы открываем программу - у нас открывается её "экземпляр", загружая исполняемый файл в в оперативную память, это и называется процесс):

Память у нас делится на секции: код и данные. Код зачастую не меняется. Точнее, он может в теории сам себя переписывать (в случае, например, автообновления без перезапуска, а еще при внедрении кода, не предусмотренного заранее, например, читов), но в обычных программах я такого не встречал (обычно обновления идут с перезапуском программы, просто скачивают новый .exe и заменяют, при этом сам автообновлятор - это отдельный процесс). И вторая секция - это данные. Там идут сначала статические данные (глобальные переменные, строки, пр.), потом начинается куча для динамического выделения памяти (то, что мы делаем через new в C++), динамические данные - это, по сути, данные заранее неизвестной длины, память для которых мы выделяем в любой момент в программе и точно также освобождаем эту память тоже в любой момент (std::string, std::vector - это всегда динамические типы, к примеру), и последняя часть - это стек. Куча начинается сверху вниз, а стек, наоборот, снизу вверх. И стек - это те переменные/массивы/классы, которые мы объявляем внутри функций, условных операторов, циклов (и в конце они удаляются).
1709319076126.png
Жизнь переменных в С++:
void func()
{
    int a = 2; // "a" создаётся тут и живёт до конца функции
    if (a > 3)
    {
        double b = 2.27; // "b" создаётся тут и живёт до конца иф-а
        while (b > 2)
        {
            int c = 0.1; // "c" создаётся тут и живёт до конца ИТЕРАЦИИ цикла
            b -= c;
            // здесь каждую итерацию удаляется "c" из стека, если будут еще итерации цикла - она создастся заново
        }
        // здесь автоматически удаляется "b" и освобождается память для неё в стеке
    }
    b = 0; // ОШИБКА, вот здесь "b" уже не существует
    // здесь автоматически удаляется "а" и освобождается память для неё в стеке
    return;
}
При этом статические переменные (пример: static int a = 1137;) по своей сути являются глобальными, и объявляются в секции "данные".

Пока не забыл - напишу, что у адресов есть свои флаги защиты. Некоторые регионы памяти помечены как "только запись/чтение данных" (read-write), некоторые "только исполнение кода" (code execution). Поэтому если мы, например, хотим перезаписать код программы - надо снять этот флаг защиты, см. VirtualProtect.
Сами секции по сути, если чисто визуально посмотреть на эту память, неразличимы. Сказать что тут: исполняемый код или секция данных - в общем случае невозможно. При этом "прыгнуть" в секцию данных (сняв защиту) и исполнять код из этой секции - вполне реально и часто используется в читах и пр. Точно также можно читать/изменять данные из секции кода как обычные переменные.

Принцип LIFO, который там применяется, как никогда подходит под задачу стека. Пример:
C++:
void func2(int x)
{
    return x + 1;
}

void func1()
{
    int a = 2;
    a = func2(a);
    return a;
}

int main()
{
    func1();
}
Тупой пример, так как функции бессмысленные, но всё же. Идёт вызов func1, создаётся a, вызывается func2, создаётся (как аргумент) x, дальше func2 возвращает значение (x удаляется), оно записывается в a, и на последок func1 возвращает a (и a удаляется). Стек будет в момент работы func2 (по хронологии записи):
(1) [адрес указателя куда возвращаться в main]
(2) [значение a]
(3) [адрес указателя куда возвращаться в func1]
(4) [значение x]
Сначала, когда был вызов func1, у нас записала программа куда же ей возвращаться после отработки func1 (1), затем создалась переменная a (2), затем был вызов func2 и программа куда же ей возвращаться после отработки этой самой func2 (3) и затем создался аргумент x (4). Теперь, когда func2 отработает, последние 2 значения (снизу, т.е. "на вершине стека") удалятся из стека и останется только то, что нужно func1 (значения 1 и 2). Очень полезная и удобная штука, на самом то деле.
Теперь, надеюсь, понятно, что когда мы заходим в бесконечную (или даже просто очень долгую) рекурсию - у нас идёт переполнение стека, так как каждый вызов функции "кладёт" на вершину стека свои аргументы и адрес возврата, который, впрочем, один и тот же 😂 (возврат же всегда идёт на команду, следующую после return-а, который загоняет в рекурсию). И это ответ на вопрос почему не стоит в рекурсию передавать длинные типы данных (C-строки, к примеру), а лучше просто передавать 4 байта указателя, не копируя одну и ту же строку по многу раз.

Ближе к ассемблеру. Для записи в стек используется команда push, а для извлечения из стека используется pop. Работать (сравнивать, складывать, вычитать, умножать и пр.) напрямую с данными, которые в стеке, нельзя, поэтому их достают оттуда зачастую в регистр - и работают уже с регистром (eax, ebx, ...).
Например, записать данные с вершины стека в память по адресу можно так: push [0x4BFEEC] (адрес от балды).

А как это относится к шеллкоду и к нашей уязвимости?
Значит, если мы выделяем в функции, например, 128 байтов для строки - у нас выделяется на вершине стека 128 байтов, куда мы можем записывать нашу строку. А мы, например, записываем больше байтов - ну пусть будет 500 (и выходим за границу выделенной памяти). И чем это чревато? Функция следующая:
C++:
int function()
{
    char str[128];
    std::cin >> str; // и мы в консоли пишем > 128 байтов
    ...
}
Напомню, что стек записывается снизу вверх, поэтому c самого верха (минимальное значение адреса памяти) у нас будет последнее записанное значение. Поэтому, по идее, стек выглядит так (сверху вниз, т.е. слева направо):
[строка, 128 байт][адрес возврата, откуда мы вызвали нашу функцию, 4 или 8 байт в зависимости от архитектуры]
А после записи более чем 128 байт у нас перезапишется то, что справа, поэтому в теории мы можем подобрать символы такие, чтобы у нас в стеке адрес возврата записался таким как нужен нам. И тогда после отработки функции управление передастся на нужный нам адрес. Но на этом проблемы не заканчиваются, потому что:
а) Нам нужно как-то внедрить свой код по этому адресу
б) У нас сломан стек, поэтому после отработки нашего кода скорее всего приложение все равно вылетит, но нам то уже пофиг... (либо нужно чинить стек, либо прыгнуть туда, где он не будет использоваться, например, в начало кода)

Пример простой программы, так как всё познаётся на практике:
1709316918018.png
Вот обычная работа программы:
В main() написало адрес функции print(), затем дождалось ввода любого текста, вызвало функцию func(), которая прочла содержимое файла test.txt с рабочего стола и записала в массив str[32], затем файл закрылся, сработал return и вернул управление в main(), где программа и закрылась.
1709317001184.png
Но!!!
Мы попробуем сделать иначе.
Адрес функции print() - это число, которое занимает 4 байта.
А давайте попробуем найти такие 4 символа по 1 байту, чтобы в памяти если прочитать эти символы как тип int - мы бы получили нужный нам адрес.
Делаем на С++ Shell, чтобы не создавать второй проект:
1709317065380.png
Берём переменную, записываем в неё искомое число (адрес ф-и print), берём её адрес памяти (через &) и читаем значения (там же 4 байта подряд) по этому адресу, но не как число, а как символ. Далее выводим. Если что, искомые символы снизу слева скриншота.

Получается "P", "6" и какая-то ерунда. Копируем, вставляем в наш .txt файл после ненужных на данный момент (любых) символов:
1709317150885.png
Вписываем любой текст, чтобы программа продолжила работу. Ну а пока была задержка - мы смогли понять какие символы нам нужны.
И вуаля! У нас управление передалось в функцию, в которую передаваться не должно было. Посмотрите, в коде НИГДЕ НЕТ вызова print()
Стоит заметить, что я в конце функции поставил бесконечный цикл, так как на данный момент стек сломан, и непонятно куда возвращаться... иначе программа дальше просто бы крашнулась.

Можно заметить, что в файле 51 символ (блокнот пишет).
Первые 32 - любые, главное переполнить буфер и выйти за его пределы.
Остальные 19 (16 + 3). Я так подозреваю, что 16 - это пробелы в стеке, типа как bool, который занимает на самом деле 4 байта зачастую, а не 1, часто после него идут 3 неиспользуемых байта.
И последних 3 - это на самом деле наш адрес, куда мы прыгаем 😂
Примечание: Вот эти 16 я сам подбирал, прибавляя каждый раз +4 символа в файл, пока не заработало. Это ответ на вопрос а как я отгадал, что там 51 символ. Никак! Методом проб и ошибок)
Но вообще можно было бы открыть память с помощью дебагера, тот же Cheat Engine. И просто тупо посчитать сколько значений пихается в стек после добавления туда адреса возврата перед выделением памяти для строки. Но мне лень, проще подобрать по 4 байта...)

Но почему 3 байта "наших", а не 4 (ведь адрес - это int, т.е. 4 байта. ВАЖНО: в случае x64 - 8 байт, но я все делаю на x32)? А всё потому, что 4й - это нулевой (завершающий) символ. И нам повезло, что адрес начинается с двух нулей (00163650). Вот если бы нули были в средине адреса - у нас бы ничего не получилось, так как строка (чтение строки) заканчивается при виде первого нуля, и этот ноль как завершающий символ тоже записывается в конец. Поэтому мы пишем 3 байта, а последний, который "\0", сам дозаписывается функцией чтения C-строки из файла.

И да, справедливости ради расскажу о том, что в настройках проекта мне надо было отключить проверку безопасности (Security Cookies Check).
1709317363915.png
Как она устроена? Да очень просто. При вызове функции в стек после адреса возврата добавляются эти самые cookies, они рандомны и без доступа к памяти их узнать невозможно. Дальше, перед выполнением команды ret (возврат из функции), эти cookies сверяются с теми, что были. Если значение изменилось - значит кто-то пытался перезаписать адрес возврата (адрес возврата нельзя перезаписывать, не затрагивая cookies, потому что они всегда будут между любой переменной в функции и адресом возврата; то есть надо либо знать их и перезаписать такими же, но доступа к памяти у нас нет, либо угадать, что на практике невозможно). И если оказалось, что кукисы не совпадают - программа сразу крашит / вызывает исключение, не давая возможности выполниться шеллкоду (нашему коду).
В сампе такой защиты нет!

C++:
#include <iostream>
#include <fstream>
#include <string>
#include <cstdlib>

void print()
{
    std::cout << "Эээ, сюда мы не должны были попасть!@!?!@@?@" << std::endl;
    while (0);
}

void func()
{
    char str[32]; // выделяем 32 символа
    std::ifstream in("C:\\Users\\Victor\\Desktop\\test.txt"); // окрываем файл для чтения
    in >> str; // читаем из файла в char[]
    in.close();
    return;
}

int main()
{
    setlocale(LC_ALL, "Russian");
    std::cout << "Адрес функции print(): " << (void*)print << std::endl; // выводим адрес функции print, в которую мы НЕ должны попасть
    char c; std::cin >> c; // просто задержечка
    func();
    std::cout << "Программа успешно закончила свою работу." << std::endl;
    while (1);
}
Перевод int в 4 char-а:
#include <iostream>

int main()
{
    int a = ВОТ_ТУТ_САМО_ЧИСЛО;
    for (int i = 0; i < 4; ++i)
        std::cout << *(char*)((int)&a + i);
    std::cout << std::endl;
    for (int i = 0; i < 4; ++i)
        std::cout << "i = " << i << ": " << *(char*)((int)&a + i) << " (" << (int)*(unsigned char*)((int)&a + i) << ')' << std::endl;
}
Всем спасибо, всем пока.
Вопросы задавать не стесняемся, на все буду стараться отвечать, что сам знаю...
 
Последнее редактирование: