План статьи:
1.0. Введение.
1.1 Введение: информация в целом.
1.2 Некоторая информация про память.
1.2.1 Сегменты, структуры данных и адресное пространство.
1.2.2 Регистры.
1.2.2.1. Регистры данных.
1.2.2.2. Сегментные регистры.
1.2.2.3. Регистры-указатели.
1.2.2.4. Регистры-указатели.
1.3 Как хранятся числа в памяти, дополнительный код.
2.0. Простое переполнение буфера.
2.1. "Тайна границ массива".
2.1.1. Исходный код.
2.1.2. Изучим код в IDA.
2.2. Функция, которую нельзя называть.
Вторая часть: Статья - Эксплуатация бинарных уязвимостей (PWN) для почти начинающих: pwntools и простое переполнение буфера (BOF) [0x02]
1.0. Введение
1.1 Введение: информация в целом
В начале была функция, и функция эта называласьgets
, и функция использовалась в Си. А потом программы, которые использовали её, стали ломать. И ломали легко. Да и не только программы, а и аккаунты на машинах, запускающих их.В этом цикле статей мы постараемся понять, почему эти и многие другие программы стали ломать и как нам повторить это, эксплуатируя разные типы бинарных уязвимостей. Понимание и применение знаний по их эксплуатации позволит нам лучше понять, как не нужно писать код и как эксплуатировать тот код, что уже написан и скомпилирован.
Термину "бинарная уязвимость" можно дать разные определения. Но тут мы скажем, что это слабое место (часто функция или какой-либо код) в приложении. Человек, который может использовать эту уязвимость (эксплуатировать), способен ухудшить работу всего сервера, на котором запушено приложение, или, например, получить доступ к системе нежелательного уровня. А может и получить полный доступ к ней. Всё зависит от самого приложения.
Естественно, не всегда эксплуатируют уязвимости ручками. Чаще всего это делают через эксплойт(ы).
Эксплойт - скрипт или просто набор команд, которые эксплуатируют бинарную уязвимость.
Перед прочтением этого цикла статей вам следует знать, что такое ассемблер, язык Си и как ревёрсить программы на этих языки. А так же знать работу памяти на начальном уровне. И уметь пользоваться IDA с некоторыми другими инструментами.
Не будет рекламой, но скажу, что на курсе 4-месячный онлайн-курс (REFB) «Введение в реверс-инжиниринг» это можно узнать. Либо из материалов и статей с интернета. Их достаточно
Далее я напомню некоторые (но не все) моменты, которые вам следует понимать.
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, «последним пришёл — первым ушёл»). Принцип похож на игрушечную пирамидку.
Самый верхний элемент (самый верхний, красный) попадает на вершину последним (Last In). Но чтобы вытащить последний (самый нижний, фиолетовый), нужно забрать все элементы выше. Поэтому самый нижний элемент уйдёт последним, а самый верхний уйдёт первым (First Out).
Стек хорошо подходит для хранения временных переменных. А если быть точнее, то для хранения локальных переменных. Например, переменная
a
из строки int a = 15;
, которая объявлена в функции, попадёт в локальную переменную на стеке со значением 15.Стек "растёт" от старших адресов к младшим.
Так же есть куча, используемая для хранения динамических данных. Она "растёт" от младших к старшим адресам.
Данные в куче, стеке и сегменте с данными часто будут использованными нами для эксплуатации уязвимостей.
1.2.2 Регистры
У процессоров есть регистры — маленькие сверхбыстрые ячейки памяти, расположенные внутри процессора для считывания, записи, хранения временных данных и выполнения операций над ними. Процессор напрямую обращается к регистрам, что очень сильно повышает скорость работы.Разделим регистры на 5 категорий:
- Регистры данных.
- Сегментные регистры.
- Регистры-указатели.
- Индексные регистры.
- Регистр флагов.
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.
В виде картинки это можно представить так:
Так же можно сделать и с другими регистрами.
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
Если посмотреть такое число в программе, то увидим это.
Для хранения отрицательных чисел используется дополнительный код.
Рассмотрим на примере 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
Как вам такое?
В строке
arr[4] = 0x50
мы присваиваем значение пятому элементу. Это частая ошибка, так как мы объявили массив из 4 элементов (int arr[4]
), а пытаемся получить доступ к пятому под индексом 4. Напомню, что массивы в Си начинаются с индекса 0. Поэтому индекс 4 - это пятый элемент.Так как массив всего на 4 элемента, то мы выходим за пределы и меняем пятый элемент. А за пределами массива могут быть другие важные данные, которые мы "перетрём" новыми.
Но это цветочки, так как мы можем изменить элемент по отрицательному индексу в строке
arr[-1] = 0xCCA
! И язык Си даст нам это сделать без проблем.В переменной
secret_key
было число 0xAAA
. А после исполнения arr[-1] = 0xCCA
мы поменяли значение на 0xCCA
. Это потом пригодится нам (в следующей статье).Более того, можно брать и значения, которые намного превышают размер массива. Например, 0x150. Или -40.
Таким образом, язык Си позволяет нам делать почти всё, что мы захотим. Но и всю ответственность за наши действия возлагает на нас
2.1.2. Изучим код в IDA
Но ещё важно посмотреть, как это всё будет выглядеть в IDA. Или говоря по-другому посмотреть на ассемблерный код.IDA можно скачать отсюда:
Ссылка скрыта от гостей
Пока что мы будем пользоваться ею, но в будущем желательно будет перейти на
gdb
.Вот такой код у нас получился. Сразу сделаем переменные и массивы на стеке, а также дадим им осмысленные названия.
Добавим комментарии.
Тут видно, что компилятор не мучался и просто сделал перемещение в нужные участки памяти. Всё правильно =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
, запустить отладчик и проверить. У вас будут другие значения.Помним про такую вещь, как переполнение в ячейках памяти, чтобы понять это
Например, в регистре 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 ".
А теперь самое главное: функция
gets
никак не проверяет ввод. И никак его не ограничивает. Поэтому мы можем ввести данные любого размера.Как видно выше, мы можем передать любую строку. Но если передать данные слишком большого размера, то получим "segmentation fault". Или по-другому SIGSEGV. Эта ошибка говорит о том, что программа пытается обратиться к адресам из памяти, которой не существует или при обращении с не теми правами.
Рассмотрим подробнее мы это в следующей статье. А пока что рассмотрим скрин из IDA.
Сделаем его более понятным. Далее я буду часто сразу показывать скрины из IDA, где все переменные названы и созданы нужные объекты. Вот так мы сделаем его понятнее.
Если поставить точку останова на
call _gets
, то можно посмотреть на данные в стеке.Видно и даже посчитано, что строка "CODEBY" находится в 10 байтах от начала массива
key
. Если мы передадим любые 10 символов, а потом добавим строку "CDB" (её проверяет программа), то увидим строку "Yes!!".Можно использовать, например, Python:
python3 -c "print('A' * 10 + 'CDB')"
.Получим:
AAAAAAAAAACDB
. Введём её.Ура! Это наша первая запывненная программа!
Рассмотрим подробнее в IDA. Запустим отладчик и передадим в него эту строку.
Видим, что наш ввод перезаписал строку "CODEBY" на "CDB". И поэтому мы получили "Yes!!". Ура!
Но есть важный момент: строка CODEBY находилась в программе после массива
key
.Фрагмент исходного кода выше.:
C:
char check[] = "CODEBY";
char key[10];
Это можно определить в IDA.
Мы зашли в окно, где показано место, выделенное для локальных переменных (на стеке). Видно, что
check
ниже, чем key
, поэтому мы и могли перезаписать данные. А если бы check
был выше key
, то уже нет. Вот так выглядит фрагмент такого кода:
C:
char key[10];
char check[] = "CODEBY";
В этом случае уже
key
находится над check
.Если вы попробуйте передать нашу строку, то ничего не получится. Также не получится это сделать в примере, если не будет использован флаг
-fno-stack-protector
. Одно из его действий - это изменение расположения переменных при компиляции так, что в итоге их нельзя будет "перетереть".В итоге мы узнали следующую информацию из тестов выше: функция
gets
никак не проверяет длину вводимых данных. Если переменные располагаются нужным образом, то можно перетереть нужные данные. Важно: никогда не используйте
gets
! Она опасна! Это пишут даже в man'ах.Помимо
gets
есть ещё и другие небезопасные функции. Например, scanf
с аргументов %s
: scanf(%s, str)
. В этом случае так же не проверяется количество вводимых символов. Но она в отличие от gets
имеет дополнительное условие: читает все символы до пробела помимо остальных.В зависимости от того, где расположен этот массив, можно выделить разные виды переполнения буфера. Например, если массив находится в стеке, то это переполнение на стеке. А если в сегменте с данными, то уже там.
Пока что на этом всё. В следующей статье мы продолжим учиться переполнять буферы
Скоро на Codeby CTF будут таски по PWN
Вот тут уже есть первый:
Большое спасибо @Dzen и @DragonSov за помощь во время создания статьи!!
Спасибо за прочтение!
Последнее редактирование: