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

Переполнение буфера стека (англ. stack buffer overflow, также stack buffer overrun) — это разновидность нарушения границ памяти, возникающая, когда программа записывает данные в область памяти на стеке вызовов программы вне границ предназначенной для этого структуры данных, как правило, буфера фиксированной длины[1][2]. Ошибки, связанные с переполнением буфера стека, возникают, когда программа записывает в стековый буфер больше данных, чем ему выделено изначально. Это практически всегда приводит к повреждению соседних данных на стеке, а при ошибочном переполнении — к сбою работы программы или неправильному её функционированию. Переполнение буфера стека — частный случай более общей программной ошибки, известной как переполнение буфера[1]. Перезапись буфера в стеке чаще приводит к нарушению работы программы, чем аналогичная ошибка в куче, так как в стеке хранятся адреса возврата всех активных вызовов функций.

Переполнение буфера стека может быть вызвано намеренно в рамках атаки, известной как разрушение стека. Если уязвимая программа выполняется с особыми привилегиями или принимает данные от ненадёжных внешних источников (например, от веб-сервера), такая ошибка становится уязвимостью безопасности. Если стековый буфер заполняется данными, поступающими от ненадёжного пользователя, этот пользователь может исказить стек таким образом, чтобы внедрить исполняемый код в работающую программу и получить контроль над процессом. Это одна из старейших и наиболее надёжных техник получения злоумышленниками несанкционированного доступа к компьютеру[3][4].

Использование переполнения буфера стека в атаках

Классическим способом эксплуатации переполнения буфера в стеке является перезапись адреса возврата функцией значением, определяемым злоумышленником, обычно указывающим на данные самого атакующего (часто — на тот же стек)[5]. Пример данной ситуации с функцией strcpy() показан ниже:

#include <string.h>

void foo(char* bar) {
    char c[12];

    strcpy(c, bar); // отсутствие проверки границ буфера
}

int main(int argc, char* argv[]) {
    foo(argv[1]);
    return 0;
}

Эта программа читает аргумент командной строки и копирует его в локальный стековый массив c. Если длина аргумента не превышает 11 символов (см. рисунок B ниже), программа работает корректно. При превышении этого значения происходит повреждение стека. (Максимальная безопасная длина строки на один байт меньше размера буфера, так как в языке C строки завершаются нулевым байтом. Таким образом, 12-символьный ввод требует хранения тринадцати байт: 12 символов плюс завершающий нуль-байт, который в результате обратится за границы отведённого массива.)

Вид стека функции foo() при разных входных данных:

A. — До копирования данных.
B. — В качестве аргумента командной строки передаётся «hello».
C. — В качестве аргумента командной строки передаётся «AAAAAAAAAAAAAAAAAAAA\x08\x35\xC0\x80».

На рисунке C, когда программе передаётся аргумент длиннее 11 байт, функция foo() перезаписывает локальные переменные на стеке, сохранённый указатель на предыдущий фрейм стека и, что особенно важно, адрес возврата. При выходе из foo() возвращаемый адрес извлекается из стека и передаёт управление указанному адресу. Таким образом, злоумышленник получает возможность записать в адрес возврата указатель на контролируемый им стековый буфер char c[12], содержащий произвольные данные. В реальной атаке вместо «A» будут размещены подходящие шелл-коды, соответствующие платформе и цели атаки. Если бы у этой программы были специальные привилегии (например, установлен бит SUID для запуска от имени суперпользователя), злоумышленник смог бы получить привилегии суперпользователя.

Злоумышленник может также изменить значения внутренних переменных для эксплуатации некоторых багов. Пример:

#include <stdio.h>
#include <string.h>

void foo(char* bar) {
    float myFloat = 10.5; // Адрес = 0x0023FF4C
    char c[28];           // Адрес = 0x0023FF30

    // Выведет 10.500000
    printf("myFloat value = %f\n", myFloat);

    /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       Карта памяти:
       @ : область c
       # : область myFloat

           *c                      *myFloat
       0x0023FF30                  0x0023FF4C
           |                           |
           @@@@@@@@@@@@@@@@@@@@@@@@@@@@#####
      foo("my string is too long !!!!! XXXXX");

    memcpy запишет 0x1010C042 (little endian) в значение myFloat.
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/

    memcpy(c, bar, strlen(bar));  // отсутствует проверка границ...

    // Выведет 96.031372
    printf("myFloat value = %f\n", myFloat);
}

int main(int argc, char* argv[]) {
    foo("my string is too long !!!!! \x10\x10\xc0\x42");
    return 0;
}

Существует обычно два способа изменения сохранённых на стеке адресов — прямой и косвенный. Для обхода защит, направленных против прямых атак, злоумышленники разработали методы косвенного воздействия, характеризующиеся меньшей зависимостью от среды[6].

Архитектурные особенности

Различные аппаратные платформы могут иметь тонкости в реализации стека вызовов, влияющие на способы эксплуатации переполнения буфера стека. Некоторые архитектуры сохраняют адрес возврата верхнего уровня в регистре, а не на стеке, поэтому перезаписанный адрес возврата не будет использован до более позднего этапа развёртывания стека. Ещё одно архитектурное различие — невозможность невыравненного доступа к памяти во многих процессорах с архитектурой RISC[7], что вместе с фиксированной длиной машинных команд может практически исключить переход к коду на стеке (за исключением специально реализованного перехода к стековому регистру)[8][9].

Растущие вверх стеки

В рамках обсуждения переполнений буфера стека часто приводятся архитектуры, в которых стек растёт в противоположном направлении. Такие архитектуры иногда рассматривают как потенциальное решение проблемы переполнения буфера стека, поскольку переполнение буфера, произошедшее в том же фрейме стека, не затрагивает указатель возврата. Однако переполнение буфера из предыдущего фрейма всё же может привести к перезаписи адреса возврата и позволить эксплуатации уязвимости[10]. Например, в показанном выше примере указатель возврата для foo не будет перезаписан из-за переполнения в фрейме memcpy, однако если переполнение возникло при выделении буфера из предыдущего фрейма, адрес возврата использованной функции будет иметь большее адресное значение, и он окажется затронут. Таким образом, изменение направления роста стека только модифицирует детали эксплуатации, но не уменьшает число потенциально уязвимых случаев.

Методы защиты

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

  • Обнаружение факта переполнения буфера в стеке, что позволяет предотвратить переход управления к вредоносному коду.
  • Запрет выполнения кода из области стека даже без явного обнаружения переполнения буфера.
  • Рандомизация расположения памяти, чтобы сделать надёжный поиск исполняемого кода маловероятным.

Канарейки стека

Технология «canary» (канарейка) для защиты от переполнения буфера стека названа по аналогии с канарейками в угольных шахтах. В память непосредственно перед указателем возврата помещается случайно выбранное значение (канарейка), и большинство переполнений буфера (уходящих по адресам вверх) неизбежно затирают это значение. Перед использованием указателя возврата (перед возвратом из функции) программа проверяет сохранность значения канарейки[2]. Эта техника существенно усложняет эксплуатацию переполнения буфера, так как злоумышленнику необходимо получить контроль над программой, не затрагивая соответствующую область памяти или вмешиваясь в другие критические переменные[2].

Неисполняемый стек

Альтернативный подход — наложить политику неисполняемости на область памяти стека (принцип W^X — запись XOR исполнение). Тогда для запуска шелл-кода из стека злоумышленнику необходимо либо снять защиту с памяти, либо поместить полезный код в незащищённую область (например, в кучу). Эта техника получила широкое распространение с появлением аппаратной поддержки запрета исполнения (no-execute flag) в большинстве современных процессоров.

Хотя этот механизм защищает от классических атак типа stack smashing, существуют иные приёмы эксплуатации стековых переполнений. Например, злоумышленники могут размещать шелл-код в незащищённых участках памяти, либо использовать технику return-to-libc (возврат к библиотечному коду), при которой в стек помещается не шелл-код, а корректная последовательность адресов вызова стандартных библиотечных функций с целью отключения защиты и последующего запуска шелл-кода[11][12].

Вариацией вышеуказанной техники является программирование, ориентированное на возврат (ROP): злоумышленник строит цепочку адресов возврата, где каждая «шестерёнка» (gadget) состоит из нескольких подобранных команд программы или системных библиотек, завершающихся инструкцией возврата.

Также возможно применение returnless-ROP — использование последовательностей команд, аналогичных по действию возврату[13].

Рандомизация

Другой подход защиты — внедрение случайности (рандомизации) в адресное пространство выполняемой программы, чтобы злоумышленник не мог надёжно определить местоположение кода. На практике рандомизируется не всё: сама программа часто загружается по фиксированному адресу, и при использовании рандомизации адресов злоумышленник всё ещё может использовать статическую область. Для защиты требуется компиляция программ с поддержкой позиционно-независимых исполняемых файлов (PIE). Критически важна энтропия рандомизации — слишком низкая энтропия даёт возможность подбора правильного адреса перебором.

Обход защит

Перечисленные выше защитные меры усложняют процесс эксплуатации. Тем не менее, при наличии иных уязвимостей или определённых условий переполнение буфера стека может быть использовано[14].

Утечка информации через эксплуатацию ошибки форматирования строки

Используя уязвимость форматной строки, злоумышленник может получить информацию о расположении памяти в процессе[15].

Обход неисполняемого стека

При включённой запрещающей исполняемость данных атакующий может использовать перезаписанный адрес возврата для выполнения кода в сегмент кода (например, .text в Linux) либо в другой исполняемой области программы, то есть повторно использовать существующий код[16].

ROP-цепочка

Суть метода — перезаписать указатель возврата так, чтобы после выполнения инструкции возврата (ret в x86) программа начала выполнять инструкции с нужного фрагмента кода, продолжающегося до следующего возврата, после чего управление перейдёт к следующему заданному адресу[16].

JOP-цепочка

Jump Oriented Programming — это техника, использующая последовательности переходов (jump) вместо инструкции возврата (ret)[17].

Обход рандомизации

ASLR на 64-битных системах уязвима для атак с утечкой адресной информации. Получив адрес даже одной функции, злоумышленник может построить ROP-эксплойт[18].

Известные примеры

  • Червь Морриса (1988) распространялся, в частности, за счёт эксплуатации переполнения буфера стека в сервере протокола finger операционной системы Unix[19].
  • SQL Slammer (2003) — использовал переполнение буфера стека в Microsoft SQL Server[20].
  • Червь Blaster (2003) распространялся через переполнение буфера стека в службе Microsoft DCOM.
  • Червь Witty (2004) — эксплуатировал аналогичную уязвимость в Internet Security Systems BlackICE Desktop Agent[21].
  • Для игровой консоли Wii были реализованы эксплойты, позволяющие запускать произвольный код на немодифицированной системе. К ним относится «Twilight hack» (переполнение через длинное имя лошади в игре The Legend of Zelda: Twilight Princess)[22], и «Smash Stack» для Super Smash Bros. Brawl (подгрузка файла через SD-карту в редакторе уровней). Последний чаще всего используется для применения модификаций самой игры[23].

Примечания