Статья Способы защиты от переполнения буферов

Переполнению буферов подвержены все серьёзные приложения – уязвимости с завидной регулярностью обнаруживаются как в продукции Microsoft, так и в софте энтузиастов. Сколько ошибок не выявлено остаётся только гадать, но чтобы ими воспользоваться, нужно проделать длинный путь. По степени накала страстей, поиск переполняющихся буферов напоминает поиск кладов – его практически невозможно автоматизировать. Трясти следует в первую очередь те буфера, на которые вы можете хоть как-то воздействовать (обычно это связанные с клавиатурным вводом буфы).

1. Источник угрозы​
2. Переполнение буферов в стеке​
3. Переполнение буферов в куче​
4. Уязвимость в спецификаторах printf()​
5. Выводы​



1. Источник угрозы

Из имеющих уязвимости переполнения буфера языков, ключевыми являются C и C++, поскольку их рантайм msvcrt.dll работает с памятью более небрежно, чем многие интерпретируемые языки. Но даже если код и написан на безопасном Python, он всё-равно использует написанные на сишке подключаемые модули, а потому по прежнему может быть уязвим. Чтобы понять природу переполнения, нужно разобраться с выделением памяти в программе – в официальных источниках оно числится как «Memory Allocation».

Как правило память под буфер можно выделить или в стеке (резервируется во время компиляции), или-же в куче «Heap» уже во время выполнения. Атаки на стек встречаются чаще, т.к. при вызове функции в нём всегда лежит «адрес возврата» обратно к вызывающему, модифицировав который можно передать управление на произвольный участок кода. Поскольку куча не хранит Return-адресов, то здесь гораздо сложнее запустить свой сплойт или шелл – эта память содержит лишь данные программы. Однако куча имеет тенденцию расширяться динамически, и если прогер использовал это свойство вызовом функции realloc(), то перехват управления всё-таки возможен, о чём пойдёт речь далее. В силу того, что переполнение буферов в стеке представляет наибольший интерес, рассмотрим как именно оно работает.


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

Причина, по которой «BufferOverflow» стало серьёзной проблемой заключается в отсутствии проверки границ во многих функциях управления памятью C и C++. Обычно пользовательский буфер переполняется, когда код зависит от внешних входных данных (например ввод с клавиатуры), или когда он имеет зависимости за пределами прямой видимости стекового фрейма, который отображён на рисунке ниже (архитектура IA32). Рассмотрим запрашивающий имя следующий код на ассемблере FASM:

C-подобный:
format   pe console
include 'win32ax.inc'
;//-------
.code
main:  stdcall  myProc,0x10,0   ;// Вызов процедуры!
       cinvoke  getch
       cinvoke  exit,0

;//------ ПРОЦЕДУРА -----------------
proc  myProc  arg1,arg2
locals
  var1  dd  0x12345678  ;//<--- Локальные переменные
  var2  dd  0x9ABCDEF0
endl
local   buff[32]:BYTE   ;//<--- Буфер в локальной памяти

       cinvoke  gets,addr buff   ;// Уязвимая функция
       cinvoke  printf,<'Hello, %s',0>,addr buff
        ret
endp
;//-------
section '.idata' import data readable
library  msvcrt,'msvcrt.dll'
import   msvcrt, printf,'printf',gets,'gets',\
                 getch,'_getch',exit,'exit'

Для вызова процедур и функций предусмотрена инструкция ассемблера CALL, которая неявно (т.е. аппаратно где-то под катом) сначала помещает в стек адрес-возврата в виде следующей за собой инструкции, и лишь потом передаёт управление на вход в функцию. В свою очередь, выход из отработанной функи осуществляется инструкцией RET, которая снимает адрес-возврата со стека и переходит по нему, продолжая тем самым программу. Таким образом, инструкции call и ret всегда ходят парой. Так будет выглядеть стек на входе в представленную выше процедуру «myProc»:

StackFrame.webp

Проблема в том, что большинство функций рантайма для хранения временных значений всегда используют локальную память функции именно в стеке, что влечёт за собой катастрофические последствия. С одной строны это удобно, т.к. на выходе из функции компилятор сам очищает лок.память предотвращая её утечку (не нужен постоянный контроль). Но с другой стороны программист не может заранее предугадать, буфер какого именно размера потребуется в стековой памяти, и резервирует разумно-ограниченное её кол-во как на рис.выше (пусть будет) 32 байта. Например если код запрашивает имя юзера, оно может иметь длину 4-байта «Вася», а может принадлежать Лоуренсу Уоткинсу с длинною в 2.253 слов. Здесь уже прогерам приходится садиться на шпагат, и выбирать золотую середину.

Как результат, ввод 44-байтной строки в выделенный зелёным лок.буфер приведёт к тому, что мы пробьём дно буфера 0-31, и затерём сначала обе переменные, а затем и адрес-возврата из функции, подсунув вместо него указатель на свой шелл. Единственная проблема здесь в том, как из копируемой в буфер строки сформировать фиктивный адрес-возврата, т.к. клавиатурный ввод поддерживает только ASCII-коды печатных символов, и создать из них HEX-адрес не так просто (ситуацию усугубляет, если в адресе присутствует двоичный нуль). Однако при наличии смекалки и эту проблему можно решить (см.ниже).

Чтобы предотвратить модификацию адреса-возврата при переполнении буфера, инженеры Microsoft предлагают нам 2 основных решения – это замена уязвимой функции gets() на безопасную и способную ограничивать длину ввода fgets(), а так-же вставку перед адресом-возврата контрольного слова «Canary word», что на жаргоне звучит как «Канарейка». Это может быть любое значение размером DWORD, главное чтобы оно было уникальным при каждом вызове функции (например текущее время, или такты процессора rdtsc), иначе взломщик сможет легко найти и восстановить его. Теперь на выходе из функции мы сможем проверить значение канарейки и если оно изменилось, возможно это попытка переполнения буфа в стеке взломщиком. Позиция контрольного слова выделена на рис.ниже жёлтым:

Canary.webp


3. Переполнение буферов в куче

Куча «Heap» – это область динамической памяти программы для выделения блоков произвольного размера. Для временных буферов рекомендуется выделять память именно в куче, а не в стеке, т.к. в ней будет отсутствовать уязвимый адрес-возврата. Тогда какой интерес представляет куча для взломщиков? Чтобы ответить на этот вопрос, нужно рассмотреть способ динамического распределения памяти.

В основе управления хипом лежит функция malloc(), и сопутствующие ей: calloc(), realloc(), и free().

void * malloc ( size ) ;
Выделяет Size-байт из кучи, и возвращает указатель на выделенный блок, или нуль в случае ошибки.

void * calloc ( size, num ) ;
Аналогично malloc(), только забивает выделенный блок памяти нулями, чего не делает предыдущая.
Size = размер блока, Num = их кол-во. Эти аргументы можно переставить местами.

void * realloc ( ptr, size ) ;
Позволяет динамически изменить размер выделенного блока.
Ptr = указатель на выделенную ранее память, Size = новый размер блока.

void free ( ptr ) ;
Освобождает выделенную malloc(), calloc(), или realloc() память.
Ptr = указатель на блок, который необходимо освободить.


Суть в том, что при выделении памяти с помощью malloc(), диспетчер вообще не обращается к ядру ОС – вместо этого используются «Метаданные» для отслеживания свободных и занятых участков, которые располагаются там-же, сразу за выделенным блоком памяти. Эти метаданные использует затем функция realloc(), когда потребуется динамически расширить или освободить приёмный буфер.

Сами метаданные хранят всего 2 указателя для организации связанного списка – это линк на метаданные предыдущего свободного блока памяти, и линк на метаданные следующего блока. Чтобы разобраться с форматом служебных данных, можно последовательно запросить 2 блока памяти подряд, и посмотреть на результат в отладчике:

C-подобный:
main:  cinvoke  calloc,32,1  ;// Выделить 2 блока,
       push     eax          ;// ...по 32 байта.
       cinvoke  calloc,32,1

       cinvoke  free,eax     ;// Освободить их!
       pop      eax
       cinvoke  free,eax

@exit: cinvoke  getch
       cinvoke  exit,0

При первом вызове функция calloc() вернула мне адрес выделенного блока 0x005D0E78, где я обнаружил забитый нулями буфер (выделен жёлтым), дальше 2 дворда назначение которых я так и не понял (возможно это выравнивание Padding), и в хвосте ещё 2 дворда метаданных (выделены зелёным). Судя по хранящихся в этих ячейках данным, предыдущий блок имеет адрес 0x005D00C4, а следующий свободный должен распологаться по адресу 0x005D0EA0:

calloc1.webp

Значит продолжаем трейс в отладчике и зовём второй раз calloc().. Хм, и точно аллокатор выделил нам 32-байтный блок в памяти хипа по адресу 0x005D0EA0, в хвосте которых так-же маячат метаданные.

calloc2.webp

Таким образом, атака на переполнение буфера в куче подразумевает мод именно метаданных. Если вместо указателей на «Backward/Forward Block» мы сможем подсунуть линк на свой шелл, то при сл.вызове calloc() или realloc() он получит управление! При поиске уязвимостей в коде, здесь можно искать не только вызовы malloc/calloc() с последующим вводом с клавиатуры gets(), но и функции копирования блоков из одного места в другое memcpy().

Реализация переполнения буферов в куче требует тщательного разбора метаданных, а потому этот способ не получил широкого распространения. Единственно возможный вариант защиты от атак подобного рода является ввод с ограничением длины fgets(), вместо дырявой как сито gets().


4. Фиктивные спецификаторы функции printf()

Команды «peek» и «poke» использовались в Basic для доступа к содержимому ячейки памяти – «peek» читает байт по указанному адресу, а «poke» записывает. Термины устарели, но по привычке используются программистами для обозначения доступа к памяти в целом. Для начала посмотрим на список функций из либы msvcrt.dll (Microsoft VisualC Runtime), которые представляют для нас особый интерес:

Printf.webp

Известно, что функции printf() + scanf() первым аргументом ожидают спецификатор ввода/вывода. Например такая конструкция будет расценивать клавиатурный ввод как строку: scanf('%s',*buff), а printf('%X',var) распечатает второй аргумент в виде HEX-числа.

Проблема в том, что эти функции страдают классическим недержанием кол-ва переданных им спецификаторов, что является огромной дырой в подсистеме безопасности. Они могут принимать произвольное кол-во спецификаторов, и этот факт позволяет нам навязывать фиктивные, по своему усмотрению. Рассмотрим такой пример, где функция gets() читает строку в приёмный буфер(in), далее sprintf() копирует эту строку в буфер вывода(out), и наконец printf() распечатывает содержимое выходного буфа:

C-подобный:
format   pe console
include 'win32ax.inc'
entry    main
;//-------
.data
in_buff    rb  32   ;// Буф для ввода,
out_buff   rb  32   ;// ..и для вывода.

;//-------
.code
main:  stdcall  myProc,0x10
       cinvoke  getch
       cinvoke  exit,0

;//----- ПРОЦЕДУРА -----------
proc  myProc param1
locals
  var1  dd  0x12345678   ;// Лок.переменные
  var2  dd  0x9ABCDEF0
endl
       cinvoke  printf,<'Name: ',0>
       cinvoke  gets,in_buff
       cinvoke  sprintf,out_buff,<'Hello, %s!',0>,in_buff
       cinvoke  printf,out_buff
        ret
endp
;//-------
section '.idata' import data readable
library  msvcrt,'msvcrt.dll'
import   msvcrt, printf,'printf',sprintf,'sprintf',\
                 getch,'_getch',exit,'exit',gets,'gets'

..компилим, запускаем, всё исправно работает.

p1.webp


Теперь посмотрим на реакцию функции sprintf(), если на запрос «Name» навязать ей несколько HEX-спецификаторов (%08Х указывает расширить число до 8 знаков):

p2.webp


Как видим, глупая функция приняла наш ввод за свои спецификаторы, в результате чего прихватила с собой 5 следующих двордов из стека – первые(2) это лок.переменные см.исходник, третий это значение сохранённого на входе в процедуру регистра EBP, четвёртый дворд есть ничто иное как адрес-возврата из процедуры, и наконец последний это аргумент вызова stdcall (0x10).

Таким образом, взяв в заложники спецификаторы функции printf(), мы можем распечатать дамп памяти как стека так и кучи, чтобы проанализировав найти, например, смещение адреса-возврата в стеке, или произвольного указателя в куче. Другими словами получили аналог 'peek' бейсика. Мотаем это на ус...

Ладно, будем считать, что с чтением памяти определились. Но на практике интерес представляет запись, чтобы модифицировать, например, всё тот-же адрес-возврата. Примечательно, что printf() способна не только печатать вывод в stdout, но и осуществлять (!)запись по указанному в аргументе адресу, для чего предусмотрен спецификатор %n. Поскольку мод буфера осуществляется уже после вывода его на экран, доказательства перезаписи спецификатором(%n) приходится добывать в отладчике. Так мы получим аналог 'poke', детали смотри .

Ну и конечно классический вариант перезаписи ячеек функцией gets(buff) или scanf('%s',*buff).
Если функция внутри процедуры запрашивает ввод текстовой строки, то не прибегая к лишним усилиям мы можем подсунуть ей HEX-значения в формате ASCII-кодов. Когда с комбинацией Alt мы вводим 10-тичные значения на цифровой клавиатуре, нам становится доступен весь диапазон от 00h до FFh. Саму таблицу можно взять например .

Рассмотрим это дело на таком примере, где запрашивается строка со-спецификатором(%s), но для демонстрации мы распечатаем её в HEX.

C-подобный:
       cinvoke  printf,<'Name: ',0>
       cinvoke  gets,in_buff
       cinvoke  sprintf,out_buff,<'Hello, %08X',0>,in_buff
       cinvoke  printf,out_buff

Допустим нам нужен адрес 0x0014E2F4, тогда учитывая обратный порядок байт «Little Endian» в процессорах х86 получаем 0xF4E21400. Значит вводим последовательность 244 226 20 с зажатой Alt, и вот он нужный указатель:

p3.webp



5. Выводы

В своей массе, переполнению буферов подвержены только консольные приложения, которые импортируют функции из либы msvcrt.dll (хидер stdio.h). Однако и в оконных приложениях довольно часто встречается WinAPI wsprintf() из библиотеки user32.dll, которая осуществляет форматированный по спецификаторам вывод в буфер. Если таковая обнаружится в софте, это может послужить потенциальным признаком данного рода уязвимости.

Ну и под занавес вот несколько рекомендаций для защиты от переполнений буферов в стеке и куче:

1. Используйте только безопасные функции ввода с ограничением длины типа fgets(), а ещё лучше WinAPI Read/WriteConsole() из либы Kernel32.dll.​
2. Используйте «Canaries Word», которые вставляют контрольное слово перед адресом-возврата в стеке, и проверяются перед обращением к нему.​
3. Не используйте локальные буферы в стеке, а выделяйте для них память из кучи.​
4. Собирайте свои исходники с флагом ASLR (рандомизация адресного пространства), чтобы при каждой загрузке системы менялись адреса базы загрузки образа в память, и соответственно адрес его стека.​
5. Сделайте стек неисполняемым установив бит NX (No-eXecute), чтобы плохой парень не вставил шелл-код непосредственно в стек. Для этого нужно включить DEP (Data Execution Protect) в свойствах системы. По умолчанию DEP включён только для системных приложений, так-что нужно взвести галку «..для всех».​

dep.webp
 
  • Нравится
Реакции: Edmon Dantes и yetiraki
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы