Что такое переполнение буфера
Переполнение буфера для чайников
Бродя по многочисленным форумам, смотря рассылки и т.д. Я наткнулся на один очень частный вопрос. Звучит он примерно так: «Я не пойму технику переполнения буфера, объясните, пожалуйста!». В данном материале я бы хотел рассмотреть технику полностью. Весь материал будет рассчитан для ОС Linux. Я постараюсь затронуть тему локального и удаленного переполнения буфера. Постараюсь внятно объяснить все. Я думаю, этот материал будет понятен даже новичку.
Итак, пора приступить к изучению.
Что-то я уж заговорился 🙂 Давайте перейдем к обсуждение данной ошибки.
Скажу, что для изучения данного материала, у Вас должны быть хотя бы начальные знания языка Си под Linux. Для дальнейшей работы нам понадобятся следующие инструменты: gcc, gdb, gedit (но можно и другой редактор).
Теперь перейдем к непосредственному объяснению техники переполнения. Допустим, Вы написали утилиту, которая принимает входную строку (первый аргумент). Далее она вызывает системный вызов утилиты «ls» и ищет файл/директорию. В случае если файл/директория найдены, то программа оповещает пользователя о том, что такой файл/директория существуют в системе. Давайте посмотрим на пример такой программки.
Давайте откомпилируем программу и попытаемся запустить: Итак, программа не нашла файла/директории в текущем каталоге. Теперь попробуем создать файл в текущем каталоге.
Так, мы создали файл с помощью стандартной утилиты touch в системе Linux, и программа оповестила нас о том, что такой файл существует в системе. Вроде ничего подозрительного и нет. Никакого переполнения нет в системе. Согласен, программе ведет себя вполне стандартно. Теперь давайте попробуем ввести название файла более 267 символов. Потом объясню, почему именно более 267 символов. Итак:
Так вот строка в коде:
Говорит о том, что в переменную filename нужно копировать первый входной аргумент программы. Но взглянем выше, и мы увидим следующее: Вышеприведенная строка является объявлением переменной filename как типа char (символьного), который состоит из 255 массивов. То есть данная переменная имеет входной буфер на 255 символов. Получается, мы туда можем поместить 255 символов из входного аргумента нашей программы. Итак. Я думаю, вы уже догадались о том странном сообщении. Если нет, то оно значит то, что мы ввели более 255 символов во входной буфер, и программа вызвала ошибку т.к. размер, введенный в аргументе, превышает отведенный размер буфера переменной. Именно это и называется переполнение буфера. А теперь попробуйте сформулировать определение.
Двигаемся дальше. Я думаю все линуксоиды знают очень хорошую и нужную утилиту gdb. Это утилита является встроенным отладчиком в системах Unix. gdb расшифровывается как GNU Debugger. Теперь давайте запустим нашу утилиту в этом отладчике и попробуем ввести длинное имя файла/директории.
Взглянем на адрес, по которому после переполнения обращается функция. Он равен: 0x41424242. А теперь взглянем на запуск программы:
В аргументе присутствуют 268 символов «A» и адрес равный BBBA. А теперь переведите его в hex формат. У меня получилось вот что: 0x42424241, а у компьютера вот: 0x41424242. Из этого можно судить, что компьютер читает значения как арабы или китайцы. Т.е. справа налево. Ну и конечно сверху вниз. Поэтому в системе Unix (да и в Win32) стек растет сверху вниз. Получается, что самый большой адрес будет наверху, а далее стек будет убывать. Примерный вариант стека в стандартной программе таков: Т.е. в случае с нашим переполнением программа себя ведет в стеке так: Идут данные. Если все в порядке, то программа обычно завершает свою работу и выгружается из стека. В случае переполнения ДАННЫЕ превышают норму и уже АДРЕС будет указывать не на выход из функции ( в нашем случает это return в main() ), а на что-то другое ( в нашем случае это последние 4 символа в аргументе. )
Давайте взглянем на следующее. Я думаю, вы еще не закрыли gdb. Введите следующую команду. Мы видим регистры слева, а справа их значения. Помните, я Вам говорил, что для переполнения нужно ввести 268 символов, а не 255 как определено. Теперь взгляните на это: Так вот 268 символов это и есть переполнение при котором значение регистра ebp затирается на значение входного аргумента в hex формате ( в нашем случае на «A» в hex формате ).
Т.е. попробуйте ввести такое в нашу утилиту: Мы видим, что программа завершилась нормально без каких-либо ошибок и переполнений. Взглянем на значения регистров: А их и нет 🙂 Программа завершилась нормально и выгрузилась из памяти.
А попробуйте ввести такое значение: Видно что программа завершилась с ошибкой и в качестве адреса по которому она обратится (адресом возврата) является сама функция main() из библиотеки libc. И поэтому для того чтобы указать свой адрес мы использовали 4 дополнительных символа. Они переводились в hex формат и указывали на адрес возврата. При просмотре регистров мы увидим, что регистр ebp затерся значение «A» в hex. Теперь давайте взглянем на другой регистр. Название ему EIP. eip 0x41424242 0x41424242
Мы видим, что его адрес перезаписался на тот адрес, который мы указали. Т.е. на BBBA в hex формате. Я теперь хочу немного отклониться и рассказать вам об этих самых регистрах процессора.
Вообще регистры это некое подобие строителей внутри процессора. Они как бы получают данные и складывают их в компьютере. Т.е. в случае со строителями они строят дом/гараж и т.д. Они получают данные и складывают их, а далее некая программа пытается прочесть информацию из этих регистров. Количество регистров в архитектуре процессора x86 большое. И с каждым разом все увеличивается и увеличивается. Они бывают как 16-ти разрядные, так и 32-х. Сейчас я хочу рассказать более детально об основных регистрах процесорра.
Думаю, вы уже наглотались теории по самые уши 🙂 Ну ничего осталось совсем чуть-чуть. Я сейчас постараюсь максимально внятно объяснить процесс переполнения, а далее нам останется только осуществить все на практике. И мы уже будем на коне! Итак, поехали.
Процесс переполнения происходит следующим образом:
Итак, думаю довольно сухомятки. Пора приступить к реалиям. Для начала давайте снова запустим нашу программу в отладчике gdb. Происходит переполнение. Вспомните регистр ESP. Давайте взглянем внутрь него: Внимание. В вашей системе может быть по-другому. Итак, что мы видим. А видим мы следующее. Слева у нас как раз те адреса возврата на данные, которые расположены справа. То бишь на данные символов «A». Из этого может следовать, что после переполнения наша уязвимая утилита обращается по одному из этих адресов, в которых имеется значение «A». На ум сразу приходит, что после того как шеллкод будет расположен, он успешно должен исполниться, после того как мы успешно засунем адрес возврата на наш код.
В данном примере я показал простейшее переполнение на основе входного параметра. Так же мне хотелось бы Вам еще показать переполнения, основанные через «Переменные окружения» и удаленные переполнения буфера.
ПЕРЕПОЛНЕНИЕ БУФЕРА ЧЕРЕЗ «ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ».
sprintf(буфер_куда_копировать, формат_копирования, откуда_копировать_данные);
Скажу лишь то, что параметр «формат_копирования», может быть отпущен, но в этом случае возникает другая ошибка программирования. Название ей Ошибки При форматировании Строк. Но об этом читайте в других источниках.
Синтаксис функции getenv() таков:
Итак, мы рассмотрели пример уязвимой программы. Напишем пример программы, которая покажет нам, как переполняется в этом случае буфер. Но сначала откомпилируйте эту программу. Ну я думаю Вам все должно быть ясно. Единственное скажу про синтаксис функции setenv(). Он таков:
Все. Откомпилируйте программу. Как видно наша уязвимая программа вызвала переполнение. Для того чтобы посмотреть подробности, скажу, что после переполнения в текущей директории должен создаться файл «core». В нем имеется информация о переполнении. Поищите его. Далее его нужно просмотреть через gdb: Вот. Возглянем на регистр ESP для того чтобы вычеслить адрес возврата на шеллкод. Так. Пора писать эксплоит. По сути, он ничем не отличается от предыдушего, только функциями. Ну, я думаю, ничего сложного нет, чтобы разобраться с этим кодом. Скажу лишь то, что т.к. адрес буфера уязвимой программы маленький, я расположил адрес в диапазоне от 0 до 500. Он все равно правильно будет расположен. Так теперь давайте откомпилируем эксплоит и запустим. Вот и все, что требовалось доказать :). Переходим к удаленному переполнению буфера.
УДАЛЕННОЕ ПЕРЕПОЛНЕНИЕ БУФЕРА.
Я думаю, многие видели в security рассылках сообщение об очередной ошибке в каком-либо демоне. И в advisory написано, например, что тип атаки является «Удаленным» (Remote). Вначале статьи мы описали принцип локального переполнения. Сейчас я хочу показать Вам пример удаленного переполнения. Мы напишем уязвимый демон. А далее напишем для него эксплоит. Итак, рассмотрим пример уязвимого сервера. Итак, выше приведен листинг простенького сервера. Давайте откомпилируем его и попытаемся запустить. Демон слушает 2278 порт. Попробуем соединиться с этим портом. Работает отлично. Я думаю, Вы уже заметили ошибку переполнения в сервере. Т.е. если серверу передать слишком длинную строку, то он завершится с ошибкой. Давайте рассмотрим пример программу, которую в простонародье принято считать DOS-утилита. Давайте испробуем программу. Не закрывайте сервер. Взглянем на окно сервера. Вот и переполнение! Взглянем на значение регистра ESP. Так вот. Произошло настоящее переполнение. Хочу предупредить, что для того чтобы правильно выбрать адрес на шеллкод не стоит брать адреса верхние и нижние. Нужно взять адреса средние. Настало время написать эксплоит. Давайте протестируем эксплоит. Опа. Работает! Вот в принципе и все. Вообще переполнение удаленное и локальное мало чем отличается.
В этом материале я постарался рассказать очень подробно тему переполнения. Я думаю, она очень понятна даже для человека, который вообще не знал об этой уязвимости. В заключении хотелось бы также отметить то, что я разработал утилиту, которая генерирует эксплоит автоматически. Скачать ее вы можете на сайте http://unl0ck.info. На данный момент это версия 0.3. В будущем планируется добавить новые возможности.
Хотелось бы поблагодарить следующих людей: stine, cr0n, f00n, nekd0, forsyte, eitr0n, msm, mssunny. Без этих людей жизнь в Сети была бы однообразна.
Переполнение буфера: анатомия эксплоита
Взгляд на то, как эксплуатируются уязвимости систем и почему существуют эксплоиты.
1 Введение
Переполнение буфера было задокументировано и осмыслено еще в 1972 [1] году. Это один из наиболее часто используемых векторов эксплуатации уязвимостей. Последствия встречи злоумышленника с уязвимым к переполнению буфера кодом могут варьироваться от раскрытия конфиденциальных данных до полного захвата системы.
Поскольку люди все активнее полагаются на компьютерные системы для передачи и хранения конфиденциальной информации, а также для управления сложными системами «из реальной жизни», компьютерные системы непременно должны быть безопасными. Тем не менее, пока используются языки программирования вроде C и C++ (языки, не производящие контроля выхода за границы), эксплоиты, направленные на переполнение буфера, будут существовать. Вне зависимости от контрмер, принимаемых для защиты памяти от избыточного объема входных данных (контрмеры мы обсудим позже), злоумышленники всегда оставались на шаг впереди.
Используя инструменты вроде GDB (GNU Project debugger: отладчик проекта GNU), опытный злоумышленник (которого мы с этого момента будем называть «хакер») может получить контроль над программой во время ее аварийного завершения и использовать ее привилегии и окружение для выполнения собственных инструкций.
Данный документ объясняет то, почему существуют подобные уязвимости, то, как они могут быть эксплуатированы для взлома системы, и то, как защитить системы от подобных уязвимостей. Однако, чтобы защититься от чего-либо, нужно сначала понять суть угрозы.
Отметим, что данный документ не берет в рассмотрение многие механизмы защиты памяти, реализованные в новых ОС, включая stack cookies (canaries), address space layout randomisation (ASLR) и data execution protection (предотвращение выполнения данных, DEP).
2. Взгляд на память и ее понимание.
2.1 Буферы
Буфер – заданное количество памяти, зарезервированное для заполнения данными. Например, для программы, которая считывает строки из файла словаря, размер буфера может быть установлен равным длине наибольшего слова на английском языке. Проблема возникает, если файл все же содержит строку большей длины, чем буфер. Это может случиться как легальным образом (если в словарь будет добавлено новое очень длинное слово) так и когда хакер вставляет строку, предназначенную для повреждения памяти. Рисунок 1 иллюстрирует эти идеи на примере строк «Hello», «Dog» и мусора в виде «x» и «y».
Пусть программа позволяет пользователям указать новое сообщение приветствия (заменить Hello чем-нибудь на свой вкус). Буфер для хранения этого сообщения имеет длину 6 байт: 5 заняты словом «Hello», а еще один NUL-символом (имеет значение 0 и выполняет роль маркера окончания строки). Пусть «Hello» заменили на «Heya», тогда в буфере будет храниться 4-буквенное слово, после которого следует NUL-символ и один байт с мусором, после чего, как и раньше, идет следующее слово.
Отметим, что символ r является мусором и может иметь произвольное значение. Это просто значение последнего байта из данной области памяти. Указанная в качестве приветствия более длинная строка вроде «DonkeyCat» может перезаписать смежную область памяти.
Если программа теперь попытается обратиться к строке, имевшей ранее значение «Dog», на самом деле она считает значение «Cat», являющееся окончанием нашего слишком длинного приветствия.
Рисунок 1: Строки в памяти
2.2 Указатели и плоская модель памяти
Указатель – это адрес, позволяющий ссылаться на некоторую область памяти. Указатели часто используются для обращения к строкам из кучи (одна из областей памяти для хранения данных) или для доступа к множеству фрагментов данных путем сочетания общего базового адреса и смещения. Наиболее важный указатель для хакера соответствует точке выполнения, которая является началом области памяти, содержащей нуждающийся в запуске машинный код. Эти указатели будут обсуждаться позднее.
Плоская модель памяти используется в большинстве нынешних операционных систем. В данной модели процессам предоставляется одна непрерывная область (виртуальной) памяти, так что программа может обращаться к любой точке выделенной ей памяти путем указания лишь смещения. Возможно сейчас это не кажется важным, но это значительно облегчает для хакеров задачу поиска их буферов и указателей в памяти.
Реализация механизма виртуальной памяти сильно повлияла на информационные технологии. Процессам теперь выделяется область виртуальной памяти, которая отображается на некоторую область физической памяти. Это означает, что буферы с гораздо большей вероятностью каждый раз будут оказываться в одной области памяти, поскольку не нужно беспокоиться, что другие процессы займут область памяти, использованную их буферами при предыдущем запуске. Лучший способ продемонстрировать этот принцип – открыть две разных программы в отладчике и отметить, что обе они используют одинаковое адресное пространство.
2.3 Стек
В архитектуре x86 (равно как и в других архитектурах) существует много структур памяти, которые заслуживают рассмотрения. В данном документе мы рассмотрим одну из них под названием стек. Техническое наименование этого стека – стек вызовов, однако в целях упрощения здесь мы будем называть его просто «стек».
Каждый раз, когда программа вызывает функцию, аргументы функции «кладутся» на стек. Это позволяет быстро получать к ним доступ, использовать и изменять их значение. Вот как работает стек. Существует регистр процессора (в 32-битных системах называемый ESP, где SP означает «stack pointer» или «указатель стека»), который увеличивает 1 значение (на размер буфера или указателя памяти, не считая нескольких байтов, необходимых для выравнивания), резервируя пространство для новых данных, которые хочет сохранить процесс. Рисунок 2 иллюстрирует строку, которая кладется в стек поверх другой строки.
Стек похож на башню – заполняется сверху вниз. Если ESP резервирует 50 байтов адресного пространства, но реально записываются 60 байт, процессор перезапишет 10 байтов информации, которая может быть использована позднее. Представленный рисунок не отражает сложность структуры данных, располагающихся в стеке. Путем тактической перезаписи определенных областей памяти, можно добиться очень интересных эффектов.
Стек можно сравнить с черновиком процессора. Когда люди делают вычисления или исследования, они частенько записывают числа или номера страниц на клочках бумаги. Если на черновике слишком много записей, человек может в итоге написать что-нибудь поверх одной из предыдущих записей и позднее неправильно интерпретировать ее.
2.4 Регистры
Регистры – это блоки высокоскоростной памяти, располагающиеся внутри процессора. Регистры общего назначения (nAX, nBX, где n – символ, отражающий размер регистра) используются для арифметических вычислений, для хранения указателей, счетчиков, флагов, аргументов функции и т. д.
Наряду с регистрами общего назначения существуют более узкоспециализированные регистры. Например, nSP указывает на наименьший адрес в стеке (на его логическую вершину), отчего и получил свое название. Этот регистр крайне полезен при обращении к данным стека, поскольку положение данных в памяти может изменяться в широких пределах, но данные стека располагаются неподалеку от адреса, на который указывает ESP.
Другой регистр, имеющий большое значение в мире компьютерной безопасности – nIP, Instruction Pointer или Указатель Инструкции. Этот регистр указывает на адрес текущей команды для выполнения. Способ, которым данный регистр получает свои значения, представляет для хакеров особый интерес и будет рассмотрен позднее.
2.5 Визуализация памяти
В отличие от показанных выше рисунков, компьютер не представляет содержимое буфера или номера страниц в виде символов или десятичных чисел. Компьютер использует двоичную систему счисления, но для нас будет гораздо проще перевести числа, используемые компьютером, в шестнадцатеричную систему. Это умеют многие отладчики, поэтому мы можем интерпретировать содержимое памяти и взаимодействовать с ней используя шестнадцатеричные числа, на что компьютер будет реагировать так же, как если бы мы работали в его родной двоичной системе. Шестнадцатеричная система счисления – позиционная система с основанием 16, которую очень удобно использовать для взаимодействия с памятью компьютера, поскольку два разряда представляют значение одного байта.
2.6 Рабочие инструменты
GDB – GNU Project debugger, свободно распространяемый консольный отладчик, встроенный в большинство ОС Unix и Linux. Хотя многие утверждают, что отладчики с графическим интерфейсом превосходят их консольные аналоги, практические навыки работы с GDB позволят вам свободно использовать любой другой отладчик. Также многое можно сказать в пользу инструментов, которые распространены повсеместно. Вам может понадобиться отладить программу на любой системе, и, по сравнению с прочими отладчиками, GDB гарантированно отыщется без хлопот.
Данный документ создан не как учебное пособие по GDB. Хотя мы попытаемся объяснить каждый шаг или команду GDB, используемые в данном документе (чтобы упростить жизнь новичкам), всем, кто хочет использовать невероятный потенциал GDB полностью, настоятельно рекомендуется ознакомиться с его официальной документацией на http://www.gnu.org/s/gdb/documentation/ или другом уважаемом ресурсе.
2.7 NUL-терминированные строки (заканчивающиеся 0x00)
В компьютерной науке, операционных системах и языках программирования существует довольно мало принципов, спорных в той же степени, что и NUL-терминированные строки (строки заканчивающиеся NUL-символом). Их называют «самой дорогой однобайтовой ошибкой» [4] (кстати, если уж это и ошибка, то гораздо более, чем «однобайтовая», но это тема для еще одной статьи), и они являются причиной переполнений буфера в том виде, в каком они происходят.
Когда NUL-терминированная строка записывается в стек (или еще куда-нибудь), программа бездумно продолжает писать данные до тех пор, пока не достигнет маркера конца строки – символа NUL. Это означает, что она перезаписывает другие аргументы, сохраненные указатели (которые имеют ВАЖНОЕ значение и будут рассмотрены позже) – все без разбора.
3 Получение контроля над программой
3.1 Что происходит?
Переполнение буфера в стеке происходит, когда проверка выхода за границы не производится над данными, записываемыми в статический буфер. Если объем копируемых в стек данных превосходит размер буфера, компьютер продолжает перезаписывать стек до тех пор, пока не достигнет NUL-символа, переписывая другие значения в стеке и некоторые указатели, которые говорят программе, что делать дальше. Такие указатели являются сохраненными значениями регистра EIP (Extended Instruction Pointer) или SEH-указателей (Structured Exception Handler или Структурная обработка исключений). В данном документе мы рассмотрим лишь указатели первого типа (EIP), поскольку с ними связан традиционный способ получения контроля над программой.
Когда данные перезаписывают один из сохраненных указателей инструкции, происходят интересные вещи. На некотором этапе после вызова функции процессор возвращается по адресу, сохраненному в одном из этих указателей и компьютер считает, что по этому адресу находится следующая инструкция. Обычно в данном случае адрес оказывается некорректным, что приводит к аварийному завершению программы. В Unix и Linux это приводит к тому, что операционная система посылает процессу сигнал SIGSEV. Этот сигнал соответствует ‘SEGMENTATION FAULT’ (ошибка сегментации) и сообщает процессу, что он пытается обратиться к несуществующей или запрещенной области памяти.
Опытный хакер может найти эти сохраненные адреса и получить контроль над программой при ее аварийном завершении.
Что случится если новое значение указателя указывает на корректный адрес, который соответствует области памяти, доступной атакующему для записи?
3.2 Исследование стека
Рассмотрим код на рисунке 3, который соответствует недоработанной системе входа на FTP-сервер. Программа запускается с правами суперпользователя (root), так что она может изменять свойства файлов. С помощью команды ‘chmod u+s’ для программы был установлен UID-бит, позволяющий обычным пользователям взаимодействовать с ней (например, анонимным FTP-пользователям). Данный код принимает один аргумент и сравнивает его со строкой (более показательным было бы сравнение пары логин-пароль со значением из базы данных, но для демонстрации мы ограничимся более простым примером). Если аргумент совпадает со строкой, происходит вход пользователя.
Рисунок 3: уязвимая программа на языке C
Данный файл был скомпилирован с помощью gcc версии 3.3.6 (старая версия, которая по умолчанию не включает механизмы защиты памяти) с флагом –g, который облегчает использование отладчика GDB.
При запуске GDB в линуксовой консоли ему передается аргумент, содержащий имя уязвимой программы. При вводе команды ‘list’ отладчик должен показать исходный код программы. Если код не был показан, значит компилятор неправильно воспринял ключ –g. Чтобы понять, как выглядит стек при вызове функции, мы выставим точки останова на строках 11, где происходит вызов strcpy, и 12, сразу после strcpy, как можно увидеть на рисунке 4. Отметим, что остановка производится перед выполнением команды на соответствующей строке.
Рисунок 4: Точки останова в GDB
Ввод в GDB команды ‘run AAAAAAAAAAAAAAAAAAAA’ запустит программу с аргументом, состоящим из 20 символов A, и остановит ее выполнение в точках останова, как можно видеть на рисунке 5.
Рисунок 5: GDB Анализ
Ввод команды «info r esp» (где r означает ‘register’) приведет к выводу адреса, хранимого в регистре esp (вершина стека). На 64-битных системах соответствующий регистр называется rsp. Подобным образом можно получить значение любого регистра, включая указатель инструкции (nIP) и rsp/esp.
На следующем этапе мы исследуем содержимое стека до вызова функции strcpy(). Это делается с помощью команды
На рисунке 6 представлен пример того, как выглядит стек после событий, предшествующих непосредственному вызову функции (когда ESP уже зарезервировал пространство в стеке для данных).
Рисунок 6: Дамп первоначального содержимого стека
Большая часть данной области памяти представляет собой мусор: после начального адреса области 0xbffff780 следует заполнитель для выравнивания, после чего идет 60 байт мусора (случайные данные, лежащие в выделенном, но еще не заполненном буфере), а затем слово (4 байта), зарезервированное под целочисленную переменную loggedin по адресу 0xbffff7dc (выделено курсивом). Еще 4 байта, следующие через 12 байт от loggedin и также выделенные курсивом, будут детально рассмотрены позже. Ввод команды continue в GDB приведет нас к следующей точке останова, что можно увидеть на рисунке 7.
Рисунок 7: Дальнейший анализ GDB
Рисунок 8: символы ‘A’, положенные в стек
Байты со значением 0x41 можно увидеть ближе к вершине стека (к наименьшему адресу). В рамках данного запуска программы очевидно, что пользователь не сможет осуществить вход. Однако, несмотря на то, о чем думал программист, существует по меньшей мере два других способа осуществить вход в данную систему, один из которых позволит пользователю скомпрометировать систему в целом.
3.3 Повреждение стека (stack smashing)
3.3.1 Часть 1: искажение переменных
Повреждение стека заключается в переполнении стека приложения или операционной системы. Это позволяет нарушить работу программы или системы или привести к аварийному завершению. [5]
Подача на вход специально сформированной строки позволит управлять выполнением программы. Запуск программы с аргументом ‘secur3’ приведет к тому, что на экран будет выведено ‘Logged in!’. При запуске с любым другим паролем из менее чем 50 символов (размер буфера) программа выведет на экран ‘Login Failed’. Если взглянуть на стек на 12 строке программы, эти две строчки будут видны начиная с той же области, откуда начинались символы ‘A’ в предыдущих примерах.
Первая уязвимость данной программы связана с положением переменной ‘int loggedin’ по отношению к строковому буферу ‘password’. Если аргумент, передаваемый программе является достаточно большой строкой, при копировании в стек он перехлестнет границы буфера и перезапишет значение loggedin. Перезапись значения loggedin символом ‘A’ приведет к тому, что булевское значение данной переменной станет равным true (поскольку любое ненулевое значение соответствует логической истине). Когда программа дойдет до строки ‘return loggedin;’, это новое значение loggedin (0x00000041) будет интерпретировано в функции main как true и пользователь войдет в систему. Рисунок 9 показывает искажение памяти в действии.
Рисунок 9: Переполнение буфера в стеке
Содержимое стека, представленное на рисунке 9, является результатом команды вызова программы из консоли, которая приведена на рисунке 10.
Рисунок 10: эксплоит в действии
Отметим, что данный небольшой Перл-скрипт формирует строку из 77 символов ‘A’, которая затем передается как аргумент уязвимой программе.
Простой способ защитить программу от этого переполнения заключается в изменении ее кода так, чтобы переменная loggedin располагалась в стеке перед буфером password. На рисунке 11 показан фрагмент соответствующего кода.
Рисунок 11: изменение положения буфера в памяти
Это решение в самом деле предотвратит перезапись loggedin при переполнении буфера password, но едва ли его можно назвать идеальным, поскольку оно все еще имеет огромный потенциал для искажения стека. Кроме того, за буфером располагается еще один важный блок данных (также выделенный на рисунке рамкой), называемый сохраненным указателем (или адресом) возврата. Перезапись этого блока может полностью скомпрометировать систему.
3.3.2 Часть 2: Искажение указателей инструкции
Указатели на инструкцию – это указатели, которые процессор может использовать для ссылки на исполняемый код. В стеке существует несколько видов указателей на инструкции, но в данном документе будет рассмотрен только один – сохраненный указатель возврата. После выполнения вызова функции, выполнение перемещается в некоторую область памяти. Откуда процессор знает, как вернуться к предыдущему состоянию выполнения после возврата из функции? Он использует тот самый указатель. Хакер может поместить код в буфер password и перезаписать сохраненный указатель возврата значением адреса из этого буфера, что заставить процессор выполнить код хакера.
В UNIX-подобных системах существуют переменные среды, которые располагаются в достаточно фиксированных местах памяти и которые можно использовать для хранения двоичных данных. Эти переменные лучше подходят для хранения вредоносного кода, чем буфер переменной password, который может менять свое положение в памяти в серии запусков и иметь размер, недостаточный для хранения кода, компрометирующего систему. Есть и другие преимущества использования переменных среды, например, возможность включения NUL-символов, однако воспользоваться ими затруднительно, поскольку для создания таких переменных пользователю нужна командная оболочка. Этот тип ‘хостинга’ кода не подходит для чисто удаленных эксплоитов, в которых у хакера нет командной оболочки.
В данном примере код запустит командную оболочку (shell) с правами root (поэтому подобный код часто называют ‘shell-code’ или шелл-код). Шелл-код будет рассмотрен нами позднее.
Возвращаясь к рисунку 9, можно отметить очевидный факт, что искажение памяти может быть продолжено до адреса сохраненного указателя возврата включительно. Перезапись этого указателя значением 0x41414141 приведет к ошибке сегментации SIGSEV, поскольку программа попытается обратиться к данному адресу, который является некорректным. Если к повторяющимся символам ‘A’ в нужном месте присоединить корректный адрес, программа примет его за адрес возврата, загрузит его в nIP (EIP на 32-битных системах) и выполнит любые инструкции, находящиеся по данному адресу. Используя опкоды (шестнадцатеричное представление машинных инструкций) для переполнения буфера и перезапись сохраненного указателя возврата значением адреса начала буфера, мы добьемся, что программа невольно запустит предоставленный код со своими привилегиями. На практике адрес для перезаписи указателя возврата соответствует не началу буфера, а указывает в середину NOP sled (массива из повторяющихся опкодов инструкции NOP). Более подробно данная техника будет рассмотрена позже. Сейчас же достаточно сказать, что эта техника является чем-то вроде водостока в памяти и направляет nIP (в данном случае EIP) прямо к шелл-коду. Рисунок 12 показывает соответствующий поток выполнения: верхняя стрелка представляет строку, записываемую в стек, изогнутая стрелка внизу представляет прыжок, делаемый при загрузке нового адреса в EIP, а вторая нижняя стрелка отображает перемещение EIP от места попадания в NOP sled до выполнения шелл-кода.
Рисунок 12: Поток выполнения эксплоита
Снова оглядываясь на рисунок 9, можно отметить, что буфер начинается в памяти по адресу 0xbffff750. Это означает, что если заполнить буфер опкодами, создающими шелл с правами root, и перезаписать сохраненный указатель возврата данным адресом, то программа использует свои привилегии, чтобы создать интерактивную командную оболочку с правами root для обычного пользователя. Шелл-код, используемый в данном случае, будет рассмотрен позднее. Пока достаточно понять, что его опкоды (тоже будут рассмотрены позднее) говорят системе сделать системный вызов и запустить ‘/bin/sh’.
Наш эксплоит будет написан с помощью двух стандартных, но еще не рассмотренных нами техник: ‘NOP sled’ и ‘repeated address’ (повторяющийся адрес). NOP sled состоит из повторяющихся машинных кодов инструкции NOP, которая означает ‘ничего не делать’: процессор просто пропускает ее, двигаясь дальше по стеку. Использование NOP sled позволяет увеличить область допустимых расположений начала шеллкода, то есть помогает справиться с небольшими изменениями памяти при разных запусках программы. Техника ‘repeated address’ состоит в выравнивании адреса, загружаемого в EIP при считывании сохраненного указателя возврата, путем заполнения стека одинаковыми октетами (столбцы на рисунке 9, например) со значением нужного адреса. Эти две техники просто повышают вероятность корректного выполнения эксплоита. Рисунок 13 содержит короткий Перл-скрипт, который формирует строку, содержащую все три компонента, показанные на рисунке 12.
Рисунок 13: конструктор эксплоита на Перле
Отметим, что использованный в данном документе шелл-код написан Стивом Ханной [2].
После того, как вредоносная строка попадает в буфер (и переполняет его), ее компоненты в стеке можно легко различить, что видно на рисунке 14.
Рисунок 14: эксплоит в стеке
После того, как EIP загрузит новый указатель возврата, он пробегает NOP sled и выполняет шелл-код, что приводит к запуску шелла с правами root.
Рисунок 15: запуск шелла с правами root
4 Шелл-код
4.1 Что это такое и зачем нужно?
Шелл-код – это название вредоносной начинки эксплоита. Как правило, он пишется на ассемблере и представляется в виде опкодов. Шелл-код получил такое название, поскольку его первоначальной целью (на заре создания эксплоитов) было запустить командную оболочку. В наши дни шелл-код может гораздо больше и возможности его ограничены лишь творческими способностями хакера. По этой причине некоторые эксперты в данной области считают термин ‘шелл-код’ слишком узким.
Шелл-код имеет некоторые ограничения, которых нет у обычных программ. Шелл-код не может содержать ‘плохих’ символов. Какие именно символы считать плохими зависит от эксплоита. Например, если полезная нагрузка интерпретируется как строка, нулевой байт является плохим символом, поскольку это маркер конца строки. Если он встретится в середине шелл-кода, то код не будет скопирован до конца (нулевой байт может встретиться лишь в одном месте – конце шелл-кода). Кроме того, шелл-код, как правило, имеет ограничение на размер, основанное на размере доступных буферов (иногда можно связать несколько буферов, совершая в шелл-коде прыжки между ними).
Программы на компилируемых языках высокого уровня обычно компилируются в двоичные исполняемые файлы. Содержимое двоичных файлов можно представить как в двоичной, так и вдругой системе счисления. При представлении в шестнадцатеричной системе содержимое двоичных файлов (не считая литералов вроде строк, имен переменных и функций) – это опкоды ассемблерных инструкций. Ассемблерная инструкция (например, mov) является именем для некоторого ассемблерного опкода, например опкод 0xeb соответствует инструкции JMP SHORT, часто встречаемой в шелл-кодах. Нам необходимо, чтобы опкоды представляли корректный шелл-код, поскольку они внедряются в уже откомпилированную программу (и эмулируют таковую) так, чтобы процессор смог их выполнить.
Распространенной практикой является написание шелл-кодов на языке ассемблера с последующим использованием программы-ассемблера вроде NASM http://www.nasm.us/, который преобразует ассемблерные инструкции в опкоды, а также производит низкоуровневое управление памятью, например, создание стековых фреймов (если пользователь не указал, что хочет сделать это самостоятельно). Полученные опкоды затем модифицируются для запуска в качестве шелл-кода.
4.2 От машинного кода к шелл-коду
На рисунке 16 представлена простая программа, которая может быть ассемблирована и выполнена с помощью ELF-линковщика под UNIX. Она похожа на классическую программу «Hello, World!», но выводит строку ‘Executed’. Это программа будет использована для демонстрации того, как машинный код ассемблируется и модифицируется в годный к употреблению шелл-код.
Данную программу можно запустить, ассемблировав с помощью NASM и слинковав с помощью ELF, однако на этом этапе она будет еще далека от шелл-кода.
Рисунок 16: Простая ассемблерная программа
Рисунок 17: шелл-код №1
Хотя данный код запустится как шелл-код при определенных обстоятельствах, его нельзя запустить через переполнение строкового буфера. Шестнадцатеричное представление данного кода после ассемблирования содержит много NUL-байтов, которые прервут копирование строки в буфер раньше времени. Эти терминирующие NUL-байты можно увидеть на рисунке 18.
Рисунок 18: Шестнадцатеричный дамп ассемблированного шелл-кода
Рисунок 19: взаимодействие с частями регистра
Рисунок 19 показывает, как дополняются малые значения при использовании регистра целиком. При работе с частью регистра прочие его части сохраняют свои текущие значения. Это приводит к тому, что при помещении значения 4 в подрегистр al весь регистр в целом будет иметь совсем другое значение. Справиться с этой проблемой можно, выполнив xor (операция исключающего ИЛИ) регистра с самим собой до того, как изменить значение al. При этом сначала регистр примет нулевое значение, затем последняя часть регистра примет значение 4, а регистр в целом как аргумент будет интерпретироваться корректно. Данный принцип применяется к каждому регистру, используемому в шелл-коде.
Последний шаг использует описанный выше дополнительный код для удаления NUL-байтов из смещения, используемого инструкцией call. Инструкция call теперь находится в самом конце кода (после нее только строка ‘Executed’). За счет этого смещение метки «code» относительно инструкции call становится отрицательным и выражается в дополнительном коде, не содержащем NUL-байтов. Переход на инструкцию call происходит за счет того, что в самое начало кода добавлен short jump на метку «caller». Поскольку операция short jump использует ‘короткое’ (однобайтовое и в данном случае ненулевое) значение, она не будет ничем дополняться и не создаст дополнительных NUL-байтов. Эти трюки дают нам код и его ассемблированное шестнадцатеричное представление, показанные на рисунке 20. Как показывает шестнадцатеричный дамп, в коде не осталось NUL-байтов.
Рисунок 20: шелл-код #2
Рисунок 21: Итоговый шелл-код
При внедрении в программу в ходе эксплоита, этот код перенаправляет поток выполнения к NOP sled, откуда он переходит к инструкции jmp short, затем выполняется код, на терминал выходится «Executed», а итоговое прерывание завершает програму с нулевым кодом ошибки.
Есть много факторов, влияющих на то, какие символы считать ‘плохими’. Самый простой способ выявить плохие символы – заполнить буфер байтами со всевозможными значениями от 0x00 до 0xFF и отметить, какие символы останавливают копирование шелл-кода в стек. Существует несколько способов избавиться от плохих символов, один из которых состоит в использовании вышеописанных методов. Другой состоит в использовании кодировщика символов, но это увеличит размер шелл-кода.
5 Заключение
Искусство эксплуатации уязвимостей состоит из четырех больших частей:
Необходимо, чтобы разработчики программ знали о шагах, необходимых для обеспечения защиты памяти. Простейший путь обезопасить входные данные – не доверять ничему. Нужно всегда проверять размер передаваемых данных и в крайних случаях проверять данные на наличие потенциально вредоносных опкодов. Переполнение буфера – одна из наиболее серьезных угроз компьютерной безопасности с которой сталкиваются в наши дни (как и за последние 40 лет) разработчики и потребители программ. Важно, чтобы при написании кода разработчики учитывали этот факт.