По следам Petya: находим и эксплуатируем уязвимость в программном обеспечении

    image

    Нашумевшие события последних месяцев наглядно показывают, насколько актуальной является проблема критических уязвимостей в программном обеспечении. Массовые атаки на пользователей с помощью вирусов-шифровальщиков 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-цепь, позволяющую это сделать. Последовательность операций следующая:

    1. Разместить строку «dir» по адресу, который известен на момент срабатывания уязвимости.
    2. Поместить в регистр rcx адрес этой строки.
    3. Передать управление в функцию 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 мы собираем самые разнообразные задания, требующие знаний из разных направлений ИБ и способные показать, чем чревато небрежное отношение к безопасности: слабые пароли, плохая реализация сервера (уязвимость в ней мы искали методом фаззинга и подробно описали в этой статье), нестойкие шифры. А на примере данного задания хорошо видно, как небрежности в программировании могут нанести критический урон безопасности информации.
    Метки:
    • +11
    • 5,9k
    • 7
    НеоБИТ 94,80
    Компания
    Поделиться публикацией
    Комментарии 7
    • 0
      Интересно, сколько лет надо изучать\программировать, чтобы досконально разбираться в том, что здесь написано? ))
      Кстати, было бы здорово увидеть на вашем сайте лабораторные (хоть и к прошедшим квестам).
      • 0
        Тут много зависит от способностей и желания. Остальное уже рутина ;).
        • 0
          Полностью согласен! Читать интересно, но хотелось бы увидеть и простейшие задания, которые входят в данную тему…
        • 0
          NWOcs, маленькая опечатка в формуле (0x328-0x198)/4 = 100
          • +1
            Пересчитали, опечатки не нашли :)
            Дело в том, что в левой части формулы у нас значения HEX (0x328-0x198=0x190), а в правой части — десятичные значения!
          • 0
            Отличный howto.
            • 0
              Спасибо!

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое