Нашумевшие события последних месяцев наглядно показывают, насколько актуальной является проблема критических уязвимостей в программном обеспечении. Массовые атаки на пользователей с помощью вирусов-шифровальщиков WannaCry и Petya осуществлялись путем удалённой эксплуатации уязвимостей нулевого дня в сетевых сервисах Windows – SMBv1 и SMBv2. Нахождение и эксплуатация уязвимостей для удаленного выполнения кода – задачка явно не из легких. Однако лучшим из лучших специалистов по информационной безопасности всё по зубам!
В одном из заданий очного тура NeoQuest-2017 необходимо было найти уязвимости в доступной по сети программе-интерпретаторе команд, и с помощью их эксплуатации выполнить код на удаленном сервере. Для доказательства взлома нужно было прочитать содержимое файлового каталога на сервере – там, по условию, размещался ключ задания. Участникам был доступен бинарный файл интерпретатора. Итак, лёд тронулся!
Начинаем исследование
Для начала запустим бинарник и посмотрим, что он собой представляет. Становится ясно, что исполняемый файл интерпретатора формата PE и предназначен для архитектуры Intel x64. Также ясно, что он скомпилирован с поддержкой DEP/ASLR, но без CFG. Сторонние библиотеки также не подгружаются.
Сама программа представляет собой простой интерпретатор команд работы с целочисленным вектором. Синтаксис поддерживаемых команд доступен при входе в программу. Вектор поддерживает запись и чтение значений из ячеек, запись в вектор целиком.
С поверхностным анализом закончено, пора реверсить – тур-то очный, время поджимает!
А что подавать на вход?
Первая промежуточная задача, которую необходимо решить – точное определение поверхности атаки. Уточним количество и формат команд, загрузив бинарник в IDA Pro. Функция main легко находится путем поиска выводимых на экран строк.
Анализ функции main позволяет утверждать следующее:
1. 1. Сначала адрес массива array на стеке функции main заносится в переменную array_base. В цикле ячейки массива инициализируются нулями. Условие выхода из цикла позволяет узнать размер массива – 100 (0x64) целочисленных ячеек.
2. 2. Происходит вывод на экран приветствия и считывание команды пользователя. Присутствие строк «cls» и «whoami» говорит о вызове функции system (в дальнейшем это нам сильно пригодится).
3. Инициализируются переменные-регулярные выражения, для проверки корректности синтаксиса. Видно, что недокументированные команды отсутствуют. Кроме того, становится ясно множество допустимых параметров каждой инструкции.
4. Последовательно в бесконечном цикле производится проверка введенной команды на соответствие регулярным выражениям. Если соответствие какой-либо команде найдено – вычисляются аргументы у команды и вызывается её обработчик.
В результате анализа кода функции main выяснено, что недокументированных команд нет. Возможно влиять на следующие параметры:
- индексы в командах set/get;
- помещаемое значение в команде set;
- размер и содержимое буфера для записи в массив.
В силу наличия защитных механизмов ASLR и DEP необходимо найти и реализовать 2 уязвимости:
- уязвимость раскрытия адреса в исполняемой области памяти — для обхода ASLR и построения ROP-цепи с обходом DEP;
- уязвимость перехвата потока управления по контролируемому нами адресу – для передачи управления на начало ROP-цепи и исполнения кода.
ROP (Return Oriented Programming) – современная техника эксплуатации, направленная на обход защитного механизма DEP. Суть этой техники заключается в переиспользовании маленьких фрагментов (гаджетов) исполняемой памяти перед инструкциями ret. Выстроенные в цепочку, адреса гаджетов последовательно снимаются со стека, выполняя команды в гаджете вплоть до команды ret. Она, в свою очередь, снимает со стека адрес следующего гаджета, и т.д.
Уязвимость №1
Теперь мы знаем, что целевой массив имеет константный размер и расположен на стеке функции main. Попробуем прочитать ячейку с индексом, выходящим за границы массива.
Сначала при величинах, больших размера массива, мы наблюдаем нормальное поведение – выход с сообщением об ошибке. Однако при значениях индекса больших, чем 2147483647, программа то падает, то выдает какие-то значения.
Похоже, что индексы, которые трактуются как отрицательные числа, проходят проверку границ и выдают содержимое памяти по отрицательному индексу вне границ массива. Уязвимость найдена!
Почему так происходит? Уязвимость кроется в неверной обработке индекса в командах set/get. Численный индекс в массиве считывается как параметр команды set и переводится из строковой в численную форму с помощью вызова stoul. Эта функция возвращает беззнаковое целое.
Однако при передаче параметра в функции SET/GET это же значение ошибочно приводится к знаковому целому – оно сравнивается с размером массива и влияет на результат команды знакового сравнения jl. Команда GET выдает значение ячейки памяти, отсчитанное от начала массива.
Эту возможность можно использовать для чтения из памяти какого-либо исполняемого адреса. Поскольку массив расположен на стеке, чтением ячеек массива с отрицательными индексами мы можем просматривать содержимое стека до расположения в нем массива. Поскольку архитектура – x64, адреса в памяти занимают 8 байт. Чтение же из массива осуществляется по 4 байта. Поэтому для чтения адреса из памяти необходимо напечатать две подряд идущие ячейки.
С помощью отладчика экспериментально находим такие ячейки памяти перед буфером, где лежит исполняемый адрес – например, 4294967282 == -14 и 4294967283 == -13. Их значения мы далее используем при построении ROP-цепи.
Кроме того, в дальнейшем при эксплуатации нам понадобится адрес самого буфера на стеке. Как его найти? Взглянем на начало функции main и увидим, что переменная array_base хранит указатель на начало буфера.
На стеке эта переменная расположена по адресу rsp+0x358-0x328, а сам буфер начинается с адреса rsp+0x358-0x198.
Значит, необходимо отступить (0x328-0x198)/4 = 100 четырехбайтовых ячеек от начала массива, чтобы прочесть переменную array_base. Искомые смещения: 4294967196, 4294967197.
Благодаря данной уязвимости мы раскрыли исполняемый адрес в памяти процесса, а также выяснили адрес искомого буфера для размещения параметров ROP-цепи. Теперь необходимо найти способ перехвата потока управления программы.
Уязвимость №2
До сего момента мы не исследовали функцию интерпретатора load. Посмотрим на неё повнимательнее. Очень скоро здесь обнаруживается классическое переполнение буфера на стеке. Введенная с клавиатуры строка — аргумент команды load — выделяется и передается как первый параметр функции-обработчика LOAD. Вторым параметром идет адрес искомого буфера.
Функция LOAD осуществляет циклическую запись в этот буфер без каких-либо проверок его размера. Как видно, количество записанных байт зависит только от размера входной строки.
Передав достаточно большое количество символов на вход команде load, возможно переписать адрес возврата на стеке и с выходом из интерпретатора передать управление по контролируемому нами адресу. С учетом размера и расположения буфера на стеке, адрес первичной передачи управления необходимо размещать со смещением 4 * (100 + 2) байта (дополнительные 8 байт переписывают сохраненное на стеке значение регистра rbp).
Поскольку переполненный буфер расположен на стеке, а не в куче, то ROP-цепь будем располагать там же целиком – начиная с смещения 4 * (100 + 2) во входном параметре команды load.
Теперь все ингредиенты на месте. Пришло время собирать из них боевой эксплойт!
Pwn it!
Для получения ключа задания необходимо прочитать и вывести на экран содержимое локального каталога на сервере. Ранее при анализе нами было замечено, что перед выводом приветствия содержимое экрана очищается, и в приветствии присутствует имя пользователя.
Очевидно, что функция, которая это делает и принимает в качестве параметров строки «cls» и «whoami», ведет себя подобно библиотечной функции system. Необходимо вызвать эту функцию, но в качестве выполняемой команды взять «dir» – вывод на экран содержимого локального каталога на сервере.
Построим ROP-цепь, позволяющую это сделать. Последовательность операций следующая:
- Разместить строку «dir» по адресу, который известен на момент срабатывания уязвимости.
- Поместить в регистр rcx адрес этой строки.
- Передать управление в функцию system_func.
При размещении адресов в буфере необходимо вспомнить о наличии ASLR, и вычислять их динамически, с использованием считанных ранее адресов исполняемой памяти и начала буфера. Кроме того, их запись в буфер осуществляется в соответствие с нотацией Little Endian, то есть, от младших байтов к старшим.
Теперь необходимо найти смещения нужных гаджетов в исполняемом файле интерпретатора. Первый необходим для помещения в rcx адреса строки «dir», второй — для вызова функции system_func. По смещениям 0x24c00 и 0x16ce6 соответственно в секции кода исполняемого файла интерпретатора находятся нужные участки кода.
Строку «dir» мы поместим после гаджетов. Её адрес, таким образом, будет на 4*(100 + 2) + 6*4 байт больше адреса буфера.
Ниже приведен наш вариант скрипта для генерации содержимого буфера:
from __future__ import print_function
import sys
n = 102
f=open("buf.txt", "w")
base_l = 0x2f150000 - 0x0 # get 4294967282
base_h = 0x7ff6 # get 4294967283
buffer_l = 0x0afcf6e0 # get 4294967196
buffer_h = 0xe8 # get 4294967197
def rev(x):
return ((x << 24) & 0xff000000 | (x << 8) & 0x00ff0000 | (x >> 8) & 0x0000ff00 | (x >> 24) & 0x000000ff)
arr = [
base_l + 0x24c00,
base_h, # pop rcx ; ret
buffer_l + n*0x4 + 6*0x4,
buffer_h, # buffer ptr - in rdi
base_l + 0x16ce6,
base_h # &system in our binary
]
for i in range(0,n): # dumb
print("%08x" % rev(0xdeadbeef), end='', file=f)
for i in arr:
print("%08x" % rev(i), end='', file=f)
# "dir\0"
print("%08x" % 0x64697200, end='', file=f)
f.close()
Всё готово, пора идти за ключом!
- Подключаемся к удалённому серверу, узнаем нужные адреса командой get.
- Генерируем уязвимый буфер на их основе.
- Выполняем команду load, переписываем адрес возврата из функции main.
- Выполняем команду exit, что завершает функцию main и запускает эксплойт на выполнение.
Искомый ключ: fb520eb552747437c09f2770a9a282ea.
Что в итоге?
В NeoQUEST мы собираем самые разнообразные задания, требующие знаний из разных направлений ИБ и способные показать, чем чревато небрежное отношение к безопасности: слабые пароли, плохая реализация сервера (уязвимость в ней мы искали методом фаззинга и подробно описали в этой статье), нестойкие шифры. А на примере данного задания хорошо видно, как небрежности в программировании могут нанести критический урон безопасности информации.