Автоматизация поиска уязвимостей с помощью angr
В предыдущей статье мы познакомились с основами символьного исполнения и фреймворком angr, и научились находить пути к определенным участкам кода. Однако истинный потенциал символьного исполнения раскрывается в более сложных задачах, чем просто проверка достижимости кода. Одной из таких задач является автоматическая генерация эксплойтов (Automatic Exploit Generation, AEG).
В этой статье мы сделаем следующий шаг и создадим простой, но функциональный AEG. Нашей целью будет поиск и эксплуатация одной из классических уязвимостей — уязвимости форматной строки. Мы углубимся в более продвинутые компоненты angr, такие как загрузчик (CLE), соглашения о вызовах (calling conventions), прототипы функций, и освоим более тонкое управление состояниями через stashes.
2. Уязвимости форматной строки (CWE-134)
В этом разделе мы подробно остановимся на описании уязвимости форматной строки для тех читателей, которые впервые сталкиваются с данным типом уязвимости. Те, кто уже знаком с ней, можете переходить к следующей части статьи.
Для примера, мы будем разбирать printf, однако это не единственная функция, которая подвержена данному типу уязвимости. Такие функции как: fprintf, sprintf, snprintf, wprintf и другие, также уязвимы. Но несмотря на целый список функций, правило защиты всего одно и оно очень простое:
Никогда не передавайте данные, контролируемые пользователем, в качестве первого аргумента форматирующей функции.
char user_input[100]; fgets(user_input, sizeof(user_input), stdin); printf(user_input); // <-- уязвимость! printf("%s", user_input); // <-- безопасно!
Смысл форматной строки в том, чтобы создать строку для вывода динамически, с теми данными, которые хранятся в переменных. Для этой цели используются специальные обозначения — спецификаторы формата. Наиболее распространенные:
- %d — десятичные числа.
- %x — шестнадцатеричные числа.
- %p — указатель, в соответствии с битностью системы
- %s — трактует аргумент как адрес на C-строку.
- %n — записывает в память количество уже выведенных символов.
Форматная строка передается первым аргументом, а дальше передаются те переменные, значения которых будут помещаться вместо спецификаторов. Но в чем уязвимость? Откуда система берет значения, если переменных нет? Обычно в учебниках по C/C++ такие ситуации обозначают как «неопределенное поведение» (undefined behavior). Чтобы разобраться в этих вопросах нужно обратиться к таким понятиям как «стек» и «соглашение о вызовах» (calling conventions), постараюсь кратко описать их значение и работу на примере систем с 32-битной архитектурой.
Стек — это область памяти, работающая по принципу LIFO (Last-In, First-Out), или «последним пришел — первым ушел». Его часто описывают на примере стопки тарелок: вы можете положить новую тарелку только наверх и взять тоже только верхнюю.
В контексте выполнения программы стек используется для временного хранения различных данных:
- Аргументы функции
- Адрес возврата
- Локальные переменные
- Служебная информация
Для работы со стеком, в языке ассемблера для архитектуры x86, используются 2 регистра: EBP — указатель на базу стека и ESP — указатель на вершину стека.
Соглашение о вызовах — это набор правил, по которым компилятор организует вызов функции. Иногда может обозначаться как Application Binary Interface — ABI. Эти правила определяют:
- Как передаются аргументы (через стек или регистры).
- В каком порядке они передаются.
- Кто отвечает за очистку стека после вызова — вызывающая функция (caller) или вызываемая (callee).
- Где находится возвращаемое значение.
Без этих правил одна скомпилированная функция просто не смогла бы «понять», как правильно взаимодействовать с другой. Например в ABI stdcall, которое является основным в 32-битных системах:
- Аргументы передаются в функцию через стек.
- Они помещаются в стек в обратном порядке: справа налево.
- Вызываемая функция (callee) сама отвечает за очистку стека от аргументов перед возвратом.
- Возвращаемое значение кладется в регистр EAX.
С точки зрения уязвимости форматной строки нас в первую очередь интересует передача аргументов в вызываемую функцию. Таким образом, исходя из соглашения stdcall перед передачей управления функции printf, необходимо поместить все аргументы в стек справа-налево.
Так, для вызова printf("%d %d «, 100, 200); подготовка стека будет выглядеть следующим образом:

Но остается вопрос, а как printf понимает, какое количество аргументов ему передано? Ответ на него — никак. Подразумевается, что как минимум 1 аргумент (форматная строка) будет передана, при ее обработке, когда функция встречает спецификатор формата, она берет значение из стека, где «по идее» должны находиться аргументы.
Если бы в примере выше функция вызывалась так:
printf("%d %d %x", 100, 200);
В таком случае, на экран вывелось бы «100 200 0xdeadbeef». Так как в printf передано всего 2 аргумента (за исключением форматной строки), а спецификаторов 3, то 3-ий аргумент будет извлечен из стека не смотря на то, что эти данные никакого отношения к printf не имеют, а являются локальной переменной в вызывающей функции.
В некоторых реализациях, в частности, в библиотеке GNU C, можно использовать номера аргументов в спецификаторах формата для переупорядочения вывода. Это делается с помощью указания номера аргумента после %, затем $ и спецификатора формата, например:
printf("c: %3$d, b: %2$d, a: %1$d", a, b, c);
Исходя из таких возможностей, с использованием некоторых техник, злоумышленнику доступно чтение (а с использованием спецификатора %n — запись) по любому адресу в памяти процесса. Описание техник, позволяющих выполнить arbitrary read/write, выходят за рамки данной статьи.
3. Внутренние механизмы angr
3.1. Загрузка бинарного файла
Любой анализ в angr начинается с загрузки исполняемого файла через создание объекта Project.
import angr project = angr.Project("path_to_bin")
Загрузкой бинарного файла занимается модуль, разработанный авторами angr — CLE. CLE загружает сам файл, связанные с ними библиотеки, вычисляет импорты и подготавливает абстракцию памяти процесса так, как этот файл загружался бы загрузчиком соответствующей ОС.
Сам объект этого загрузчика доступен через project.loader:
>>> project.loader <Loaded vuln, maps [0x400000:0xb07fff]>
Загрузчик хранит в себе информацию о загруженных объектах, их список можно посмотреть через атрибут all_objects, объект самого анализируемого файла можно получить через main_object.
>>> project.loader.all_objects [<ELF Object vuln, maps [0x400000:0x40406f]>, <ELF Object libc.so.6, maps [0x500000:0x728e4f]>, <ELF Object ld-linux-x86-64.so.2, maps [0x800000:0x83b2d7]>, <ExternObject Object cle##externs, maps [0x900000:0x97ffff]>, <ELFTLSObjectV2 Object cle##tls, maps [0xa00000:0xa1500f]>, <KernelObject Object cle##kernel, maps [0xb00000:0xb07fff]>] >>> main_obj = project.loader.main_object >>> main_obj <ELF Object vuln, maps [0x400000:0x40406f]>
Каждый объект позволяет динамически извлекать большое количество информации, например, адрес точки входа (Entry Point), базовый адрес, секции, PLT и другую информацию, которая может пригодиться при анализе.
>>> hex(main_obj.entry) '0x4010f0' >>> hex(main_obj.mapped_base) '0x400000' >>> main_obj.sections.raw_list [<Unnamed | offset 0x0, vaddr 0x0, size 0x0>, .......... <.plt | offset 0x1020, vaddr 0x401020, size 0x70>, <.plt.sec | offset 0x1090, vaddr 0x401090, size 0x60>, <.text | offset 0x10f0, vaddr 0x4010f0, size 0x295>, <.fini | offset 0x1388, vaddr 0x401388, size 0xd>, <.rodata | offset 0x2000, vaddr 0x402000, size 0x147>, .........] >>> main_obj.plt {'puts': 4198544, '__stack_chk_fail': 4198560, 'printf': 4198576, 'strcspn': 4198592, 'fgets': 4198608, 'strcmp': 4198624}
Функциональность загрузчика также позволяет находить символы, проверять является ли функция импортируемой или экспортируемой и находить ее адрес:
>>> printf_libc = project.loader.find_symbol("printf") >>> printf_libc <Symbol "printf" in libc.so.6 at 0x5606f0> >>> printf_main = main_obj.get_symbol("printf") >>> printf_main <Symbol "printf" in vuln (import)> >>> printf_main.resolvedby <Symbol "printf" in libc.so.6 at 0x5606f0> >>> printf_libc.is_export True >>> printf_main.is_export False >>> printf_main.is_import True >>> hex(printf_libc.rebased_addr) '0x5606f0'
Обратите внимание, что с помощью атрибута .resolvedby можно получить объект, который относится к самой библиотечной функции, а не к записи в таблице импорта.
Существует большое количество параметров, которые можно указать при создании проекта. Наиболее часто используемый параметр — «auto_load_libs». Он отвечает за загрузку соответствующих библиотек при загрузке файла, при значении True (по умолчанию) библиотеки будут загружены, а в противном случае — нет. Этот параметр довольно сильно влияет на производительность, поэтому очень часто указывается как False.
3.2. SimProcedures и хуки
Особое внимание при загрузке необходимо уделять библиотечным функциям. С одной стороны они могут влиять на путь исполнения, добавляя свои ограничения, а с другой быть очень сложными для анализа, что вызовет взрыв состояний (state explosion).
Для решения этой проблемы в angr разработано большое количество заглушек — SimProcedures, которые имитируют работу большого количества (но не всех) функций из разных библиотек, например, libc, glibc, ntdll, advapi32. По умолчанию, Project пытается заменить все внешние вызовы на SimProcedures, а в том случае, если готовой заглушки нет, то управление переходит в код самой функции. Однако если auto_load_libs установлен как False, тогда вместо фактически библиотечных функций используется универсальная заглушка «ReturnUnconstrained», которая не делает ничего, а просто возвращает символьную переменную.
Механизм, с помощью которого происходит перехват вызова библиотечных функций называется «hooking», на каждом шаге работы SimulationManager angr проверяет: установлен ли хук на данный адрес. Их можно легко устанавливать самостоятельно, например для частей кода, которые не нуждаются в анализе, или если есть инструкции, которые не поддерживается angr, например, специфичные системные вызовы (syscalls).
Для установки хука необходимо сначала объявить функцию, которая будет выполняться. Существует два метода объявления и установки хука:
>>> def my_hook(state): ... state.regs.rax = 1 >>> proj.hook(0x10000, length=16, my_hook()) >>> @proj.hook(0x10000, length=16) ... def my_hook(state): ... state.regs.rax = 1
Независимо от способа результат будет идентичен. В параметре «length» указывается количество байт, которые angr должен пропустить при хуке. Также можно перехватывать вызовы библиотечных функций через proj.hook_symbol(), в таком случае вместо адреса необходимо указывать имя функции. Для управления хуками используются следующие методы:
- proj.is_hooked(addr) — проверка наличия хука для адреса
- proj.unhook(addr) — для удаления хука на указанном адресе
- proj.hooked_by(addr) — для проверки (поиска) что будет выполнено в результате хука по указанному адресу
3.3. SimState, прототипы и stashes
Как уже было описано в предыдущей статье, для отражения символьного состояния в любой момент символьного выполнения программы angr использует SimState. Инициализация начального состояния является необходимым условием для запуска символьного выполнения, независимо от того, хотим мы анализировать программу начиная с Entry Point, или с любого другого места.
Первое и самое базовое в символьных состояниях — это интерфейс доступа к памяти и к регистрам. Доступ к регистрам осуществляется через state.regs:
>>> state = proj.factory.entry_state() >>> state.regs.rbp <BV64 0x0> >>> state.regs.rsp <BV64 0x7fffffffffeff98> >>> state.regs.rbp = state.regs.rsp >>> state.regs.rbp <BV64 0x7fffffffffeff98>
Для доступа к памяти рекомендуется использовать интерфейс state.mem:
>>> state.mem[0x1000] <<untyped> <unresolvable> at 0x1000> >>> state.mem[state.regs.rsp] <<untyped> <unresolvable> at 0x7fffffffffeff98>
В результате обращения к памяти через этот интерфейс мы получаем объект класса SimMemView, но как вы можете обратить внимание этот объект «untyped», т.е. мы попытались прочитать значение из памяти явно не указав тип данных, которые мы хотим прочитать. Angr поддерживает большое число различных типов: byte, char, int, word, dword, string, и другие более сложные типы:
>>> state.mem[state.regs.rsp].uint64_t <uint64_t <BV64 0x1> at 0x7fffffffffeff98> >>> state.mem[state.regs.rsp].uint64_t.resolved <BV64 0x1>
Также существует и более низкоуровневый способ доступа к памяти — через state.memory, в таком случае читать из памяти можно с помощью state.memory.load(), а записывать — через state.memory.store(). В таком случае нужно самостоятельно интерпретировать данные с учетом их размера и порядка байтов (byte order): Big Endian/Little Endian.
Для создания SimState в angr существует несколько конструкторов:
- .blank_state() - «пустое состояние» — выполняет минимально необходимый набор подготовительных операций, для запуска исполнения с указанного адреса
- .entry_state() - конструктор для запуска исполнения с точки входа в программу
- .full_init_state() - очень похоже на .entry_state() за исключением того, что выполнение начинается со специальной SimProcedure, которая играет роль динамического загрузчика, вызывая каждую из функций инициализатора, которые должны быть вызваны до того, как выполнение достигнет точки входа (например инициализация библиотек).
- .call_state() - подготавливает состояние для запуска с конкретной функции, .call_state(addr, arg1, arg2, arg3 ...), где addr — адрес функции, а дальше идут аргументы по порядку, так, как будто вы вызываете ее непосредственно из кода. Если необходимо передать указатель на какие-то данные, разработчики советуют использовать PointerWrapper: angr.PointerWrapper("point to me!«).
Реализация call_state стала возможна благодаря возможности angr автоматически определять, какое соглашение о вызовах использует загруженный исполняемый файл, ведь, как мы уже упомянули ранее, от этого зависит как и где располагаются аргументы перед вызовом функции.
>>> project.factory.cc() <SimCCSystemVAMD64>
Через этот объект можно получить практически всю информацию, касающуюся данного соглашения о вызовах. Такую как: используемый порядок аргументов, местоположение этих аргументов (регистры/стек) и т.д.
Довольно часто при поиске уязвимостей необходимо определить, имеем ли мы контроль над определенным аргументом. Благодаря angr.calling_conventions мы можем делать скрипты для анализа, которые не будут зависеть от конкретного соглашения о вызовах, а следовательно, от архитектуры и операционной системы.
Более того, angr позволяет работать с прототипами функций, для этого он сначала распознает прототип и создает специальное внутреннее представление, с помощью которого в дальнейшем можно определять местоположение аргументов.
Возьмем для примера прототип функции printf:
>>> from angr.calling_conventons import parse_signature >>> printf_prot = "int printf(char*, ...)” >>> sym_prototype = parse_signature(printf_prot) >>> sym_prototype (char*, ...) -> int
Однако большинство типов, которые используются в прототипах WinAPI, angr не умеет обрабатывать, их следует самостоятельно заменять на соответствующие стандартные типы. Например, NTSTATUS -> int; любые структуры заменять на указатель типа void.
После подготовки прототипа мы можем очень легко получать доступ к аргументам функции c помощью метода arg_locs():
>>> args = project.factory.cc().arg_locs(sym_prototype) >>> format_string = args[0] >>> format_string <rdi> >>> format_string.set_value(state, 0x10) >>> format_string.get_value(state) <BV64 0x10>
Во втором разделе статьи мы рассмотрели stdcall для наглядной демонстрации работы со стеком. Однако в 64-битных Linux-системах, как в нашем практическом примере, первые аргументы передаются через регистры (например, первый аргумент в RDI). К счастью, angr автоматически определяет правильное соглашение о вызовах, поэтому наш подход с arg_locs() будет работать корректно в обоих случаях
3.4. SimulationManager
Наиболее важным интерфейсом символьного исполнения является SimulationManager. В предыдущей статье мы обсудили принцип его работы, но ничего кроме stashes и метода explore не рассмотрели, сегодня мы углубимся в его возможности.
Самый базовый способ продвижения анализа — это метод step(). По умолчанию он берет все состояния из хранилища active, исполняет для каждого из них один базовый блок кода (последовательность инструкций, заканчивающаяся инструкцией перехода) и помещает результирующие состояния обратно в active (или в другие хранилища, если путь завершился или разветвился). Но иногда требуется более тонкий контроль, например, исполнение строго определенного числа инструкций. Для этого у метода step() есть аргумент num_inst. Это особенно полезно, когда нужно остановиться непосредственно перед или после конкретной, критически важной инструкции.
Для полного исследования программы до тех пор, пока не останется активных путей, используется метод run(). Он будет циклически вызывать step() до тех пор, пока хранилище active не опустеет. В результате все состояния окажутся в конечных хранилищах, таких как deadended (пути, которые завершились штатно или по ошибке) или errored. Метод run() полезен для полного покрытия небольших программ, но для анализа сложных приложений он может быть слишком медленным из-за «взрыва состояний».
На практике нас редко интересуют все возможные пути выполнения. Обычно наша цель — достичь определенного участка кода (например, где находится уязвимость) или, наоборот, избежать тех участков, которые ведут заведомо не туда. Для таких задач идеально подходит метод explore().
Ключевые аргументы explore():
- find: Адрес (или список адресов), который мы хотим достичь. Как только состояние достигает одного из этих адресов, оно перемещается в хранилище found, и исследование этого пути прекращается.
- avoid: Адрес (или список адресов), которого мы хотим избежать. Если состояние достигает одного из этих адресов, оно перемещается в avoided, и этот путь отбрасывается.
Использование explore() — основной и наиболее эффективный способ проведения анализа в angr, так как он позволяет сфокусировать ресурсы на достижении конкретной цели.
SimulationManager предоставляет полный контроль над хранилищами. Мы можем вручную перемещать состояния между ними с помощью метода move(). Это открывает возможности для реализации сложных, специализированных стратегий анализа.
Например, если мы нашли несколько путей к цели (found), но хотим продолжить исследование одного из них, мы можем переместить его обратно в active.
>>> simgr.move(from_stash='found', to_stash='active') # Можно применить фильтр: переместить только одно, наиболее интересное состояние >>> simgr.move(from_stash='found', to_stash='active', filter_func=lambda s: s.addr == specific_address)
filter_func — это функция, которая принимает на вход SimState, а после проверки возвращает True или False.
4. Реализация AEG
Теперь, когда мы закончили с теоретической частью, можем смело переходить к написанию практической реализации скрипта для автоматического поиска уязвимостей форматной строки. К счастью, нам понадобится не весь рассмотренный сегодня функционал.
Возьмем небольшую программу для демонстрации уязвимости:
#include <stdio.h> #include <string.h> #include <stdlib.h> void vulnerable_function() { char note[128]; printf(">>> Access granted! You can leave a diagnostic note for the admins.\n"); printf(">>> Enter note: "); // Читаем ввод пользователя для заметки fgets(note, sizeof(note), stdin); printf("\n>>> Your note is: "); printf(note); } int main() { char password[32]; printf("--- Secure Admin Portal ---\n"); printf("Enter password: "); // Читаем пароль fgets(password, sizeof(password), stdin); // Убираем символ новой строки, который читает fgets password[strcspn(password, "\n")] = 0; // Аутентификация if (strcmp(password, "s3cr3t_p4ssw0rd!") == 0) { printf(">>> Password accepted.\n"); vulnerable_function(); } else { printf(">>> Incorrect password. Access denied.\n"); } return 0; }
Эта программа просто получает от пользователя заметку и выводит ее на экран, но доступ к этой функции получается по паролю. По аналогии с предыдущей статьей, нам нужно найти правильный ввод, чтобы попасть в уязвимую часть кода. И после проверить, возможна ли эксплуатация уязвимости форматной строки, и если да, то подготовить PoC.
Но в отличии от примера в предыдущей статье, мы не будем анализировать уязвимую программу, она нам понадобится только для проверки результата. Мы напишем универсальный скрипт, который позволит искать данный тип уязвимостей.
Начнем как всегда с создания проекта:
BINARY_PATH = "./vuln" project = angr.Project(BINARY_PATH, auto_load_libs=False)
Уязвимости форматной строки подвержены функции типа printf, в нашем скрипте мы остановимся именно на этой функции. Для начала нужно определить, есть ли printf в таблице импорта. После чего определить ее адрес в библиотеке, т.е. то место, куда передается управление при вызове данной функции. Такой подход универсален как для статического вызова, так и при динамической загрузке библиотек (в отличии от исследования перекрестных ссылок (xref)).
printf_sym = project.loader.main_object.get_symbol("printf") if printf_sym: printf_addr = printf_sym.resolvedby.rebased_addr print(f"[+] Найден адрес printf") else: print("[-] printf не найден") sys.exit(1)
Теперь представим ситуацию, что мы нашли вызов функции printf в коде, но он же может и не содержать уязвимости, как нам доказать обратное? Для того, чтобы это было уязвимостью, первый аргумент — форматная строка должна контролироваться пользователем, т.е. быть символьным. Осталось только определить, в каком регистре будет находиться первый аргумент. Для этого мы воспользуемся прототипом функции:
prototype_printf = parse_signature("int printf(char*, ...)") printf_args = project.factory.cc().arg_locs(prototype_printf) format_string = printf_args[0]
Далее создаем начальное состояние и SimulationManager:
state = project.factory.entry_state(stdin=angr.SimFile, add_options={angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY, angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS}) simgr = project.factory.simulation_manager(state)
У каждой программы существуют 3 системных потока для ввода/вывода информации (по умолчанию относятся к взаимодействию через интерфейс командной строки):
- stdin (Standard Input) — используется программой для получения ввода.
- stdout (Standard Output) — используется для вывода информации.
- stderr (Standard Error) — поток, который используется для вывода сообщений об ошибках.
В нашем примере ввод считывается из stdin с помощью fgets. Чтобы сделать этот ввод символьным, нужно соответствующим образом настроить начальный SimState. Хотя angr это делает сам по умолчанию, в дальнейшем могут возникнуть небольшие трудности. Когда будет найден printf с символьной форматной строкой, нужно будет добавить дополнительные условия: что в этой форматной строке могут содержаться спецификаторы формата. Так как существует вероятность, что форматная строка хоть и контролируется пользователем, но спецификаторы формата не могут в ней находиться — возможно, где-то по пути выполнения происходит санитизация (процесс очистки данных от вредоносных, опасных или ненужных элементов).
Способ сделать stdin символьным, описанный выше, позволит выгрузить его в символьную переменную, для которой мы и добавим ограничения. Способ, которым angr пользуется по умолчанию не позволяет так сделать, мы смогли бы только прочитать данные.
Искать сам printf мы будем с помощью simgr.explore(find=printf_addr). Как обсуждалось ранее, после того, как искомый адрес будет найден, это символьное состояние переходит из хранилища active в found, нам нужно проверить, является ли первый аргумент символьным и если нет, то переместить состояние обратно в active. Выполняется это с помощью метода .move с функцией фильтрации.
Так как в первый аргумент помещается не сама форматная строка, а указатель на нее, нужно проверить состояние тех данных, которые лежат по переданному адресу.
Итоговая конструкция будет выглядеть следующим образом:
state.mem[format_string.get_value(state)].char.resolved.concrete
Здесь мы сначала получаем адрес форматной строки (format_string.get_value(state)); затем обращаемся к памяти процесса по этому адресу (state.mem[....]), интерпретируем данные как символ (.char), получаем значение (.resolved) и проверяем, является ли оно конкретным (.concrete). Это свойство равно True, если данные — это конкретное число, и False, если они символьные. Нас интересуют как раз символьные данные, поэтому мы переносим в active только те состояния, где это условие не выполняется.
while simgr.active: simgr.explore(find=printf_addr) if simgr.found: simgr.move("found", "active", lambda state: state.mem[format_string.get_value(state)].char.resolved.concrete) if simgr.active: simgr.step()
После этого цикла в хранилище found будут находится состояния с потенциально уязвимыми вызовами printf, нам осталось только проверить, можно ли в эту форматную строку записать спецификаторы формата.
Для этого выгрузим весь stdin как символьную переменную:
stdin_size = found_stash.posix.stdin.size sym_stdin = found_stash.posix.stdin.load(0, stdin_size)
В этом случае stdin будет одной цельной символьной переменной, использовать в условиях какие-то ее части нельзя, но мы можем разбить ее на несколько отдельных переменных:
stdin_chrs = sym_stdin.chop(8)
Мы получим список символьных переменных, каждая из которых размером 8 бит, т.е. 1 символ в кодировке ASCII, используемой по-умолчанию в ОС Linux. Нам нужно, чтобы символы «**%» и, например «p**» стояли парой в любом месте, или на нулевом, или на первом и т.д., при этом сначала должен идти «%» И следом «р» Подготовим BVV значения, которые будем использовать в условии:
percent = claripy.BVV(ord('%'), 8) p_char = claripy.BVV(ord('p'), 8)
Итак, условие получается следующее:
And(stdin_chrs[i] == percent, stdin_chrs[i + 1] == p_char)
Создадим список из этих условий для каждой позиции:
and_constr = [claripy.And(stdin_chrs[i] == percent, stdin_chrs[i + 1] == p_char) for i in range(len(stdin_chrs[:-1]))]
И объединим их с помощью ИЛИ:
all_constr = claripy.Or(*and_constr)
На этом все, осталось только объединить все вместе и вывести полученную строку. Итоговый скрипт:
import angr from angr.calling_conventions import parse_signature import claripy import sys BINARY_PATH = "./vuln" project = angr.Project(BINARY_PATH, auto_load_libs=False) printf_sym = project.loader.main_object.get_symbol("printf") # Получаем адрес printf if printf_sym: printf_addr = printf_sym.resolvedby.rebased_addr print(f"[+] Найден адрес printf") else: print("[-] printf не найден") sys.exit(1) prototype_printf = parse_signature("int printf(char*, ...)") printf_args = project.factory.cc().arg_locs(prototype_printf) format_string = printf_args[0] # Создаём state с символьным stdin state = project.factory.entry_state(stdin=angr.SimFile, add_options={angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY, angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS}) simgr = project.factory.simulation_manager(state) # Ищем путь до printf while simgr.active: simgr.explore(find=printf_addr) if simgr.found: simgr.move("found", "active", lambda state: state.mem[format_string.get_value(state)].char.resolved.concrete) if simgr.active: simgr.step() if not simgr.found: print("[-] Не удалось достичь printf") sys.exit(1) for found_stash in simgr.found: print(f"Найден printf с символьной форматной строкой! Адрес блока: {hex(found_stash.callstack.call_site_addr)}") stdin_size = found_stash.posix.stdin.size sym_stdin = found_stash.posix.stdin.load(0, stdin_size) stdin_chrs = sym_stdin.chop(8) percent = claripy.BVV(ord('%'), 8) p_char = claripy.BVV(ord('p'), 8) and_constr = [claripy.And(stdin_chrs[i] == percent, stdin_chrs[i + 1] == p_char) for i in range(len(stdin_chrs[:-1]))] all_constr = claripy.Or(*and_constr) found_stash.solver.add(all_constr) payload = found_stash.solver.eval(sym_stdin, cast_to=bytes) print(f"Exploit to {project.filename}: {payload}")
Получаем результат:

Обратите внимание, что angr не только подобрал пароль, но и самостоятельно добавил после него нулевой байт (\x00), необходимый для успешного прохождения проверки в strcmp. Только после этого начинается наша полезная нагрузка для printf.
Проверяем:

5. Заключение
Таким образом, не проводя даже базового анализа программы, мы получили готовый PoC для уязвимости форматной строки. Хотя полноценным AEG это назвать нельзя, данный пример демонстрирует огромные возможности фреймворка angr и большой простор для исследований.
В случае уязвимостей форматной строки подход «символьного исполнения -> фильтрация по вызовам printf -> решение ограничений» позволяет довольно эффективно находить нужные payload. Однако стоит помнить о существующих сложностях: взрыв состояний (при больших программах), моделировании ОС и библиотек, поддержке сложных ограничений (системных вызовов, I/O) и обходе защит вроде ASLR и DEP.