• Перейти на CTF площадку Игры Кодебай

    Выполняйте задания по кибербезопасности в формате CTF и получайте бесценный опыт. Ознакомиться с подробным описанием можно тут.

Статья Эксплуатация бинарных уязвимостей (PWN) для почти начинающих: простое переполнение буфера [0x01]

123.jpg

1.0. Введение.

1.1 Введение: информация в целом

В начале была функция, и функция эта называлась gets, и функция использовалась в Си. А потом программы, которые использовали её, стали ломать. И ломали легко. Да и не только программы, а и аккаунты на машинах, запускающих их.

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

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

Естественно, не всегда эксплуатируют уязвимости ручками. Чаще всего это делают через эксплойт(ы).

Эксплойт - скрипт или просто набор команд, которые эксплуатируют бинарную уязвимость.

Перед прочтением этого цикла статей вам следует знать, что такое ассемблер, язык Си и как ревёрсить программы на этих языки. А так же знать работу памяти на начальном уровне. И уметь пользоваться IDA с некоторыми другими инструментами.
Не будет рекламой, но скажу, что на курсе это можно узнать. Либо из материалов и статей с интернета. Их достаточно :)
Далее я напомню некоторые (но не все) моменты, которые вам следует понимать.

1.2 Некоторая информация про память

1.2.1 Сегменты, структуры данных и адресное пространство

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

Почти всегда самые главные части для нас - это сегмент с кодом (.text), сегмент с данными (основные: .data и .bss) и сегмент стека.

Сегмент .data содержит инициализированные переменные. Например, переменная a из строки int a = 15;, которая не объявлена в функции, попадёт в этот сегмент с начальным значением 15.
Сегмент .bss содержит неинициализированные переменные. Например, переменная a из строки int a;, которая не объявлена в функции, попадёт в этот сегмент, но без начального значения. Там просто зарезервируется память.

Сегмент - непрерывная область адресного пространства со своими атрибутами доступа.
Тут стоит сделать важное уточнение: начнём изучать бинарные уязвимости мы с ELF-файлов из-за простоты работы под Linux. Но если цикл статей вам понравится, то в дальнейшем перейти к Windows. Так вот, в ELF-файле сегмент может быть разбит на секции, а в PE-файлах (EXE для Windows) наоборот секция - это основная используемая единица.

Потом происходит инициализация стека.
Стек - структура данных, организованных по принципу LIFO (Last In, First Out, «последним пришёл — первым ушёл»). Принцип похож на игрушечную пирамидку.

Pasted%20image%2020221229175853.png
Pasted image 20221229175853.png


Самый верхний элемент (самый верхний, красный) попадает на вершину последним (Last In). Но чтобы вытащить последний (самый нижний, фиолетовый), нужно забрать все элементы выше. Поэтому самый нижний элемент уйдёт последним, а самый верхний уйдёт первым (First Out).
Стек хорошо подходит для хранения временных переменных. А если быть точнее, то для хранения локальных переменных. Например, переменная a из строки int a = 15;, которая объявлена в функции, попадёт в локальную переменную на стеке со значением 15.

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

1.2.2 Регистры

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

  1. Регистры данных.
  2. Сегментные регистры.
  3. Регистры-указатели.
  4. Индексные регистры.
  5. Регистр флагов.
Регистры данных используются для хранения промежуточных значений, счётчика цикла, передача аргумента и других операций. Вспомним название регистров под x64 и их частое назначение.

1.2.2.1. Регистры данных

AH (старшая половина) или AL (младшая половина) (1 байт), AX (2 байта), EAX (4 байта), RAX (8 байт) - Accumulator. Хранит результат функции и результаты других вычислений.
BH (старшая половина) или BL (младшая половина) (1 байт), BX (2 байта), EBX (4 байта), RBX (8 байт) - Base. Часто используется для косвенной адресации памяти.

CH (старшая половина) или CL (младшая половина) (1 байт), CX (2 байта), ECX (4 байта), RCX (8 байт) - Count. Используется, как счётчик циклов и в некоторых инструкциях. Используется в соглашении AMD64 ABI и Microsoft x64 для передачи целочисленного аргумента.

DH (старшая половина) или DL (младшая половина) (1 байт), DX (2 байта), EDX (4 байта), RDX (8 байт) - Data. Используется для хранения старшей части результатов некоторых функций. Используется в соглашении AMD64 ABI и Microsoft x64 для передачи целочисленного аргумента.

Приставка E - Extended (EAX, ECX и так далее). Такие регистры только в x32 и x64.
Приставка R - Re-extended (RAX, RCX и так далее). Такие регистры только в x64.
BH и BL - это половинки BX.
BX - это половина EBX.
EBX - это половина RBX.
В виде картинки это можно представить так:

Pasted%20image%2020221229192509.png
Pasted image 20221229192509.png


Так же можно сделать и с другими регистрами.

1.2.2.2. Сегментные регистры

В x64 не используются сегментные регистры, поэтому в CS, SS, DS и ES базовый адрес принудительно выставляется в 0. Сегментные регистры FS и GS всё ещё могут иметь какой-либо адрес, но он будет 64-битным.

1.2.2.3. Регистры-указатели

SPL (1 байт, но в x64), SP (2 байта), ESP (4 байта), RSP (8 байт) - регистр, который указывает на вершину стека.
BPL (1 байт, но в x64), BP (2 байта), EBP (4 байта), RBP (8 байт) - регистр, который чаще всего указывает на начало стекового кадра. Относительно значения в этом регистре адресуются локальные переменные на стеке.
IP (2 байта), EIP (4 байта), RIP (8 байт) - регистр, который хранит адрес следующей команды для исполнения.

1.2.2.4. Регистры-указатели

SIL (1 байт, но в x64), SI (2 байта), ESI (4 байт), RSI (8 байт) - регистр, который является указателем индекса строки и используется при работе со строками в ассемблере.
DIL (1 байт, но в x64), DI (2 байта), EDI (4 байта), RDI (8 байт) - регистр, который является указателем индекса строки назначения и используется при работе со строками в ассемблере.

1.3 Как хранятся числа в памяти, дополнительный код

В ассемблере и языке Си под переменную с числом можно выделить разное количество байт для хранения. Вот типы языка Си с обозначениями из ассемблера:
char (byte - db) - 1 байт
short (word - dw) - 2 байта (слово)
int (double - dd) - 4 байта (двойное слово)
long (quadro word - dq) - 8 байт (четверное слово)
Напоминаю, что мы рассматриваем x64, поэтому размеры такие.
Например, возьмём число 134. Поместим его в переменную типа int. Представим в 16ой системе счислений: 0x86. Сейчас это число занимает 1 байт. Добьём его незначащими нулями до 4 байт.
0x86 (1 байт)
0x0086 (2 байта)
0x00000086 (4 байта)
Вспомним, что в x86 и x64 данные хранятся в Little-Endian и получим такое :)

0x86000000
Если посмотреть такое число в программе, то увидим это.

Pasted%20image%2020221230194549.png
Pasted image 20221230194549.png


Для хранения отрицательных чисел используется дополнительный код.
Рассмотрим на примере 1 байта. В 1-байтовом (8-битном) регистре или переменной может быть 256 значений (2).

Решили, что первая часть значений: [0; 127] - это положительные числа и нуль. От 0 до 127.
А вторая часть значений: [128; 255] - это отрицательные числа от -128 до -1.

Если посмотреть в двоичном виде, то можем убедиться в этом.

0 - 0000 0000
1 - 0000 0000
127 - 0111 1111
128 (-128) - 1000 0000
129 (-127) - 1000 0001
255 (-1) - 1111 1111.


Отличие этих чисел в том, что первый бит - это 1 (выделена жирным). По ней и определяется отрицательное или положительное число.
Если самая первая цифра в 2-ом представлении - это 0, то число положительное.
Если самая первая цифра в 2-ом представлении - это 1, то число отрицательное.
Если все цифры 0 в 2-ом представлении, то всё число - это 0.

Только важно учесть размер переменной для определения.

Выше мы рассматривали значение для 1 байта. А если взять 4-байтовое, то оно выглядит так:

00000000 00000000 00000000 00000000

И по первому биту определяется, положительное или отрицательное число. В данном случае оно положительное.

Вернёмся снова к 1 байту:


127 - 0111 1111
128 (-128) - 1000 0000
Видим, что числа 127 и 128 разделяют диапазоны неотрицательных и отрицательных чисел. Их можно представить в 16-ом виде для удобства.


0x7F - 0111 1111
0x80 - 1000 0000
0xFF - 1111 1111


Запомните их.
Если мы вернёмся к 4 байтам и используем 4 байтовую переменную, то максимальное положительное число - это 0x7FFFFFFF. А начало положительных в 0x00000000.
Максимальное отрицательное - это 0xFFFFFFFF. А начало отрицательных в 0x80000000.
Снова видим эти числа. Просто они немного увеличились.
Таким образом, диапазон положительных чисел и 0 для 8 байтовой (64-битной) переменной или регистра: [0x0000000000000000, 0x7FFFFFFFFFFFFFFF].
Диапазон отрицательных чисел для 8 байтовой (64-битной) переменной или регистра: [0x8000000000000000, 0xFFFFFFFFFFFFFFFF].


Диапазон положительных чисел и 0 для 4 байтовой (32-битной) переменной или регистра: [0x00000000, 0x7FFFFFFF].
Диапазон отрицательных чисел для 4 байтовой (32-битной) переменной или регистра: [0x80000000, 0xFFFFFFFF].

Диапазон положительных чисел и 0 для 2 байтовой (16-битной) переменной или регистра: [0x0000, 0x7FFF].
Диапазон отрицательных чисел для 2 байтовой (16-битной) переменной или регистра: [0x8000, 0xFFFF].

Диапазон положительных чисел и 0 для 1 байтовой (8-битной) переменной или регистра: [0x00, 0x7F].
Диапазон отрицательных чисел для 1 байтовой (8-битной) переменной или регистра: [0x80, 0xFF].
Они похожи :)

Дробные числа представлены в языке Си в виде таких основных типов:
float (4 байта)
double (8 байт)
Эти числа хранятся в памяти через стандарт IEEE-754.
Это только часть из всей необходимой информации, что вам следует вспомнить. Если вы не помните большую часть из неё, то, скорее всего, вам будет трудно понять материал дальше. Но можете попробовать, если уверены в себе.

2.0. Простое переполнение буфера.

Про переполнения буфера знает большинство из нас. Буфер - это непрерывный ограниченный участок памяти фиксированного размера. Чаще всего, это массив.

Почему становится возможным переполнить массив?
В языке Си и С++ не проверяются границы массивов. Поэтому программист может записать значение за границы массива. Более того, не только программист, а ещё и мы. Но при определённых условиях!

2.1. "Тайна границ массива"

2.1.1. Исходный код

Перед изучением таких условий, посмотрим на то, как мы можем выйти за границы массива.

C:
#include <stdio.h>

int main() {

    char azz[] = "azzaz";
    int arr[4] = {0x41, 0x42, 0x43, 0x44};
    int test = 65;
    int secret_key = 0xAAA;

    arr[1] = 5;
    printf("arr[1] = %d\n", arr[1]);

    arr[4] = 0x50; /* Частая ошибка, так как массив начинается с нуля. */
    printf("arr[4] = %d\n", arr[4]);


    printf("secret_key_before = %#X\n", secret_key);
    arr[-1] = 0xCCA;
    printf("secret_key_after = %#X\n", secret_key);

    arr[0x150] = 5;
    printf("arr[0x150] = %d\n", arr[0x150]);
    printf("arr[0x180] = %d\n", arr[0x180]);

    arr[-40] = 5;
    printf("arr[-40] = %d\n", arr[-40]);
 
    return 0;
}


Компиляция: gcc test.c -o test
Запуск: ./test
Как вам такое? :)


Pasted image 20221231131959.png
Pasted%20image%2020221231131959.png


В строке arr[4] = 0x50 мы присваиваем значение пятому элементу. Это частая ошибка, так как мы объявили массив из 4 элементов (int arr[4]), а пытаемся получить доступ к пятому под индексом 4. Напомню, что массивы в Си начинаются с индекса 0. Поэтому индекс 4 - это пятый элемент.
Так как массив всего на 4 элемента, то мы выходим за пределы и меняем пятый элемент. А за пределами массива могут быть другие важные данные, которые мы "перетрём" новыми.
Но это цветочки, так как мы можем изменить элемент по отрицательному индексу в строке arr[-1] = 0xCCA! И язык Си даст нам это сделать без проблем.

Pasted%20image%2020221231131913.png

В переменной secret_key было число 0xAAA. А после исполнения arr[-1] = 0xCCA мы поменяли значение на 0xCCA. Это потом пригодится нам (в следующей статье).

Более того, можно брать и значения, которые намного превышают размер массива. Например, 0x150. Или -40.
Таким образом, язык Си позволяет нам делать почти всё, что мы захотим. Но и всю ответственность за наши действия возлагает на нас :)

2.1.2. Изучим код в IDA

Но ещё важно посмотреть, как это всё будет выглядеть в IDA. Или говоря по-другому посмотреть на ассемблерный код.
IDA можно скачать отсюда:
Пока что мы будем пользоваться ею, но в будущем желательно будет перейти на gdb.


Pasted image 20221231185801.png

Pasted%20image%2020221231185801.png

Вот такой код у нас получился. Сразу сделаем переменные и массивы на стеке, а также дадим им осмысленные названия.




1.gif


Добавим комментарии.


Pasted image 20221231200811.png


Тут видно, что компилятор не мучался и просто сделал перемещение в нужные участки памяти. Всё правильно =D
Если для доступа к элементу мы используем положительный индекс, то всё понятно. Компилятор будет сразу перемещать данные в нужную ячейку памяти, как показано выше. Или использовать формулу ниже для нахождения адреса элемента, а потом дальнейшего перемещения в него.

Z = x+n×k

Где:

Z — это адрес нужного нам элемента.
x — это адрес элемента с индексом 0.
n — это индекс нужного нам элемента.
k — это размер типа данных одного элемента массива.
А если индекс отрицательный, то будет такое: Z < x.

Например, мы запустили отладчик и получили такие данные:
x = 0x7FFF494B13D0.
n = -1 (0xFFFFFFFFFFFFFFFF).
k = 4 (int).

Тогда если подставить, получим такой пример: Z = 0x7FFF494B13D0 + (-1) * 4 = 0x7FFF494B13D0-0xFFFFFFFFFFFFFFFF*4 = 0x7FFF494B13D0-FFFFFFFFFFFFFFFCh = 0x00007FFF494B13CC. Можем поставить точку останова на mov [rbp+secret_key], 0CCAh, запустить отладчик и проверить. У вас будут другие значения.



2.gif


Помним про такую вещь, как переполнение в ячейках памяти, чтобы понять это :)

Например, в регистре AL (1 байт - 8 бит) было 255 (-1). Мы добавили 1. Получили 256. Оно не помещается в AL, поэтому мы вычитаем из него 256 (2) и получаем 0.
Или по-другому -1+1=0.
Думаю, остальное всё ясно.

2.2. Функция, которую нельзя называть

Вспомним начало статьи и функцию gets. Эта функция читает вводимую строку в буфер. Чтение прекращается, когда будет встречен символ новой строки (\n) или конец файла. Символ \n на конце не учитывается при записи строки в буфер. Потом к итоговой строке добавляется 0x0 (символ конца строки).
Вот прототип функции:


C:
char * gets( char * string );

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


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

char key_correct[] = "CDB";

int main() {

    char check[] = "CODEBY";
    char key[10];

    printf("Check your key ^_^\n");
    printf("Enter key: ");
    gets(key);

    if (strcmp(check, key_correct) == 0) {
        printf("Yes!!\n");
    }
    else {
        printf("No :(\n");
    }



    return 0;
}


И увидели тут уязвимость. Более того, ваш знакомый скомпилировал файл через gcc check.c -o check -fno-stack-protector. Самое главное тут - это -fno-stack-protector. Флаг -fno-stack-protector отключает защиту на стеке.
При простом вводе данных мы видим строку "No :(".



3.gif


А теперь самое главное: функция gets никак не проверяет ввод. И никак его не ограничивает. Поэтому мы можем ввести данные любого размера.
4.gif



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

Рассмотрим подробнее мы это в следующей статье. А пока что рассмотрим скрин из IDA.


Pasted image 20230102150656.png
Pasted%20image%2020230102150656.png


Сделаем его более понятным. Далее я буду часто сразу показывать скрины из IDA, где все переменные названы и созданы нужные объекты. Вот так мы сделаем его понятнее.



5.gif

Если поставить точку останова на call _gets, то можно посмотреть на данные в стеке.
6.gif



Видно и даже посчитано, что строка "CODEBY" находится в 10 байтах от начала массива key. Если мы передадим любые 10 символов, а потом добавим строку "CDB" (её проверяет программа), то увидим строку "Yes!!".

Можно использовать, например, Python: python3 -c "print('A' * 10 + 'CDB')".
Получим: AAAAAAAAAACDB. Введём её.
Pasted%20image%2020230102172720.png

Pasted image 20230102172720.png


Ура! Это наша первая запывненная программа! :)
Рассмотрим подробнее в IDA. Запустим отладчик и передадим в него эту строку.
7.gif



Видим, что наш ввод перезаписал строку "CODEBY" на "CDB". И поэтому мы получили "Yes!!". Ура!
Но есть важный момент: строка CODEBY находилась в программе после массива key.

Фрагмент исходного кода выше.:


C:
    char check[] = "CODEBY";
    char key[10];

Это можно определить в IDA.
8.gif



Мы зашли в окно, где показано место, выделенное для локальных переменных (на стеке). Видно, что check ниже, чем key, поэтому мы и могли перезаписать данные. А если бы check был выше key, то уже нет. Вот так выглядит фрагмент такого кода:

C:
    char key[10];
    char check[] = "CODEBY";

В этом случае уже key находится над check.
9.gif






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

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

Важно: никогда не используйте gets! Она опасна! Это пишут даже в man'ах.

Помимо gets есть ещё и другие небезопасные функции. Например, scanf с аргументов %s: scanf(%s, str). В этом случае так же не проверяется количество вводимых символов. Но она в отличие от gets имеет дополнительное условие: читает все символы до пробела помимо остальных.

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

Пока что на этом всё. В следующей статье мы продолжим учиться переполнять буферы :)


Скоро на Codeby CTF будут таски по PWN :)

Вот тут уже есть первый:

Большое спасибо @Dzen и @DragonSov за помощь во время создания статьи!! (y)

Спасибо за прочтение! :)
 
Последнее редактирование:
Мы в соцсетях: