R0 CREW

Gray Hat Python: Глава 8 - Fuzzing (Перевод: Prosper-H)

Перейти к содержанию

Intro

Fuzzing – был горячей темой в течении некоторого времени, главным образом, потому что это один из самых эффективных методов поиска ошибок в программном обеспечении. Fuzzing - является ничем иным, как создание некорректных или полу-корректных данных для отправки приложению, в попытке вызвать в нем неисправность. В этой главе, мы обсудим различные типы фаззеров, а так же классы ошибок, предоставляющие собой неисправности, которые мы ищем; затем мы создадим файловый фаззер для собственного использования. В последующих главах, мы рассмотрим фрейворк Sulley, а так же фазер ioctlizer, который был разработан для тестирования драйверов Windows.

Приступая к теме фаззинга нужно знать, что существует два типа фаззеров: генерирующие (generation) и мутирующие (mutation). Генерирующие фаззеры создают данные, посылаемые тестируемой программе; мутирующие фазеры берут части уже существующих данных и изменяют их. Примером генерирующего фаззера могло бы быть что-то, что создает набор неправильных HTTP-запросов и отправляет их тестируемому веб-серверу. Мутирующим фаззером могло бы быть что-то, что перехватывает и изменяет пакеты HTTP-запросов перед их отправкой веб-серверу.

Для того, чтобы понять, как создавать эффективные фаззеры, нужно вначале рассмотреть общие классы ошибок, которые они способны выявлять [1]. Ниже представлена подборка наиболее общих ошибок, существующих в приложениях сегодня. Далее мы покажем вам, как их можно спровоцировать с помощью вашего собственно фаззера.

8.1 Bug Classes

При анализе программного обеспечения на ошибки, хакер или реверс-инженер ищет определенные баги, которые позволят ему взять под свой контроль выполнением кода в приложении. Фаззеры могут предоставить автоматизированный способ поиска багов, которые помогают хакеру: получать контроль над системой, повышать привилегии в системе или воровать информацию, к которой у тестируемого приложения есть доступ. Мы сосредоточимся на багах, которые обычно обнаруживаются в программном обеспечении, которое работает в качестве независимого процесса. Обнаружение ошибок в таких программах, с наибольшей вероятностью, может привести к компрометации всей системы.

8.1.1 Buffer Overflows

Переполнение буфера является наиболее распространенным типом ошибок в программном обеспечении. Все виды безобидных функций управления памятью, манипулирования строками и даже внутренняя функциональность, являющаяся частью языка программирования, как раз и есть тем, что служит появлению ошибок переполнения буфера в программном обеспечении.

Короче говоря, переполнение буфера происходит тогда, когда данные сохраняются в области памяти, которая слишком мала, чтобы хранить их. Для объяснения этого понятия будем думать о буфере, как о ведре, которое может содержать 10 литров воды. Ничего не случится, если мы нальем в ведро две чашки воды, или заполним его наполовину, или даже до краев. Но все мы знаем, что случится, если попытаться налить 15 литров воды – вода выльется на пол… По сути тоже самое происходит и в программном обеспечении; когда имеется слишком много воды (данных), она выливается из ведра (буфер) и разливается по полу (память). Когда атакующий может управлять способом перезаписи памяти, он находит на пути к получению возможности полного выполнения кода и, в конечном счете, компрометации системы в той или иной форме. Есть два основных типа переполнения буфера: переполнение стека и переполнение кучи. Эти типы ведут себя совершенно по разному, но приводят к одному и тому же результату – атакующий контролирует выполнение кода.

Переполнение стека является характерным переполнением буфера, которое впоследствии перезаписывает данные в стеке, что может быть использовано в качестве средства контроля над выполнением программы. Выполнение кода, может быть осуществлено из-за переполнения стека атакующим, что позволит: перезаписать адрес возврата из функции, заменить указатель на функции, заменить значения переменных или изменить цепочку выполнения обработчиков исключения в нутрии приложения.

Переполнение кучи происходит в пределах сегмента кучи, процесса выполнения, где приложение динамически выделяет память во время выполнения. Куча состоит из блоков, которые связаны между собой метадынными. Когда происходит переполнение кучи, атакующий имеет возможность перезаписать метаданные в блоке, расположенном рядом с той областью, которая переполнилась. Когда это происходит, он может управлять записью в произвольные места памяти, которые могут включать: переменные, указатели на функции, маркеры безопасности (security tokens) или любое количество других важных структур данных, которые могут храниться в куче во время переполнения. Отследить переполнение кучи на начальной стадии может быть довольно сложной задачей, поскольку блоки, которые были повреждены, могут использоваться через некоторый промежуток времени. Эта задержка, до срабатывания нарушения прав доступа, может создать некоторые проблемы, когда вы будете пытаться отследить сбой, во время выполнения фаззинга.

С точки зрения фаззинга, для того чтобы выявить переполнение буфера, нужно просто попытаться передать очень большое количество данных в тестируемое приложение, в надежде, что это вызовет переполнение.

Далее мы рассмотрим целочисленные переполнения, являющиеся еще одним распространенным классом ошибок, которые содержатся в программном обеспечении.

8.1.2 Integer Overflows

Целочисленное переполнение является интересным классом ошибок. Оно основано на используемом размере целых чисел, и том, как процессор обрабатывает арифметические операции над этими числами. Целое число (со знаком), размер которого равен 2 байтам, может содержать значения от -32767 до 32767. Целочисленное переполнение происходит при попытке сохранить значение вне этого диапазона (т.е. число больше 32767 или меньше -32767, или другими словами оно не умещается в 2 байта). В этом случае процессор отбрасывает биты старшего разряда, чтобы успешно сохранить значение. На первый взгляд это не похоже на большую проблему, но давайте посмотрим пример, в котором показано, как целочисленное переполнение может привести к выделению слишком маленького места и возможно, в результате, в будущем приведет к переполнению буфера.

MOV EAX, [ESP + 0x8]
LEA EDI, [EAX + 0x24]
PUSH EDI
CALL msvcrt.malloc

Первая инструкция берет параметр из стека [ESP + 0x8] и загружает его в EAX. Следующая инструкция складывает 0x24 c EAX и сохраняет результат в EDI. Затем мы передаем результат этого сложения в функции выделения памяти malloc, в качестве единственного параметра, который является размером запрашиваемой области памяти для выделения. Все выглядит довольно безобидно, правда? Если предположить что в EAX содержится очень большое число, которое находится рядом с максимально возможным значением, то в случае добавления, например, 0x24 произойдет его переполнение, и в конечном итоге – EAX будет содержать маленькое число. Посмотрите на Листинг 8-1. На нем можно видеть то, что происходило бы, если бы параметр стека находился под нашим контролем, в момент, когда мы передали бы в нем (параметре) большое значение 0xFFFFFFF5.

Listing 8-1: Arithmetic operation on a signed integer under our control

Stack Parameter => 0xFFFFFFF5
Arithmetic Operation => 0xFFFFFFF5 + 0x24
Arithmetic Result => 0x100000019 (larger than 32 bits)
Processor Truncates => 0x00000019

Если это произойдет, то malloc выделит только 0x19 байт, которые могли бы быть намного меньшей частью памяти, чем та, какую разработчик рассчитывал выделить. Если в этот небольшой буфер предполагается сохранить большую порцию данных, то произойдет переполнение буфера. Для переполнения целого числа с помощью фаззера, мы должны убедиться, что передаем как высокие положительные числа, так и низкие отрицательные в попытке достигнуть целочисленного переполнения, которое могло бы привести к нежелательному поведению тестируемого приложения или даже полного переполнения буфера.

Далее мы перейдем к атакам на форматную строку, которые являются еще одним распространенным классом ошибок, в современном программном обеспечении.

8.1.3 Format String Attacks

Атаки на форматную строку, включают возможность ввода данных (атакующим), которые обрабатываются как спецификаторы в определенных функциях обработки строк, таких как, например, функция printf языка С. Давайте сначала рассмотрим прототип функции printf:

int printf( const char * format, ... );

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

int test = 10000;
printf("We have written %d lines of code so far.", test);
Output:

We have written 10000 lines of code so far.

Парметр %d является спецификатором. Если криворукий программист, забудет положить, какое-либо значение в спецификатор, перед вызовом функции, то вы увидите что-то вроде этого:

char* test = "%x";
printf(test);
Output:

5a88c3188

Это сильно отличается от того, что мы видели ранее. Если в строке передаваемой функции printf, присутствует спецификатор, который не связан с каким-либо аргументом функции, то в качестве недостающего аргумента функция printf используются значения из стека! В нашем случае число 0x5a88c3188, которое вы видите, является либо частью каких-то данных сохраненных в стеке, либо указателем на данные в памяти. Есть два наиболее интересных спецификатора %s и %n. Спецификатор %s говорит строковой функции сканировать память на наличие строки до тех пор, пока она не встретит NULL-байт, указывающий на конец строки. Это удобно либо для чтения больших объемов данных, либо для того, чтобы узнать, что храниться по определенному адресу, либо для вызова сбоя в приложении, обратившись к памяти, запрещенной для чтения. Спецификатор %n уникален в том, что позволяет вам писать данные в память, вместо их чтения. Это позволяет атакующему перезаписать адрес возврата или указатель на функцию, что в любом случае приведет к произвольному выполнению кода. С точки зрения фаззинга, нам нужно просто убедиться, что that the test cases we are generating pass in some of these format specifiers in an attempt to exercise a misused string function that accepts our format specifier.

Теперь, когда мы рассмотрели наиболее распространенные классы ошибок, пришло время приступить к созданию нашего первого фаззера. Это будет простой генерирующий файловый фаззер, который сможет мутировать в любой файловый формат. Мы также задействуем PyDbg для контроля и отслеживания сбоев в тестируемом программном обеспечении. Вперед!

8.2 File Fuzzer

Уязвимости формата файла быстро становятся вектором для атак с клиентской стороны, поэтому, естественно, что мы должны быть заинтересованы в поиске ошибок в парсерах формата файлов. К тому же, нам нужно иметь возможность работать со всеми различными видами форматов, чтобы получить наибольшую выгоду, причем не важно, тестируем ли мы антивирусные продукты или программы для чтения текста. Мы также должны убедиться в том, что у нас подключена некоторая отладочная функциональность, с помощью которой мы сможем поймать информацию о краше (crash), который, в свою очередь, позволит определить – нашли ли мы эксплуатируемую уязвимость или нет. Под конец, мы добавим некоторые возможности электронной почты, чтобы уведомлять и посылать вам информацию о сбоях (crash) всякий раз, как они будут происходить. Это может быть полезно, если у вас есть несколько фаззеров, работающих с множеством целей, и вам нужно знать, когда можно приступить к исследованию сбоя. Первым шагом должно быть создание скелета класса, с функциональностью простого выбора файлов (случайным образом), которая будет заботиться об открытии случайных файлов для мутации. Откройте новый Python файл, назовите его file_fuzzer.py и введите следующий код.

file_fuzzer.py

from pydbg import *
from pydbg.defines import *

import utils
import random
import sys
import struct
import threading
import os
import shutil
import time
import getopt

class file_fuzzer:

    def __init__(self, exe_path, ext, notify):

        self.exe_path = exe_path
        self.ext = ext
        self.notify_crash = notify
        self.orig_file = None
        self.mutated_file = None
        self.iteration = 0
        self.exe_path = exe_path
        self.orig_file = None
        self.mutated_file = None
        self.iteration = 0
        self.crash = None
        self.send_notify = False
        self.pid = None
        self.in_accessv_handler = False
        self.dbg = None
        self.running = False
        self.ready = False

        # Optional
        self.smtpserver = 'mail.nostarch.com'
        self.recipients = ['jms@bughunter.ca',]
        self.sender = 'jms@bughunter.ca'
        
        self.test_cases = [ "%s%n%s%n%s%n", "\xff", "\x00", "A" ]

    def file_picker( self ):

        file_list = os.listdir("examples/")
        list_length = len(file_list)
        file = file_list[random.randint(0, list_length-1)]
        shutil.copy("examples\\%s" % file,"test.%s" % self.ext)

        return file

Скелет класса, нашего файлового фаззера, определяет глобальные переменные для отслеживания основной информации. Функция file_picker просто использует встроенные функции из Python для того, чтобы получить список файлов в каталоге и случайным образом выбрать один из имеющихся для мутации. Теперь нам нужно сделать некоторую работу: загрузить тестируемое программное обеспечение; отследить его сбои (crashers) и завершить его, когда разбор (parsing) документа будет закончен. На первом этапе нужно загрузить тестируемое ПО в поток (thread) отладчика и установить обработчик нарушения доступа (access violation handler). Затем нужно породить второй поток, чтобы контролировать первый. Это позволит второму потоку, по прошествии определенного количества времени, убить первый. Также будет добавлена функция уведомления по email. Давайте добавим эти функции в наш класс.

file_fuzzer.py

...

    def fuzz( self ):

        while 1:
            (#1): if not self.running:

                # We first snag a file for mutation
                self.test_file = self.file_picker()
                (#2): self.mutate_file()

                # Start up the debugger thread
                (#3): pydbg_thread = threading.Thread(target=self.start_debugger)
                pydbg_thread.setDaemon(0)
                pydbg_thread.start()

                while self.pid == None:
                    time.sleep(1)

                # Start up the monitoring thread
                (#4): monitor_thread = threading.Thread(target=self.monitor_debugger)
                monitor_thread.setDaemon(0)
                monitor_thread.start()

                self.iteration += 1
            else:
                time.sleep(1)

    # Our primary debugger thread that the application
    # runs under
    def start_debugger(self):

        print "[*] Starting debugger for iteration: %d" % self.iteration
        self.running = True
        self.dbg = pydbg()

        self.dbg.set_callback(EXCEPTION_ACCESS_VIOLATION,self.check_accessv)
        pid = self.dbg.load(self.exe_path,"test.%s" % self.ext)

        self.pid = self.dbg.pid
        self.dbg.run()

    # Our access violation handler that traps the crash
    # information and stores it
    def check_accessv(self,dbg):

        if dbg.dbg.u.Exception.dwFirstChance:

            return DBG_CONTINUE

        print "[*] Woot! Handling an access violation!"
        self.in_accessv_handler = True
        crash_bin = utils.crash_binning.crash_binning()
        crash_bin.record_crash(dbg)
        self.crash = crash_bin.crash_synopsis()

        # Write out the crash informations
        crash_fd = open("crashes\\crash-%d" % self.iteration,"w")
        crash_fd.write(self.crash)

        # Now back up the files
        shutil.copy("test.%s" % self.ext,"crashes\\%d.%s" % (self.iteration,self.ext))
        shutil.copy("examples\\%s" % self.test_file,"crashes\\%d_orig.%s" % (self.iteration,self.ext))

        self.dbg.terminate_process()
        self.in_accessv_handler = False
        self.running = False

        return DBG_EXCEPTION_NOT_HANDLED

    # This is our monitoring function that allows the application
    # to run for a few seconds and then it terminates it
    def monitor_debugger(self):

        counter = 0

        print "[*] Monitor thread for pid: %d waiting." % self.pid,
        while counter < 3:
            time.sleep(1)
            print counter,
            counter += 1

        if self.in_accessv_handler != True:
            time.sleep(1)
            self.dbg.terminate_process()
            self.pid = None
            self.running = False
        else:
            print "[*] The access violation handler is doing its business. Waiting."

            while self.running:
                time.sleep(1)

    # Our emailing routine to ship out crash information
    def notify(self):

        crash_message = "From:%s\r\n\r\nTo:\r\n\r\nIteration: %d\n\nOutput:\n\n %s" % (self.sender, self.iteration, self.crash)

        session = smtplib.SMTP(smtpserver)
        session.sendmail(sender, recipients, crash_message)
        session.quit()
    
        return

Теперь у нас есть основная логика, позволяющая управлять приложением во время фаззинга, поэтому давайте кратко рассмотрим ее функции. На первом шаге (#1) нужно убедиться, что фаззинг еще не запущен. Флаг self.running также будет установлен, если обработчик нарушения прав доступа (access violation handler) будет занят составлением отчета о сбое (crash). После того как был выбран файл для мутации, он передается в простую функцию мутации (#2), которую мы в скорее напишем.

После того как функция мутатора отработала, мы запускаем наш отладочный поток (#3), который просто запускает приложение и передает покалеченный (mutated) файл в качестве аргумента командной строки. Затем мы ожидаем в цикле пока отладочный поток зарегистрирует PID тестируемого приложения. Как только мы получили PID – запускаем контролирующий поток (#4), чья работа заключается в уничтожении (kill) приложения по прошествии определенного количества времени. После того как контролирующий поток был запущен, мы увеличиваем счетчик (count) и повторно входим в наш основной цикл до тех пор, пока не придет время выбрать новый файл и начать фаззинг снова. Теперь давайте добавим простую функцию мутации.

file_fuzzer.py

...
    def mutate_file( self ):

        # Pull the contents of the file into a buffer
        fd = open("test.%s" % self.ext, "rb")
        stream = fd.read()
        fd.close()

        # The fuzzing meat and potatoes, really simple
        # Take a random test case and apply it to a random position
        # in the file
        (#1): test_case = self.test_cases[random.randint(0,len(self.test_cases)-1)]

        (#2): stream_length = len(stream)
        rand_offset = random.randint(0, stream_length - 1 )
        rand_len = random.randint(1, 1000)

        # Now take the test case and repeat it
        test_case = test_case * rand_len

        # Apply it to the buffer, we are just
        # splicing in our fuzz data
        (#3): fuzz_file = stream[0:rand_offset]
        fuzz_file += str(test_case)
        fuzz_file += stream[rand_offset:]

        # Write out the file
        fd = open("test.%s" % self.ext, "wb")
        fd.write( fuzz_file )
        fd.close()

        return

Это элементарный мутатор. Мы случайным образом выбираем текст из глобального списка (#1); затем выбираем случайно смешение и длину новых данных, которые будут записаны в файл (#2). Затем мы разрезаем файл (#3), используя смещение и длину, чтобы внести в него изменения. Когда мы закончим и запишем файл, отладочный поток сразу же использует его для тестирования приложения. Теперь давайте добавим код управления нашим фаззером.

file_fuzzer.py

...
    def print_usage():

        print "[*]"
        print "[*] file_fuzzer.py -e <Executable Path> -x <File Extension>"
        print "[*]"

        sys.exit(0)

if __name__ == "__main__":

    print "[*] Generic File Fuzzer."
    
    # This is the path to the document parser
    # and the filename extension to use
    try:
        opts, argo = getopt.getopt(sys.argv[1:],"e:x:n")
    except getopt.GetoptError:
        print_usage()

    exe_path = None
    ext = None
    notify = False

    for o,a in opts:
        if o == "-e":
            exe_path = a
        elif o == "-x":
            ext = a
        elif o == "-n":
            notify = True

    if exe_path is not None and ext is not None:
        fuzzer = file_fuzzer( exe_path, ext, notify )
        fuzzer.fuzz()
    else:
        print_usage()

Тут мы добавили возможность обработки параметров командной строки. Параметр -e это путь к тестируемой программе. Параметр -x является расширением файла, которое относится к тестируемому приложения (например, “.txt”, “.pdf”, etc). Необязательный параметр -n говорит фаззеру, хотим ли мы получать уведомления или нет. Теперь давайте протестируем скрипт.

Лучший способ, который мне пришел в голову, чтобы протестировать на работоспособность файловый фаззер – это испытать его на целевом приложении, наблюдая за результатами мутации в действии. Не существует лучшего способа протестировать фаззинг текстовых файлов, чем использование Windows Notepad в качестве тестового приложения. Используя этот способ можно видеть изменения текста на каждой итерации, вместо того чтобы использовать для этих целей hex-редактор или утилиту двоичного сравнения (binary diffing tool). Прежде чем начать, создайте папку examples и папку crashes, в той же директории из которой вы будете запускать скрипт file_fuzzer.py. После создания каталогов, создайте и разместите несколько фиктивных тестовых файлов в папке examples. Для запуска фаззера, используйте следующую командную строку:

python file_fuzzer.py -e C:\\WINDOWS\\system32\\notepad.exe -x .txt

После чего начнет запускаться Notepad, и вы сможете видеть, как ваши тестовые файлы изменяются. Убедившись, что файлы изменяются соответствующим образом, можно взять файловый фаззер и протестировать его на любом другом целевом приложении. Позвольте закончить главу некоторыми соображениями касаемыми будущего развития для этого фаззера.

8.3 Соображения

Хотя мы создали фаззер, который может найти некоторые багги, при наличии достаточного времени, есть ряд возможных улучшений, которые вы могли бы реализовать (по желанию). Думайте об этом как о возможном домашнем задании.

8.3.1 Code Coverage

Покрытие кода (code coverage) – это метрика, которая измеряет, сколько выполнилось кода целевой программы во время ее тестирования. Эксперт по фаззингу Чарли Миллер (Charlie Miller) опытным путем доказал, что увеличение покрытия кода приведет к увеличению числа найденных ошибок [2]. Мы не можем не согласиться с капитанской логикой! Простой способ, который вы можете использовать для измерения покрытия кода, заключается в том, чтобы использовать любой из выше упомянутых отладчиков и установить программные брейкпойнты (soft breakpoints) на все функции в нутрии тестируемого приложения. Простое сохранение счетчика того, сколько функций попало в каждый тест – даст вам представление о том, насколько эффективен ваш фаззер в рамках тестируемого приложения. Есть и более сложные примеры, реализации покрытия кода, которые вы вправе применять к вашему фаззеру.

8.3.2 Automated Static Analysis

Автоматизированный статический анализ двоичных файлов для нахождения горячих точек в целевом коде может быть чрезвычайно полезным для багхантера. Что-то столь же простое, как отслеживание всех вызовов функций, которые обычно не правильно используются (например, strcpy) и мониторинг за их вызовами, может дать положительные результаты. Более продвинутый статический анализ мог бы помочь в поиске других интересных участков. Чем больше ваш фаззер знает о тестируемой программе, тем больше у него шансов найти ошибки.

Все выше – это только некоторые из улучшений, которые можно применить к созданному файловому фаззеру или любому другому фаззеру, который вы создадите в будущем. Когда будете создавать собственный фаззер, очень важно создать его достаточно расширяемым, чтобы можно было добавлять новую функциональность в будущем. Вы удивитесь, как часто вы будете доставать все тот же фаззер, в течении долгого времени, и вы будете благодарить себя за небольшую работу, проделанную над дизайном фронтэнда, благодаря которому можно легко вносить изменения в будущем. Теперь, когда мы самостоятельно создали простой файловый фаззер, пришло время перейти к использованию Sulley, Python-ориентированного фреймворка для фаззинга, созданного Педрамом Амини (Pedram Amini) и Араном Портным (Aaron Portnoy) из TippingPoint. После этого мы перейдем к фаззеру ioctlizer, написанному мной, который предназначен для нахождения ошибок в подпрограммах управления вводом-выводом (I/O Control Routines), которые используются во множестве драйверах Windows.

© Translated by Prosper-H from r0 Crew

[1] An excellent reference book, and one you should definitely add to your bookshelf, is Mark Dowd, John McDonald, and Justin Schuh’s The Art of Software Security Assessment: Identifying and Preventing Software Vulnerabilities (Addison-Wesley Professional, 2006).

[2] Charlie gave an excellent presentation at CanSecWest 2008 that illustrates the importance of code coverage when bughunting. See http://cansecwest.com/csw08/csw08-miller.pdf. This paper was part of a larger body of work Charlie co-authored. See Ari Takanen, Jared DeMott, and Charlie Miller, Fuzzing for Software Security Testing and Quality Assurance (Artech House Publishers, 2008).