Переполнению буферов подвержены все серьёзные приложения – уязвимости с завидной регулярностью обнаруживаются как в продукции Microsoft, так и в софте энтузиастов. Сколько ошибок не выявлено остаётся только гадать, но чтобы ими воспользоваться, нужно проделать длинный путь. По степени накала страстей, поиск переполняющихся буферов напоминает поиск кладов – его практически невозможно автоматизировать. Трясти следует в первую очередь те буфера, на которые вы можете хоть как-то воздействовать (обычно это связанные с клавиатурным вводом буфы).
1. Источник угрозы
Из имеющих уязвимости переполнения буфера языков, ключевыми являются C и C++, поскольку их рантайм msvcrt.dll работает с памятью более небрежно, чем многие интерпретируемые языки. Но даже если код и написан на безопасном Python, он всё-равно использует написанные на сишке подключаемые модули, а потому по прежнему может быть уязвим. Чтобы понять природу переполнения, нужно разобраться с выделением памяти в программе – в официальных источниках оно числится как «Memory Allocation».
Как правило память под буфер можно выделить или в стеке (резервируется во время компиляции), или-же в куче «Heap» уже во время выполнения. Атаки на стек встречаются чаще, т.к. при вызове функции в нём всегда лежит «адрес возврата» обратно к вызывающему, модифицировав который можно передать управление на произвольный участок кода. Поскольку куча не хранит Return-адресов, то здесь гораздо сложнее запустить свой сплойт или шелл – эта память содержит лишь данные программы. Однако куча имеет тенденцию расширяться динамически, и если прогер использовал это свойство вызовом функции
2. Переполнение буферов в стеке
Причина, по которой «BufferOverflow» стало серьёзной проблемой заключается в отсутствии проверки границ во многих функциях управления памятью C и C++. Обычно пользовательский буфер переполняется, когда код зависит от внешних входных данных (например ввод с клавиатуры), или когда он имеет зависимости за пределами прямой видимости стекового фрейма, который отображён на рисунке ниже (архитектура IA32). Рассмотрим запрашивающий имя следующий код на ассемблере FASM:
Для вызова процедур и функций предусмотрена инструкция ассемблера
Проблема в том, что большинство функций рантайма для хранения временных значений всегда используют локальную память функции именно в стеке, что влечёт за собой катастрофические последствия. С одной строны это удобно, т.к. на выходе из функции компилятор сам очищает лок.память предотвращая её утечку (не нужен постоянный контроль). Но с другой стороны программист не может заранее предугадать, буфер какого именно размера потребуется в стековой памяти, и резервирует разумно-ограниченное её кол-во как на рис.выше (пусть будет) 32 байта. Например если код запрашивает имя юзера, оно может иметь длину 4-байта «Вася», а может принадлежать Лоуренсу Уоткинсу с длинною в 2.253 слов. Здесь уже прогерам приходится садиться на шпагат, и выбирать золотую середину.
Как результат, ввод 44-байтной строки в выделенный зелёным лок.буфер приведёт к тому, что мы пробьём дно буфера 0-31, и затерём сначала обе переменные, а затем и адрес-возврата из функции, подсунув вместо него указатель на свой шелл. Единственная проблема здесь в том, как из копируемой в буфер строки сформировать фиктивный адрес-возврата, т.к. клавиатурный ввод поддерживает только ASCII-коды печатных символов, и создать из них HEX-адрес не так просто (ситуацию усугубляет, если в адресе присутствует двоичный нуль). Однако при наличии смекалки и эту проблему можно решить (см.ниже).
Чтобы предотвратить модификацию адреса-возврата при переполнении буфера, инженеры Microsoft предлагают нам 2 основных решения – это замена уязвимой функции
3. Переполнение буферов в куче
Куча «Heap» – это область динамической памяти программы для выделения блоков произвольного размера. Для временных буферов рекомендуется выделять память именно в куче, а не в стеке, т.к. в ней будет отсутствовать уязвимый адрес-возврата. Тогда какой интерес представляет куча для взломщиков? Чтобы ответить на этот вопрос, нужно рассмотреть способ динамического распределения памяти.
В основе управления хипом лежит функция
Выделяет Size-байт из кучи, и возвращает указатель на выделенный блок, или нуль в случае ошибки.
Аналогично malloc(), только забивает выделенный блок памяти нулями, чего не делает предыдущая.
Size = размер блока, Num = их кол-во. Эти аргументы можно переставить местами.
Позволяет динамически изменить размер выделенного блока.
Ptr = указатель на выделенную ранее память, Size = новый размер блока.
Освобождает выделенную malloc(), calloc(), или realloc() память.
Ptr = указатель на блок, который необходимо освободить.
Суть в том, что при выделении памяти с помощью
Сами метаданные хранят всего 2 указателя для организации связанного списка – это линк на метаданные предыдущего свободного блока памяти, и линк на метаданные следующего блока. Чтобы разобраться с форматом служебных данных, можно последовательно запросить 2 блока памяти подряд, и посмотреть на результат в отладчике:
При первом вызове функция
Значит продолжаем трейс в отладчике и зовём второй раз
Таким образом, атака на переполнение буфера в куче подразумевает мод именно метаданных. Если вместо указателей на «Backward/Forward Block» мы сможем подсунуть линк на свой шелл, то при сл.вызове
Реализация переполнения буферов в куче требует тщательного разбора метаданных, а потому этот способ не получил широкого распространения. Единственно возможный вариант защиты от атак подобного рода является ввод с ограничением длины
4. Фиктивные спецификаторы функции printf()
Команды «peek» и «poke» использовались в Basic для доступа к содержимому ячейки памяти – «peek» читает байт по указанному адресу, а «poke» записывает. Термины устарели, но по привычке используются программистами для обозначения доступа к памяти в целом. Для начала посмотрим на список функций из либы msvcrt.dll (Microsoft VisualC Runtime), которые представляют для нас особый интерес:
Известно, что функции
Проблема в том, что эти функции страдают классическим недержанием кол-ва переданных им спецификаторов, что является огромной дырой в подсистеме безопасности. Они могут принимать произвольное кол-во спецификаторов, и этот факт позволяет нам навязывать фиктивные, по своему усмотрению. Рассмотрим такой пример, где функция
..компилим, запускаем, всё исправно работает.
Теперь посмотрим на реакцию функции
Как видим, глупая функция приняла наш ввод за свои спецификаторы, в результате чего прихватила с собой 5 следующих двордов из стека – первые(2) это лок.переменные см.исходник, третий это значение сохранённого на входе в процедуру регистра
Таким образом, взяв в заложники спецификаторы функции
Ладно, будем считать, что с чтением памяти определились. Но на практике интерес представляет запись, чтобы модифицировать, например, всё тот-же адрес-возврата. Примечательно, что
Ну и конечно классический вариант перезаписи ячеек функцией
Если функция внутри процедуры запрашивает ввод текстовой строки, то не прибегая к лишним усилиям мы можем подсунуть ей HEX-значения в формате ASCII-кодов. Когда с комбинацией
Рассмотрим это дело на таком примере, где запрашивается строка со-спецификатором(%s), но для демонстрации мы распечатаем её в HEX.
Допустим нам нужен адрес
5. Выводы
В своей массе, переполнению буферов подвержены только консольные приложения, которые импортируют функции из либы msvcrt.dll (хидер stdio.h). Однако и в оконных приложениях довольно часто встречается WinAPI
Ну и под занавес вот несколько рекомендаций для защиты от переполнений буферов в стеке и куче:
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»:Проблема в том, что большинство функций рантайма для хранения временных значений всегда используют локальную память функции именно в стеке, что влечёт за собой катастрофические последствия. С одной строны это удобно, т.к. на выходе из функции компилятор сам очищает лок.память предотвращая её утечку (не нужен постоянный контроль). Но с другой стороны программист не может заранее предугадать, буфер какого именно размера потребуется в стековой памяти, и резервирует разумно-ограниченное её кол-во как на рис.выше (пусть будет) 32 байта. Например если код запрашивает имя юзера, оно может иметь длину 4-байта «Вася», а может принадлежать Лоуренсу Уоткинсу с длинною в 2.253 слов. Здесь уже прогерам приходится садиться на шпагат, и выбирать золотую середину.
Как результат, ввод 44-байтной строки в выделенный зелёным лок.буфер приведёт к тому, что мы пробьём дно буфера 0-31, и затерём сначала обе переменные, а затем и адрес-возврата из функции, подсунув вместо него указатель на свой шелл. Единственная проблема здесь в том, как из копируемой в буфер строки сформировать фиктивный адрес-возврата, т.к. клавиатурный ввод поддерживает только ASCII-коды печатных символов, и создать из них HEX-адрес не так просто (ситуацию усугубляет, если в адресе присутствует двоичный нуль). Однако при наличии смекалки и эту проблему можно решить (см.ниже).
Чтобы предотвратить модификацию адреса-возврата при переполнении буфера, инженеры Microsoft предлагают нам 2 основных решения – это замена уязвимой функции
gets() на безопасную и способную ограничивать длину ввода fgets(), а так-же вставку перед адресом-возврата контрольного слова «Canary word», что на жаргоне звучит как «Канарейка». Это может быть любое значение размером DWORD, главное чтобы оно было уникальным при каждом вызове функции (например текущее время, или такты процессора rdtsc), иначе взломщик сможет легко найти и восстановить его. Теперь на выходе из функции мы сможем проверить значение канарейки и если оно изменилось, возможно это попытка переполнения буфа в стеке взломщиком. Позиция контрольного слова выделена на рис.ниже жёлтым: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:Значит продолжаем трейс в отладчике и зовём второй раз
calloc().. Хм, и точно аллокатор выделил нам 32-байтный блок в памяти хипа по адресу 0x005D0EA0, в хвосте которых так-же маячат метаданные.Таким образом, атака на переполнение буфера в куче подразумевает мод именно метаданных. Если вместо указателей на «Backward/Forward Block» мы сможем подсунуть линк на свой шелл, то при сл.вызове
calloc() или realloc() он получит управление! При поиске уязвимостей в коде, здесь можно искать не только вызовы malloc/calloc() с последующим вводом с клавиатуры gets(), но и функции копирования блоков из одного места в другое memcpy(). Реализация переполнения буферов в куче требует тщательного разбора метаданных, а потому этот способ не получил широкого распространения. Единственно возможный вариант защиты от атак подобного рода является ввод с ограничением длины
fgets(), вместо дырявой как сито gets().4. Фиктивные спецификаторы функции printf()
Команды «peek» и «poke» использовались в Basic для доступа к содержимому ячейки памяти – «peek» читает байт по указанному адресу, а «poke» записывает. Термины устарели, но по привычке используются программистами для обозначения доступа к памяти в целом. Для начала посмотрим на список функций из либы msvcrt.dll (Microsoft VisualC Runtime), которые представляют для нас особый интерес:
Известно, что функции
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'
..компилим, запускаем, всё исправно работает.
Теперь посмотрим на реакцию функции
sprintf(), если на запрос «Name» навязать ей несколько HEX-спецификаторов (%08Х указывает расширить число до 8 знаков):Как видим, глупая функция приняла наш ввод за свои спецификаторы, в результате чего прихватила с собой 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, и вот он нужный указатель: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 включён только для системных приложений, так-что нужно взвести галку «..для всех».