Статья про бинарные уязвимости была написана на основе лекций на ту же тематику, подготовленных мной для младших специалистов нашего отдела, которым надо было быстро разобраться в видах бинарных уязвимостей. Она предназначена для начинающих специалистов в области реверса и практической безопасности, участников состязаний CTF, решающих соответствующие категории задач, для специалистов информационной безопасности и разработки, стремящихся к пониманию существующих уязвимостей.
Бинарные уязвимости — это уязвимости исполняемых файлов, библиотек и объектного кода, которые позволяют перехватывать поток управления и запускать код злоумышленника. Это может обернуться не только компрометацией персональных данных, но и финансовыми потерями как для компаний, так и для пользователей.
Существенная часть бинарных уязвимостей может быть проэксплуатирована с помощью атаки переполнения буфера. В соответствии со списком CVE это достаточно распространённый класс уязвимостей: только за 2017 год их было зарегистрировано более 2 000. По состоянию на сентябрь 2023-го года количество зарегистрированных уязвимостей переполнения буфера превысило 22 989 за всё время [1].
На рисунке ниже показано, как менялось количество зарегистрированных уязвимостей переполнения буфера с 1999 по 2023 год. Можно заметить увеличение уязвимостей такого рода с течением времени.
Уязвимости переполнения буфера были основой для создания множества известных вирусов и атак, таких как Conficker Worm (2008), Stuxnet Worm (2010), Triton Malware (2017), что привело к значительному ущербу. Например, червь Conficker, воздействовавший примерно на 15 млн устройств в 2008 году и позднее, использовал уязвимость windows (CVE:2008-4250), связанную с переполнением буфера.
Подобные атаки, а также растущее число зарегистрированных уязвимостей в базах данных показывают необходимость изучать бинарные уязвимости и способы защиты от них.
Основы процесса компиляции файлов
В общем виде все языки программирования делятся на интерпретируемые и компилируемые.
Интерпретацией программы называется непосредственное исполнение её исходного кода (кода, написанного программистом) интерпретатором языка — программой, которая считывает исходный код и выполняет предписанные в нём действия [2].
Компиляцией программы называют процесс преобразования исходного кода в машинный код — инструкции, готовые к исполнению на процессоре. Программа, которая осуществляет компиляцию, называется компилятор. После компиляции получившийся файл загружается в память и выполняется средствами операционной системы.
Компиляция — необратимое преобразование: так, имена переменных и функций в скомпилированный файл не включаются, а сложные вложенные блоки условий и циклов преобразуются в простую и прямолинейную последовательность простейших команд, между которыми совершаются переходы. Для того чтобы прочитать исходный код, используются декомпиляторы — это программы, транслирующие исполняемый модуль (полученный на выходе компилятора) в эквивалентный исходный код на языке программирования высокого уровня.
На рисунке ниже показан процесс компиляции программы, написанной на языке C.
Для каждой программы на языке C в любом компиляторе, например, Visual Studio, GCC или LLVM, существует четырёхэтапная процедура компиляции.
На первом этапе препроцессор обрабатывает полный исходный файл и заменяет #includes и #define их фактическим содержимым. Например, «#include <stdio.h>» просто заменяется содержимым внутри файла «stdio.h», а #define заменяется своим определённым значением везде, где используется макрос #define. Также он заменяет комментарии пустыми строками, а ещё обрабатывает директивы условной компиляции (if, ifdef, else).
На втором этапе компилятор считывает предварительно обработанные данные и генерирует эквивалентный им ассемблерный код.
На третьем этапе ассемблер обрабатывает ассемблерный код, сгенерированный компилятором, и генерирует объектные файлы (файлы .obj в Visual Studio и файл .o с помощью GCC).
На четвёртом, заключительном этапе компоновщик связывает все объектные файлы, которые предоставляются, с исполняемым файлом и создаёт окончательный исполняемый файл. В этом процессе связывания он связывает (динамически или статически) всё, что нужно исполняемому файлу [4].
Структура исполняемых файлов
В процессе компиляции из написанного разработчиком файла получается исполняемый файл. Форматом исполняемых файлов для Windows является PE (англ. Portable Executable).
Основной формат бинарных файлов программ для Linux — ELF (англ. Executable and Linkable Format).
При каждом запуске исполняемого файла операционная система создаёт новый процесс.
Процесс — это активно запущенная программа, которая загружена в память и имеет уникальный идентификатор. Когда операционная система создаёт процесс, она выделяет участок памяти с заранее определённой структурой.
Память процесса делится на несколько частей, которые называются сегментами.
Статическая память программы — это часть памяти, которая является отображением кода объектного файла программы. Она инициализируется загрузчиком программ ОС из исполняемого файла (способ инициализации зависит от конкретного формата исполняемого файла) [7]. Статическая память включает в себя несколько секций, среди которых общераспространёнными являются:
text — сегмент памяти, в который записываются сами инструкции программы;
data — сегмент памяти, в который записываются значения статических переменных программы;
bss — сегмент памяти, в котором выделяется место для записи значений объявленных, но не инициализированных в программе статических переменных;
rodata — сегмент памяти, в который записываются значения констант программы;
секция таблицы символов — сегмент памяти, в который записаны все внешние (экспортируемые) символы программы с адресами их местонахождения в секциях text или data программы.
На рисунке ниже схематично показан процесс загрузки исполняемого файла PE в память с помощью блока управления памятью (MMU), который сопоставляет виртуальную сегментацию памяти с реальными адресами.
Стек и куча
Стек (stack) — это область памяти, которая используется при вызове функций для хранения её аргументов и локальных переменных. В архитектуре x86 стек растёт вниз, т.е. вершина стека имеет самый маленький адрес. Также со стеком взаимодействуют регистры центрального процессора. Регистр SP (Stack Pointer) указывает на текущую вершину стека, а регистр BP (Base Pointer) указывает на начало фрейма (кадра), который используется для разделения стека на логические части, относящиеся к одной функции. Помимо обычных инструкций работы с памятью и регистрами (таких как mov), дополнительно для манипуляции стеком используются инструкции push и pop, которые заносят данные на вершину стека и забирают их с вершины. Эти инструкции также осуществляют изменение регистра SP [7].
Как правило, в программах на высокоуровневых языках программирования нет кода для работы со стеком напрямую, а это делает за кадром компилятор, реализуя определённые соглашения о вызовах функций и способы хранения локальных переменных. Однако функция alloca библиотеки stdlib позволяет программе выделять память на стеке.
Вызов функции высокоуровневого языка создаёт на стеке новый фрейм, который содержит аргументы функции, адрес возврата из функции, указатель на начало предыдущего фрейма, а также место под локальные переменные.
Стек как структура данных работает по принципу LIFO («последним пришёл — первым ушёл»). Другими словами, добавлять и удалять значения в стеке можно только с одной и той же стороны [9].
Куча (heap) — это часть динамической памяти, предназначенная для выделения участков памяти произвольного размера. Она в первую очередь используется для работы с массивами неизвестной заранее длины (буферами), структурами и объектами.
Для управления кучей используется подсистема выделения памяти (memory allocator), интерфейс к которой — это функции malloc/calloc и free в stdlib.
Многие языки высокого уровня реализуют более высокоуровневый механизм управления памятью поверх системного аллокатора — автоматическое выделение памяти со сборщиком мусора. В этом случае в программе не производится вызов функции malloc, а управление памятью осуществляет среда исполнения программы.
Переполнение буфера
Переполнение буфера — это уязвимость в области выделения памяти, которую могут использовать злоумышленники. Это происходит, когда в буфер (область памяти) может быть скопировано больше данных, чем он может обработать. Оно подразделяется на переполнение различных видов памяти: переполнение стека и переполнение кучи.
При эксплуатировании уязвимости переполнения буфера злоумышленник отправляет данные программе, которые она сохраняет в буфере меньшего размера. В результате информация перезаписывается, включая указатель возврата функции. Данные задают значение указателя возврата таким образом, чтобы при возврате функция передала управление вредоносному коду, содержащемуся в данных злоумышленника.
На рисунке ниже показано переполнение стека с сохранённым значением регистра BP (зелёным цветом), которое перезаписали в ходе эксплуатации уязвимости, и с сохранённым адресом возврата (красным цветом), который тоже был перезаписан.
Эксплуатируя переполнение в стеке, можно перезаписать следующие параметры:
Локальную переменную, находящуюся в памяти рядом с буфером, что изменит поведение программы.
Адрес возврата в стековом кадре. Как только функция завершается, управление передаётся по указанному атакующим адресу обычно в область памяти, к изменению которой он имел доступ.
Указатель на функцию или обработчик исключений, которые впоследствии получат управление.
Параметр из другого стекового кадра или нелокальный адрес, на который указывается в текущем контексте.
Эксплуатация переполнения буфера в куче аналогична эксплуатации переполнения на основе стека, за исключением того, что в этом сегменте памяти не хранятся адреса возврата. Следовательно, злоумышленник должен использовать другие методы, чтобы получить контроль над потоком выполнения. Злоумышленник может перезаписать указатель функции или выполнить косвенную перезапись указателя (метод, при котором используется переполнение для перезаписи указателя, указывающего на сохранённый адрес возврата) для указателей, хранящихся в этих областях памяти, но это не всегда возможно. Перезапись информации управления памятью, которая обычно связана с динамически выделяемой памятью, является более общим способом использования переполнения кучи. Распределители памяти выделяют её фрагментами. Эти фрагменты обычно содержат информацию об управлении памятью (называемую информацией о фрагментах) наряду с фактическими данными (данными фрагментов). Многие распределители могут быть атакованы путём перезаписи информации о фрагменте.
Переполнение кучи показано на примере атак на библиотеку dlmalloc. Так динамическая память делится на непрерывные фрагменты разного размера. При освобождении они объединяются в один большой свободный фрагмент. Эти свободные фрагменты хранятся в двусвязном списке, отсортированном по размеру. Когда распределитель памяти позже запрашивает блок того же размера, что и один из этих свободных блоков, первый блок этого размера удаляется из списка и становится доступным для использования в программе (т. е. он превращается в выделенный блок).
Вся информация об управлении памятью (включая этот список свободных фрагментов) хранится внутри диапазона. То есть информация хранится в блоках: когда блок освобождается, память, обычно выделяемая для данных, используется для хранения прямого и обратного указателей. На рисунке ниже показано, как выглядит куча, содержащая используемые и неиспользуемые фрагменты.
Chunk1 — это выделенный фрагмент, содержащий информацию о размере сохранённого перед ним фрагмента и его собственном. Остальная часть фрагмента доступна для записи данных.
Chunk3 — это свободный фрагмент, расположенный рядом с фрагментом Chunk1.
Chunk2 и Chunk4 — это свободные фрагменты, расположенные в произвольном месте кучи.
Chunk2 является первым фрагментом в цепочке: его прямой указатель указывает на Chunk3, а его обратный указатель — на предыдущий фрагмент в списке. Прямой указатель Chunk3 указывает на Chunk4, а его обратный указатель на Chunk2.
Chunk4 — последний фрагмент в данном примере: его прямой указатель указывает на следующий фрагмент в списке, а его обратный указатель на Chunk3.
На рисунке ниже показано, что может произойти, если массив, расположенный во фрагменте Chunk1, переполнится: злоумышленник перезапишет управляющую информацию фрагмента Chunk3. Поля размера остаются без изменений (хотя они могут быть изменены по желанию злоумышленника). Прямой указатель изменяется, чтобы указывать на 12-й байт перед адресом возврата функции f0, а обратный указатель изменяется, чтобы указывать на код, который перепрыгнет через следующие несколько байтов, а затем выполнит внедрённый код. Когда Chunk1 впоследствии освобождается, он объединяется вместе с Chunk3 в больший фрагмент.
Ниже приведено несколько примеров уязвимого кода [14]. В данном фрагменте используется функция gets() для записи произвольного количества данных в буфер стека. Поскольку невозможно ограничить объём данных, считываемых этой функцией, безопасность кода зависит от того, всегда ли пользователь вводит меньше символов, чем BUFSIZE.
...
char buf[BUFSIZE];
gets(buf);
...
В примере ниже используется функция копирования памяти memcpy(). Эта функция принимает буфер назначения, буфер источника и количество байтов для копирования. Входной буфер заполняется вызовом read(), который ограничивает размер, но пользователь может указать неверное количество байтов, которое копирует memcpy(). Этот тип уязвимости переполнения буфера (когда программа считывает данные, а затем использует их в последующих операциях с памятью над оставшимися данными) с некоторой частотой обнаруживается в библиотеках обработки изображений, аудио и других файлов.
...
char buf[64], in[MAX_SIZE];
printf("Enter buffer contents:\n");
read(0, in, MAX_SIZE-1);
printf("Bytes to copy:\n");
scanf("%d", &bytes);
memcpy(buf, in, bytes);
...
Двойное освобождение памяти (Double Free memory)
Двойное освобождение памяти — уязвимость кучи, которая возникает, когда функция free() вызывается более одного раза с одним и тем же адресом памяти в качестве аргумента [15]. Когда программа вызывается free() дважды с одним и тем же аргументом, структуры данных управления памятью программы повреждаются и могут позволить злоумышленнику записывать значения в произвольные области памяти. Это повреждение может привести к сбою программы или в некоторых случаях — к изменению потока выполнения. Перезаписывая определённые регистры или области памяти, злоумышленник может заставить программу выполнять код по своему выбору, что часто приводит к созданию интерактивной оболочки с повышенными разрешениями.
Когда буфер освобождается, считывается связанный список свободных буферов, чтобы переупорядочить и объединить фрагменты свободной памяти (чтобы иметь возможность выделять буферы большего размера в будущем). Эти фрагменты располагаются в виде двойного связанного списка, который указывает на предыдущий и следующий фрагменты. Отключение неиспользуемого буфера (что происходит при вызове free()) может позволить злоумышленнику записать произвольные значения в память.
Существует множество зарегистрированных уязвимостей, связанных с двойным освобождением памяти. К известным стоит отнести уязвимость CVE-2017-2636 в драйвере ядра Linux (до версии 4.10.1), что позволяет пользователям вызвать отказ в обслуживании или повысить привилегии. В 2022 году подобная уязвимость (CVE-2022-226617) была обнаружена в ядре графического процессора новой на тот момент версии iOS (15.4). Она позволяла выполнить произвольный код с использованием привилегий ядра.
Ниже приведен пример уязвимого кода на языке C++ [16].
// example2.cpp
// double-free error
#include <stdlib.h>
#include <string.h>
int main(int argc, char** argv) {
char* x = (char*)malloc(10 * sizeof(char));
memset(x, 0, 10);
int res = x[argc];
free(x);
// ... some complex body of code
free(x + argc - 1); // Boom!
return res;
}
UAF (Use-After-Free)
UAF (Use-After-Free) — уязвимость, которая возникает при обращении к памяти после её освобождения при помощи указателя, который не изменился.
Уязвимость UAF возникает, когда программа продолжает обращаться к ячейке памяти после того, как данные в ней были удалены или перенесены. Если в динамической памяти был набор данных, то существовал указатель, указывающий на его начало. При удалении этого набора из динамической памяти или его перемещении неочищенный указатель, который всё ещё ссылается на освобождённый участок, становится висячим (dangling pointer). После записи в тот же блок уже новых данных висячий указатель будет указывать на некорректный объект. Таким образом можно будет вызвать произвольное выполнение кода.
Примером эксплуатации уязвимости UAF является эксплойт checkm8 [18]. Он может быть использован в некоторых устройствах на iOS. Суть состоит в том, что обновление прошивки устройства инициализируется через USB, но злоумышленник передаёт команду выхода из режима восстановления DFU (Device Firmware Update) вместо образа прошивки. При последующем запуске системы указатель не обнуляется, поэтому по его адресу может быть выполнен код злоумышленника.
Ниже приведён пример кода [19]. В нём используется указатель на buf2R1 после освобождения.
#include <stdio.h>
#include <unistd.h>
#define BUFSIZER1 512
#define BUFSIZER2 ((BUFSIZER1/2) - 8)
int main(int argc, char **argv) {
char *buf1R1;
char *buf2R1;
char *buf2R2;
char *buf3R2;
buf1R1 = (char *) malloc(BUFSIZER1);
buf2R1 = (char *) malloc(BUFSIZER1);
free(buf2R1);
buf2R2 = (char *) malloc(BUFSIZER2);
buf3R2 = (char *) malloc(BUFSIZER2);
strncpy(buf2R1, argv[1], BUFSIZER1-1);
free(buf1R1);
free(buf2R2);
free(buf3R2);
}
Атака форматной строки
Уязвимость форматной строки возникает, когда отправленные данные входной строки оцениваются приложением как команда. Таким образом, злоумышленник может выполнить код, прочитать стек или вызвать ошибку сегментации в работающем приложении, вызывая новые действия, которые могут поставить под угрозу безопасность или стабильность системы.
Атака может быть выполнена, когда приложение не проверяет должным образом отправленный ввод. В этом случае, если параметр строки формата, такой как (%x), вставлен в отправленные данные, строка анализируется функцией форматирования, и выполняется преобразование, указанное в параметрах. Функция форматирования ожидает дополнительные аргументы, и при их отсутствии она использует данные из стека (или записывает в него).
Ниже приведён пример использования уязвимости форматной строки [20].
#include <stdio.h>
void main(int argc, char **argv)
{
// This line is safe
printf("%s\n", argv[1]);
// This line is vulnerable
printf(argv[1]);
}
Целочисленное переполнение
Целочисленное переполнение (перенос) — это вид уязвимости, который происходит, когда целочисленное значение увеличивается до значения, выходящего за пределы диапазона данного типа. В типизированных языках программирования для целых чисел выделяется ограниченное количество бит памяти. Когда переполнение происходит, значение может превратиться в очень маленькое или отрицательное число.
Целочисленное переполнение может возникнуть в таких случаях, как несоответствие знакового числа и беззнакового, приведению знакового числа к беззнаковому большей длины и иных.
Большинство компиляторов игнорирует переполнение и выдаёт неожиданный вывод или ошибку. Это приводит к различным атакам, таким как переполнение буфера, которое является наиболее распространённой атакой и приводит к запуску вредоносных программ или повышению привилегий.
Известным примером целочисленного переполнения является
переполнение в игре Pac-Man и её продолжениях [27]. В игре Pac Man происходит
переполнение переменной, обозначающей номер уровня, что вызывает отказ в
обслуживании и «экран смерти». Как итог отображаемая картинка становится
нечитаемой, а игра непроходимой.
Из более новых игр примером целочисленного переполнения была проблема Diablo III, в которой одним из обновлений был повышен лимит максимальной суммы сделок. Если сумма превышала 231, то игрок после завершения сделки оставался с прибылью в 232 игровой валюты, что является некорректным [28].
Ниже приведён пример кода C [21], в котором возможно целочисленное переполнение. Этот фрагмент кода предназначен для выделения таблицы размера num_imgs. Но по мере того, как num_imgs становится большим, вычисление, определяющее размер списка, в конечном итоге переполняется. Это приводит к тому, что выделяется очень маленький список.
img_t table_ptr; /*struct containing img data, 10kB each*/
int num_imgs;
...
num_imgs = get_num_imgs();
table_ptr = (img_t*)malloc(sizeof(img_t)*num_imgs);
...
Атака возврата в библиотеку
Атака возврата в библиотеку (ret-to-libc) — разновидность атаки переполнения буфера, который использует функции системной библиотеки для исполнения кода злоумышленника.
При стандартном переполнении буфера на основе стека злоумышленник записывает свой шелл-код в стек уязвимой программы и выполняет его в стеке. Однако, если стек уязвимой программы защищён (установлен бит NX), то злоумышленники больше не могут выполнять свой шелл-код из стека уязвимой программы. Для борьбы с защитой NX используется метод возврата к libc, который позволяет злоумышленникам обойти защиту битов NX и нарушить поток выполнения уязвимой программы, повторно используя существующий исполняемый код из библиотеки libc, который уже загружен и отображён в пространство виртуальной памяти уязвимой программы аналогично тому, как ntdll.dll загружается во все программы Windows [22].
На высоком уровне техника ret-to-libc похожа на обычную атаку переполнения стека, но с одним ключевым отличием —в случае ret-to-libc адрес возврата перезаписывается адресом памяти, который указывает на системную функцию из библиотеки libc (а не адресом шелл-кода), так что, когда переполненная функция возвращается, уязвимая программа вынуждена перейти к функции system() и выполнить команду оболочки, которая была передана функции system() в качестве аргумента как часть предоставленного шелл-кода.
На рисунке ниже показан пример состояния стека до и после эксплуатации уязвимости.
В данном случае уязвимая программа должна вызывать оболочку /bin/sh, для этого она должна вызывать программный вызов system("/bin/sh").
В переполненном буфере можно отметить следующие моменты:
EIP перезаписывается адресом функции system(), расположенной внутри libc.
Сразу после адреса system() идёт адрес функции exit(), так что после возврата system() уязвимая программа переходит к exit(), которая также находится в libc, так что уязвимая программа может корректно завершиться.
Сразу после адреса exit() находится указатель на область памяти, содержащую строку /bin/sh — аргумент, который необходимо передать функции system()
Таким образом, содержимое стека сверху вниз включает в себя:
адрес строки /bin/sh;
адрес функции exit();
адрес функции system().
Здесь необходимо вспомнить, что происходит со стеком при вызове функции:
аргументы функции помещаются в стек в обратном порядке, то есть крайний левый (первый) аргумент помещается последним;
вводится адрес возврата, сообщающий программе, куда вернуться после завершения функции;
проталкивается EBP;
проталкиваются локальные переменные.
По сути, был построен произвольный кадр стека для вызова функции system():
Был отправлен адрес, содержащий строку /bin/sh — аргумент для вызова system().
Также был указан адрес возврата, на который уязвимая программа перейдёт после завершения вызова system(), что в данном случае является адресом функции exit().
Атаки возврата в библиотеку имеют ряд ограничений.
На 32-разрядных машинах x86 аргументы можно контролировать, поскольку они помещаются в стек. Однако на 64-битных машинах, так как аргументы функций передаются через регистры, атаки с возвратом в libc не сработают.
Злоумышленник может использовать только те функции, которые присутствуют в сегменте кода или коде библиотеки, что ограничивает возможности атаки.
Аргументы, передаваемые злоумышленником, могут содержать байты NULL. Однако, если причиной переполнения буфера является такая функция, как strcpy(), то она завершается, когда встречает нулевые байты. Тогда полезная нагрузка атаки return-to-libc не должна будет содержать байты NULL, поэтому такой эксплуатации не будет [1].
Return-oriented programming (ROP)
ROP — это форма атаки с повторным использованием кода, которая позволяет выполнять код при наличии бита NX, а также не имеет упомянутых выше ограничений атаки с возвратом в библиотеку.
ROP использует небольшие последовательности инструкций, называемые гаджетами, вместо полной функции, чтобы преодолеть ограничения атаки возврата к libc. Эти гаджеты определяются как короткие последовательности инструкций, которые объединяются для выполнения различных высокоуровневых задач. При использовании ROP вообще нет необходимости вызывать функцию, используются только небольшие наборы инструкций (два или три), которые не имеют ни пролога процедуры, ни эпилога.
Ниже приведен пример гаджета.
pop rdi;
ret
Короткая последовательность инструкций гаджета должна быть допустимой последовательностью инструкций с инструкцией возврата в качестве последней, что заставит ЦП перейти к следующему гаджету или полезной нагрузке. Как правило, при запуске атаки злоумышленник перезаписывает сохранённый адрес возврата в стеке кодовым указателем, который переходит к первому гаджету.
Рисунок ниже даёт общее представление о ROP-атаке, которая используется для получения оболочки на машине-жертве. Прежде всего, злоумышленник отправляет определённое количество букв A, а затем перезаписывает адрес возврата функции указателем, указывающим на первый гаджет. Когда вызываемый объект выполняет инструкцию возврата, поток управления перенаправляется к первому гаджету, а стек указатель увеличивается на восемь (на 64-битной машине), где он начинает указывать на значение, находящееся на вершине стека, что в нашем случае является адресом «/bin/sh». После этого выполнится первая инструкция первого гаджета, например, инструкция pop rdi вытолкнет данные из вершины стека (/bin/sh/) и поместит их в регистр rdi, а указатель стека увеличит на восемь. Затем инструкция ret перенаправит поток управления на второй гаджет, прочитав следующий указатель кода, указывающий на функцию system(). Когда выполняется system(), она проверяет свой первый аргумент, то есть содержимое регистра rdi, которым является /bin/sh, тем самым порождая новый шелл.
Способы защиты от бинарных уязвимостей
Существуют различные методы защиты от бинарных уязвимостей как на аппаратном, так и на программном уровне. К самым распространённым относятся бит NX, рандомизация адресного пространства, перемещение только для чтения (RELRO) и другие. Самым распространённым методом защиты от переполнения буфера является канарейка.
No eXecute (NX Bit)
Бит NX-Bit (no execute bit в терминологии фирмы AMD) или XD-Bit (execute disable bit в терминологии фирмы Intel) — бит запрета исполнения, добавленный в сегмент памяти для предотвращения выполнения данных как кода.
Архитектура фон Неймана, используемая во всех основных микропроцессорах, позволяет хранить код и данные в одном и том же пространстве памяти. Доступны следующие три параметра для установки прав доступа для определённого пространства: неисполняемый (NR), исполняемый для чтения (RX) и исполняемый для чтения с возможностью записи (RWX). Таким образом, если установлен бит чтения, то страница также будет исполняемой, и это основная причина, делающая возможными атаки с внедрением кода [1].
Бит NX может быть внедрён в аппаратную часть, чтобы убрать разрешения на выполнение из областей памяти, содержащих данные. Благодаря поддержке бита NX операционные системы могут помечать определённые области памяти (куча, стек) как неисполняемые.
На рисунке ниже изображена запись бита NX (номер 63) в записи таблицы страниц x86. Код может быть выполнен, если бит NX установлен в ноль (0) для конкретной страницы; если он установлен в единицу (1), то это неисполняемая страница, содержащая только данные.
Некоторые типы атака, например, атака возврата в библиотеку, позволяют обойти защиту с помощью бита NX.
Рандомизация адресного пространства (ASLR)
Для ROP необходимо знать адреса гаджетов. Существует метод защиты адресного пространства, который затрудняет атаки такого рода. Это рандомизация адресного пространства.
ASLR рандомизирует базовые адреса различных разделов процесса, включая стек, кучу, разделяемые библиотеки и исполняемые файлы. Поэтому злоумышленник не может каждый раз использовать один и тот же эксплойт для эксплуатации одной и той же уязвимой программы, он скорее должен использовать явную полезную нагрузку для каждого появления рандомизированной программы.
ASLR в основном состоит из случайного распределения основных частей процесса (исполняемой базы, указателей стека, библиотек и т. д.) в адресное пространство памяти, которое было назначено ему операционной системой. Таким образом, злоумышленник никогда не будет знать наверняка, как получить доступ к функции, и не сможет её использовать. Однако существуют способы обхождения и этого метода защиты, такие как атака Heap spraying.
Канарейка (Stack Canary)
Канарейка (stack canary) — метод защиты стека от переполнения. Канарейка — это секретное значение, помещаемое в стек, которое изменяется при каждом запуске программы. Перед возвратом из функции проверяется канарейка стека, и, если она оказывается изменённой, то программа немедленно завершает работу.
Такой метод кажется очевидным способом смягчить уязвимость стека, поскольку практически невозможно просто угадать случайное 64-битное значение. Однако утечка адреса и брутфорс канарейки — это два метода, которые позволяют пройти проверку канарейки [24].
В первом случае можно использовать атаку форматной строки, чтобы прочитать значение канарейки, а потом перезаписать канарейку обратно в буфер при переполнении стека.
На 32-битных устройствах можно произвести брутфорс значения канарейки, но это занимает определённое время и не очень удобно. Но возможность обхода проверки таким образом существует.
Позиционно-независимый исполняемый файл
Инструкции машинного кода, хранящиеся в основной памяти, известны как позиционно -независимый код (PIC), который может выполняться правильно независимо от адреса. Как правило, общие библиотеки компилируются в виде PIC-файлов, поэтому они могут совместно использоваться несколькими процессами независимо друг от друга. Это облегчает реализацию рандомизации для каждого процесса через ASLR. Для каждого процесса загружаются разные библиотеки PIC. В отличие от абсолютного кода, который должен быть загружен в определённую ячейку памяти, PIC может быть загружен в несколько ячеек памяти без каких-либо изменений. Инструкции, относящиеся к определённому местоположению, выполняются быстрее, чем инструкции, адресованные по относительным адресам, однако на современных процессорах разница незначительна. Код, который не зависит от позиции, легко рандомизируется.
Двоичный файл, сгенерированный компилятором как позиционно-независимый код, известен как позиционно-независимый исполняемый файл (PIE). Он предоставляет произвольные базовые адреса для различных разделов исполняемого двоичного файла. PIE реализует ту же стратегию случайности для исполняемых файлов, аналогичную той, которая используется для общих библиотек, и затрудняет использование кода злоумышленниками. Если двоичный файл компилируется как исполняемый файл, не зависящий от позиции, основной двоичный файл также рандомизируется. PIE дополняет ASLR для предотвращения атак.
Параметр -pie используется при компиляции программы с помощью GCC, чтобы сделать двоичный файл независимым от позиции исполнения.
Перемещение только для чтения (RELRO)
Relro (перемещение только для чтения) влияет на права доступа к памяти аналогично NX. Разница в том, что NX делает стек исполняемым, а RELRO делает некоторые вещи доступными только для чтения.
Глобальная таблица смещений (GOT) используется
для разрешения динамически связанных функций разделяемых библиотек. Таблица
компоновки процедур содержит заглушку перехода к GOT и находится в разделе .plt [1].
Раздел .plt используется для хранения инструкций, указывающих на GOT, и находится в разделе .got.plt. Когда функция разделяемой библиотеки вызывается в первый раз, GOT указывает обратно на PLT, и выполняется вызов динамического компоновщика, который находит фактический адрес этой функции. После нахождения адреса функции он записывается в GOT.
При повторном вызове GOT уже содержит адрес. Примечательным моментом является то, что PLT должен находиться в фиксированном месте из раздела .text, GOT должен находиться в известном месте, поскольку он содержит информацию, необходимую для программы и GOT должен быть доступен для записи и для выполнения отложенного связывания. Поскольку GOT доступен для записи и находится в фиксированном месте, его можно использовать для запуска атак переполнения буфера. Таким образом, для предотвращения эксплуатации этой уязвимости требуется, чтобы все адреса были разрешены в начале, а затем помечены GOT как доступные только для чтения.
RELRO — это метод защиты, который, как правило, делает таблицу глобальных смещений доступной только для чтения, поэтому методы перезаписи GOT нельзя использовать во время эксплуатации переполнения буфера. Он имеет два уровня защиты: частичный и полный. Частичный RELRO делает раздел .got доступным только для чтения (но не раздел .got.plt), благодаря чему возможна перезапись GOT . Полный RELRO делает весь раздел .got доступным только для чтения, включая раздел .got.plt. Таким образом, любая технология перезаписи GOT запрещена.
В случае с бинарными уязвимостями совершенствуются пути защиты, создаются новые аппаратные и программные методы снижения уязвимостей. Но при этом возникают новые пути обхода методов защиты, новые атаки и находятся новые уязвимости. Поэтому работа по поиску проблем безопасности не должна быть прекращена в связи с выходом обновления, выпуском рекомендаций от разработчиков и даже с применением новых методов защиты.
Источники
Butt, Muhammad Arif & Ajmal, Zarafshan & Khan, Zafar Iqbal & Idrees, Muhammad & Javed, Yasir. (2022). An In-Depth Survey of Bypassing Buffer Overflow Mitigation Techniques. Applied Sciences. 12. 6702. 10.3390/app12136702.
course.ugractf.ru/reverse/hard.html
https://www.guru99.com/difference-compiler-vs-interpreter.html
https://kodebinary.com/c-program-compilation-process
Maxim Demidenko; Alzhan Kaipiyev; Maryam Var Naseri; Yogeswaran A/L Nathan; Nor Afifah Binti Sabri. UNDERSTANDING OF PE HEADERS AND ANALYZING PE FILE. J Arch.Egyptol 2020, 17, 8611-8620.
Kyi, Wai & Koide, Hiroshi & Sakurai, Kouichi. (2019). Analyzing the Effect of Moving Target Defense for a Web System. International Journal of Networking and Computing. 9. 188-200. 10.15803/ijnc.9.2_188.
https://vseloved.github.io/pdf/exe-ru.pdf
Xu, Jianyun & Sung, Andrew & Mukkamala, Srinivas & Liu, Qingzhong. (2007). Obfuscated Malicious Executable Scanner. Journal of Research and Practice in Information Technology. 39.
https://tproger.ru/articles/memory-model
https://stackoverflow.com/questions/41120129/java-stack-and-heap-memory-management
https://www.acunetix.com/blog/web-security-zone/what-is-buffer-overflow/
https://ru.wikipedia.org/
Younan, Yves & Joosen, Wouter & Piessens, Frank. (2006). Efficient Protection Against Heap-Based Buffer Overflows Without Resorting to Magic. 4307. 379-398. 10.1007/11935308_27.
https://owasp.org/www-community/vulnerabilities/Buffer_Overflow
https://owasp.org/www-community/vulnerabilities/Doubly_freeing_memory
https://learn.microsoft.com/ru-ru/cpp/sanitizers/error-double-free?view=msvc-170
https://lvk.cs.msu.ru/~dimawolf/SoftwareReliability/Lection11.pdf
https://xakep.ru/2019/11/21
https://cwe.mitre.org/data/definitions/416.html
https://owasp.org/www-community/attacks/Format_string_attack
https://cwe.mitre.org/data/definitions/190.html
https://www.ired.team/offensive-security/code-injection-process-injection/binary-exploitation/return-to-libc-ret2libc
https://www.daniloaz.com/
https://ir0nstone.gitbook.io/notes/types/stack/canaries
https://www.oreilly.com/
https://lvee.org/be/abstracts/280
https://pacman.fandom.com/wiki/Map_256_Glitch
https://www.gamedeveloper.com/programming/diablo-iii-economy-broken-by-an-integer-overflow-bug
Рисунок в начале статьи создан с помощью нейросети fusionbrain.ai