- BrainTools - https://www.braintools.ru -

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg

Меня зовут Егор Боровинских, старший аналитик безопасности веб-приложений в компании «Гарда».

Сегодня хочу поговорить о FFmpeg. Для тех, кто не в курсе, эта open-source библиотека является индустриальным стандартом для мобильных приложений с функциями обработки аудио и видео, которая разбирает контейнеры (MP4, MOV, AVI, RTSP и т. п.) на отдельные потоки. А знаете, что бонусом к этому фреймворку идет порядка 543 зарегистрированных CVE? Причем эти уязвимости фигурируют в программах bugbounty.

Немного цифр в подтверждение важности темы. По данным отчета [1] Quokka «State of Mobile App Security 2026», охватившего более 150 тыс. приложений, критические уязвимости (CVE) в сторонних компонентах обнаружены в 11% приложений для Android и 13% — для iOS. В 65% Android-приложений зафиксированы уязвимости высокого уровня. Безусловно, FFmpeg — не единственный фреймворк, содержащий уязвимости, но он явно один из лидеров в этом списке.

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

Статья рассчитана на специалистов, которые уже имеют опыт [2] работы с Android-приложениями, разбираются в reverse engineering ELF-библиотек, обладают базовыми знаниями о memory corruption, ну или хотя бы умеют подключать к анализу нейронки. 

Дисклеймер: все материалы статьи предоставлены исключительно в образовательных целях, и мы не несем ответственности за ее неправомерное применение.

Обзор популярных CVE

В качестве разминки предлагаю взглянуть на несколько известных уязвимостей в FFmpeg. Разбор всех 543 существующих CVE потянет на «Войну и Мир» и «Тихий Дон» вместе взятые. Так что тут ограничусь тремя примерами (но в конце статьи привел список CVE, которые, на мой взгляд, заслуживают внимания [3]).  

PixelSmash (CVE-2026-8461)

В июне 2026 года специалисты из JFrog представили детальный анализ уязвимости PixelSmash (CVE-2026-8461 [4], CVSS 8.8). Баг — запись за пределы выделенной памяти [5] (heap OOB write) в декодере MagicYUV (libavcodec/magicyuv.c), доведенный до полного RCE.

Кратко опишу основную механику.

  1. Алгоритм MagicYUV разбивает все кадры на горизонтальные сегменты (slice) с фиксированным параметром slice_height. Каждый сегмент обрабатывается автономно. Если slice_height принимает нечетное значение, возникает несоответствие между механизмом выделения памяти под кадр и логикой [6] декодера. Дополнительные строки пикселей записываются за пределы выделенного буфера.

  2. FFmpeg размещает структуру AVBuffer непосредственно после пиксельного массива каждой плоскости. Таким образом, позиция этой структуры относительно начала переполняемой области вычисляется заранее и становится предсказуемой.

  3. Запись за границы буфера модифицирует три атрибута AVBuffer. В поле free с указателем на функцию освобождения подставляется адрес system(). В поле opaque, которое передается этой функции в качестве первого аргумента, записывается адрес командной строки злоумышленника. Наконец, в refcount устанавливается значение 1, чтобы освобождение структуры не произошло до момента вызова.

  4. Когда декодированный кадр отработал свое, FFmpeg вызывает штатную процедуру av_frame_unref() для освобождения памяти. Внутри этой функции содержится конструкция buf->free(buf->opaque, buf->data), где free — упомянутое выше поле структуры, содержащее адрес деаллокатора (как раз оно было заменено на шаге 3). Поскольку теперь free указывает на system(), а opaque — на строку с командой атакующего, стандартный вызов деаллокации фактически превращается в system(attacker_cmd), и команда исполняется с привилегиями процесса.

HTTP chunked overflow (CVE-2016-10190)

Речь идет о CVSS 9.8, RCE. Проблема [7] локализована в обработчике HTTP-протокола libavformat/http.c. Публично доступный рабочий эксплойт размещен в репозитории [8].

Механика эксплуатации следующая.

  1. При использовании chunked transfer encoding HTTP-сервер передает тело ответа отдельными чанками, указывая перед каждым его размер в байтах в текстовом виде. FFmpeg считывает эту текстовую строку и конвертирует ее в числовое значение посредством функции strtoll(). Результат сохраняется в переменную типа int64_t, причем число вполне может оказаться отрицательным. Вредоносный HTTP-сервер этим и пользуется, отправляя, к примеру -1. 

  2. На следующем этапе это значение проходит через выражение size = FFMIN(size, s->chunksize). FFMIN выбирает меньшее из двух значений. Отрицательное число при знаковом сравнении всегда оказывается меньше положительного дефолтного размера буфера (0x8000). Соответственно, при дальнейшей проверке и отсечении слишком крупных чанков система выбирает отрицательное значение как минимальное.

  3. То же самое число прогоняется через функцию recv(), отвечающую за чтение данных из сокета. Однако у нее беззнаковый параметр размера — size_t. Поэтому битовое представление -1 превращается в 0xffffffffffffffff. В итоге FFmpeg дает recv() команду считать из сети порядка 18 эксабайт (18 миллионов терабайт) информации и поместить ее в буфер, реальная емкость которого составляет всего 0x8000 байт.

  4. Сама функция recv() не соотносит запрошенный объем с фактической вместимостью буфера, а автоматом записывает в память все поступающие от атакующего по сети байты. В итоге запись расползается далеко за пределы буфера и достигает соседней структуры AVIOContext, расположенной в памяти на фиксированном смещении 0x8060 от начала. А далее перезаписывается поле read_packet (тут хранится адрес функции, которая отвечает за чтение следующего пакета данных).

  5. При последующем вызове функции avio_read() выполняется код s->read_packet(s >opaque, buf, size). Таким образом, происходит переход по сохраненному в поле read_packet адресу. Это поле и было заменено на предыдущем шаге. В итоге вызывается уже не легитимный read_packet, а вредоносный код злоумышленника. 

AV1 RTP depacketizer (DFVULN-127)

Эту уязвимость [9] нашел в 2026 году автономный агент depthfirst в RTP-депакетайзере AV1 (компоненте, который восстанавливает кадры из отдельных RTP-пакетов) — libavformat/rtpdec_av1.c, функция av1_handle_packet(). Эксплуатация бреши позволяет достичь RCE без аутентификации с помощью одного RTP-пакета размером 183 байта. PoC (полноценный Docker-репро) доступен тут [9].

И снова кратко рассмотрим механику уязвимости.

  1. Передаваемый через RTP Видеопоток AV1 дробится на OBU (Open Bitstream Unit), то есть самостоятельные блоки битстрима. Границы между кадрами обозначаются специальным OBU-маркером Temporal Delimiter (TD). Депакетайзер объединяет все OBU обратно в единый пакет с кадром, отслеживая две независимые позиции. При обнаружении TD он немедленно продвигает переменную pktpos вперед на величину obu_size, указанную злоумышленником в заголовке OBU. Важно, что реальная память под такой сдвиг не выделяется, а buf_ptr (позиция чтения входных данных) не перемещается вовсе. В итоге pktpos начинает указывать за пределы фактически аллоцированного буфера.

  2. Последующий OBU в потоке вызывает функцию av_grow_packet, которая выделяет память под изначальный размер (напомню, что о сдвиге на шаге 1 ей ничего не известно). Функция приступает к записи байтов этого OBU, начиная с позиции pkt->data[pktpos]. Запись попадает в чужую память за его пределами, так как pktpos уже указывает далеко за границы реально выделенного на данном шаге буфера. 

  3. Злоумышленник записывает нужный адрес через содержимое следующего OBU в поле AVBuffer.free [10], которое находится на фиксированном смещении 152 байта от начала данных в уже поврежденной области памяти. Причем атакующий не трогает соседнее поле refcount (счетчик ссылок). Если последнее обнулится преждевременно, структура освободится раньше времени, и вся цепочка атаки нарушится.

  4. Снова вызывается av_grow_packet, и счетчик ссылок refcount обнуляется в штатном режиме. Это и есть сигнал, что структура больше не нужна, и далее происходит вызов b->free(…). Напомню, что данное поле уже было подменено. И управление переходит на указанный злоумышленником адрес.

В отличие от вышеописанной PixelSmash, здесь не требуется, чтобы жертва открывала файл вручную. Атакующему достаточно одного RTP-пакета в процессе стандартного воспроизведения потока. Это оптимальный сценарий для захвата сервера, с которого жертва атаки загружает видео. Например, это может быть IP-камера или скомпрометированный RTSP-релей.

Ищем FFmpeg в APK

Прежде чем искать уязвимости, нужно отловить сам FFmpeg внутри APK. Представим, что мы уже нашли где-то нужный APK. Например, взяли из скоупа баг-баунти.

Для стандартной распаковки, как правило, достаточно команды apktool d app.apk. Сложности начинаются при распространении приложения через Android App Bundle (split APK) — тогда требуется извлечь все сегменты пакета разом.

Интересующий нас файл .so может находиться в отдельном модуле, например, config.arm64_v8a.apk. В этом случае используем bundletool или выгружаем все APK с устройства через adb pull (команда pm path <package> покажет пути ко всем сплитам).

apktool d app.apk

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 1
543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 2

Итак, пакет распакован — теперь проверяем, есть ли вообще FFmpeg внутри APK. Если фреймворк обнаружен, важно также понять, в каком виде он интегрирован. Для этого сперва проводим быстрый поиск по всем распакованным ABI-директориям (arm64-v8a, armeabi-v7a, x86_64). Версия и параметры сборки в разных архитектурах часто имеют отличия, поэтому лучше проверить каждую:

find . -iname "*.so" | xargs -I{} sh -c 'strings {} 2>/dev/null | grep -q

"FFmpeg version" && echo {}' 
543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 3

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

Паттерн

Как этот паттерн выявить

Статическая компиляция

FFmpeg вшит прямо в SDK-библиотеку (отдельныхlibavcodec.so и libavformat.so в APK нет).

strings libtarget.so | grep "FFmpeg version" — ищет версионную строку прямо в бинарнике.

nm -D libtarget.so | grep -E "avcodec_|avformat_" — показывает, экспортированы ли FFmpeg-символы наружу.

Динамическая линковка

FFmpeg подключается отдельными .so-файлами (libavcodec.so/libavformat.so), SDK-библиотека линкуется на них.

readelf -d libtarget.so | grep NEEDED — показывает libavcodec.so / libavformat.so в зависимостях (и сами эти файлы физически лежат рядом в папке lib/).

Java/Kotlin-обертка

Используются готовые библиотеки, такие как mobile-ffmpeg или FFmpegKit.

grep-rl"com/arthenica/mobileffmpeg|com/arthenica/ffmpegkit" --include="*.smali" . — поиск следов пакетов в smali-коде.

CLI-бинарник как asset

Готовый исполняемый файл лежит в ресурсах и запускается через Runtime.exec().

find . -type f -name "ffmpeg" -exec file {} ; — поиск самого бинарника.

grep-rl"com.github.hiteshsondhi88.libffmpeg" — поиск популярной Java-обертки для CLI.

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 4

Рекомендую проверять также нестандартные варианты названий библиотек. К примеру, в реальных проектах встречаются наименования наподобие loadLibrary("ffmpg") вместо стандартного "ffmpeg" — это трудно обнаружить простым поиском по точному совпадению. Поэтому есть смысл искать по частичному совпадению имени (например, *ffmp*) или же по характерным сигнатурным строкам внутри самого .so файла (FFmpeg version, Lavc).

Определяем версии FFmpeg

Теперь определим версию фреймворка, ведь от этого могут зависеть актуальные уязвимости и особенности поведения [11]. Напомню, что версия FFmpeg вшита в бинарный файл в виде текстовой строки:

strings libtarget.so | grep -E "FFmpeg version|Lavc[0-9]"

FFmpeg version 2.8.3

Lavc56.60.100

Вторая строка (Lavc<major>.<minor>.<micro>) отражает версию непосредственно libavcodec. На нее важно смотреть, если агрессивная обфускация (strip) вырезала строку FFmpeg version. Lavc-метка же обычно остается нетронутой, поскольку используется внутри для генерации сообщений об ошибках. Более того, ее можно однозначно сопоставить с конкретным релизом через libavcodec/version.h в истории репозитория.

Для массового сканирования большого количества приложений полезным грубым индикатором возраста всей сборки служит дата использованного тулчейна:

 file lib*.so | grep "built by NDK"

Наличие в одном и том же APK по разным .so  NDK r16 (2017 год) и NDK r27 (2024 год), обычно говорит о заброшенности одной из зависимостей. 

Пример r18b. Он выпущен в сентябре 2018 года

Пример r18b. Он выпущен в сентябре 2018 года

Стоит учитывать, что некоторые вендоры закрывают конкретную уязвимость точечным патчем, не обновляя строку версии. Такая метка может быть жестко зафиксирована еще на этапе ./configure и впоследствии может никак не изменяться. Тогда не остается ничего, кроме как выполнить сравнительный анализ кода. 

Извлекаем build-конфиг

Есть еще один момент, который важно проверить заранее, — наличие не собранных в конкретный бинарник уязвимостей в демультиплексоре, декодере или фильтре.

Не скомпилированные в код бреши в компонентах нельзя использовать, какая бы версия FFmpeg не указывалась в manifest. Так что тратить время на анализ подобных уязвимостей — бесполезная затея.

Если в APK есть отдельные библиотеки libavformat.so и libavcodec.so (это соответствует паттерну динамической линковки из таблицы выше), то можно работать напрямую с ними. Однако часто отдельных libavformat.so / libavcodec.so попросту не существует, а FFmpeg статически слинкован в единую SDK-библиотеку. Тогда везде нужно везде указывать именно ее, поскольку весь build-конфиг находится там.

Полная configure-строка практически всегда вшита в бинарник как текст:

strings libtarget.so | grep -oE "--enable-

(decoder|demuxer|protocol|muxer|filter|parser)=[a-z0-9_]+"
543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 6

Бывает, что строка удалена более агрессивным стриппером. Тогда экспортируем init-функции, чтобы восстановить список.

nm -D libtarget.so | grep -oE '[a-z0-9_]+_demuxer' | sort -u

nm -D libtarget.so | grep -oE '[a-z0-9_]+_decoder' | sort -u 

Такой подход почти гарантированно срабатывает с полностью статически слинкованными бинарниками без динамических символов (например, поиск по .rodata-секции через strings). Дело в том, что имена демодуляторов и декодеров присутствуют в виде строковых литералов при любом уровне обфускации, поскольку они задействованы в логировании и сообщениях об ошибках.

Любой кодек в FFmpeg имеет статическую запись codec-descriptor. У такой предназначенной исключительно для отображения (например, в списке ffmpeg -codecs) структуры есть свое имя и человекочитаемое название кодека. И неважно, создается ли настоящий декодер или энкодер для этого кодека. Таким образом, если удается найти через strings строку с именем кода, это говорит лишь об осведомленности FFmpeg о данном кодеке. Далеко не факт, что под него собран декодер.

Анализируем protocol handlers

А что насчет протоколов? Они зачастую становятся точками входа, ведь для их эксплуатации атакующему даже не обязательно располагать собственным медиафайлом. Достаточно контролировать URL/путь, который попадает в функцию avformat_open_input().

Найти их можно следующей командой:

strings libtarget.so | grep -oE '--enable-protocol=[a-z0-9_]+'

Опасные протоколы:

Протокол

Риск

file

Чтение произвольного локального файла (LFI), если приложение ожидает http(s)://, но не валидирует схему перед передачей в FFmpeg

concat:

Конкатенация нескольких путей одним URL-параметром

subfile:

Чтение произвольного диапазона байт файла (subfile:,,start,N,end,M,,:/path)

http / https с авто-редиректами

Классический SSRF, если AllowAutoRedirect-подобное поведение [12] не ограничено на internal/loopback адреса

rtmp / rtsp / mmsh

Тот же SSRF-паттерн, реже валидируется отдельно от http

lavfi

Произвольный filter-граф как источник потенциального DoS, в некоторых версиях фигурировал в CVE как command-injection- подобный вектор через movie= фильтр

Живой код против мертвого: прощупываем обратную достижимость

Впрочем, мало найти скомпилированный опасный протокол. Нужно еще удостовериться, существует ли реальный путь от приложения до этого вызова. Так что спускаемся на уровень Java/Kotlin/Dart-слоя, чтобы проверить, кто вообще передает URL в FFmpeg. Иными словами, прощупываем обратную достижимость.

grep -rl "Lcom/pkg/SomeClass;" --include="*.smali" . | grep -v

"^smali/com/pkg/SomeClass"

grep -n "SomeClass" AndroidManifest.xml

grep -rn "MethodName(" --include="*.smali" . | grep -v

"^smali/com/pkg/SomeClass"

strings libtarget.so | grep -c "SomeClassName|methodChannelName"

Приведу пример из практики. Тестируемое приложение включало два практически идентичных нативных класса для обработки видеопотока. Первый задействовал полноценный auto-probe с демультиплексором (avformat_open_input(iformat=NULL) и формально был уязвим к целому ряду CVE. Второй напрямую вызывал декодер на сырых байтах и полностью миновал демультиплексор. Вроде бы очевидно, какой из них должен «выстрелить»…

На деле первый описанный класс оказался мертвым кодом. Он нигде не инстанциировался, не имел внешних вызовов и даже не упоминался в Dart-слое. Реальный же путь шел через второй класс — именно там и скрывалась уязвимость.

Hardware decode fallback: основной путь или аварийный вариант?

В целом ряде Android-приложений по умолчанию используется аппаратный декодер android.media.MediaCodec, а FFmpeg задействуется, лишь если что-то пошло не так. Если уязвимый FFmpeg-путь не является основным, еще нужно проверить, насколько реально его можно использовать для атаки. Для этого сначала находим условие, по которому выбирается этот путь: это может быть либо конструкция try {MediaCodec... } catch { FFmpeg fallback }, либо явная проверка через MediaCodecList. 

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

grep -rl "MediaCodec|createDecoderByType" --include="*.smali".

Помимо условной достижимости, не стоит упускать из виду и сам Android-мост поверх MediaCodec. Обратимся к отчету [13] ZeroPath по FFmpeg. Интересно, что один из семи обнаруженных там дефектов — это heap overflow в Android-обертке над MediaCodec. Такие баги активируются через обычный подконтрольный злоумышленнику медиапоток. Словом, вероятность найти что-то интересное в собственном коде поверх MediaCodec гораздо выше, чем в сто первый раз перечитывать условный h264_slice.c.

Commit-ресерч по конкретным файлам

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

curl -s "https://api.github.com/repos/FFmpeg/FFmpeg/commits?

path=libavcodec/h264_slice.c&since=2015-11-27&until=2026-01-01&per_page=100"

Первое, что смотрим, — содержимое commit message (Bug-Id: CVE-, Fixes:, overflow, OOB, heap, out of bounds и т. д.) Во многих security-фиксах FFmpeg не отмечено никаких CVE непосредственно в самом коммите. Хорошо, если добавят соответствующий комментарий в NVD задним числом, поэтому приходится внимательно читать текст.

Но клонировать весь репозиторий не нужно:

curl -s

"https://raw.githubusercontent.com/FFmpeg/FFmpeg/n2.8.3/libavcodec/h264_slic

e.c" 

Перечень файлов для сверки формируется на базе ранее определенного build-конфига и живого пути. К примеру, стандартный набор для H.264-декодера выглядит так: h264.c, h264_slice.c, h264_cabac.c, h264_mb.c, h264_direct.c, h264_refs.c, h264_ps.c, h264_picture.c, h264_mvpred.h, h264_loopfilter.c.

Еще, кстати, в image-декодерах (mjpeg / png / tiff / webp / bmp / gif) часто встречается такой дефект, как integer overflow при расчете размера буфера на основе длины и высоты кадра.

// переполнение int при больших значениях

int buf_size = width  height  bytes_per_pixel;

// недостаточная аллокация из-за overflow

uint8_t *buf = av_malloc(buf_size);

Обнаруживается этот баг следующим образом:

  1. Выявляем скомпилированные image-декодеры. 

  2. Сверяем коммиты для указанных файлов, отфильтровав по ключевым словам: overflow, INT_MAX и av_image_check_size. Если в анализируемой версии вызов этой валидирующей функции отсутствует — это хороший сигнал.

  3. Выполняем построчный анализ кода там, где проверка производится непосредственно перед выделением памяти. Подготовить PoC для данной категории уязвимостей, как правило, относительно просто — достаточно сформировать заголовок с аномально большими значениями ширины и высоты.

Построчная сверка на git-тегах

Перепроверка — наше всё. Поэтому открываем исходник непосредственно на исследуемом теге и проверяем уязвимый участок вручную. Только так получится понять, действительно ли баг присутствует в конкретной версии.

К счастью, open‑source‑инструменты сохраняют историю изменений.

curl -s

"https://raw.githubusercontent.com/FFmpeg/FFmpeg/n2.8.3/libavcodec/h264_slic

e.c" > before.c

curl -s "https://raw.githubusercontent.com/FFmpeg/FFmpeg/<fix-commithash>/

libavcodec/h264_slice.c" > after.c

diff before.c after.c

Вот на что нужно обратить внимание, помимо исправленного участка кода:

  1. Присутствует ли в рассматриваемой версии требуемое поле или переменная в структуре.

  2. Имел ли уязвимый механизм место в предшествующих сборках.

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

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

По завершении построчного сопоставления эталонного исходника необходимо сверить его с фактическим дизассемблированным кодом целевой функции в исследуемом .so‑файле:

objdump -d libtarget.so --disassemble=target_function > target_disasm.txt

objdump -d ./ffmpeg-2.8.3/libavcodec/h264_mb.o --disassemble=target_function

> clean_disasm.txt

diff <(grep -oE 'cmp|b.w+|bl ' target_disasm.txt) <(grep -oE

'cmp|b.w+|bl ' clean_disasm.txt) 

От изучения к эксперименту: собираем ASan/UBSan-версии

Вот мы и подошли к кульминации нашего тестирования — сборке ASan/UBSan-версии для эмпирического подтверждения. Это даст нам автоматический отчет о выходе за границы памяти и неопределенном поведении.

Компилируем исключительно нужные декодеры и демультиплексоры. Не забываем [14] про флаги --disable-everything с точечными --enable-* — они сильно ускоряют компиляцию. Важно, чтобы ваш перечень соответствовал конфигурации, скомпилированной в мобильной сборке.

wget https://ffmpeg.org/releases/ffmpeg-2.8.3.tar.xz

tar xf ffmpeg-2.8.3.tar.xz && cd ffmpeg-2.8.3

./configure --disable-everything --disable-asm --disable-network 

--disable-autodetect --disable-doc --disable-pthreads 

--enable-avcodec --enable-avformat --enable-avutil 

--enable-swscale --enable-swresample 

--enable-decoder=h264 --enable-parser=h264 --enable-demuxer=h264 

--enable-protocol=file --enable-protocol=data 

--enable-muxer=rawvideo --enable-muxer=null 

--enable-filter=null --enable-ffmpeg --enable-ffprobe --enable-zlib 

--enable-debug=3 --disable-optimizations 

--extra-cflags='-fsanitize=address,undefined -fno-omit-frame-pointer -g -

O1' 

--extra-ldflags='-fsanitize=address,undefined'

make -j"$(nproc)"

Нередко уязвимость срабатывает лишь при специфической структуре кадра. Это может быть заданное количество H.264-слайсов, определенный JPEG-паттерн, аномальные значения ширины и высоты. В таком случае PoC генерируется с помощью Python-скрипта, который программно собирает NAL-юниты в формате Annex-B, или через энкодер с соответствующими параметрами.

Кстати, в репозитории (tools/target_dec_fuzzer.c) есть публичные libFuzzer-таргеты для многих декодеров и демультиплексоров. А в открытых отчетах OSS-Fuzz нередко лежит прикрепленный минимизированный crash-testcase. Остается скачать его и сразу прогнать через собственную сборку.

Если же готовый подходящий issue не найден, то самостоятельно собираем libFuzzer-харнесс на базе tools/target_dec_fuzzer.c под целевой декодер, а затем запускаем фаззинг. 

ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:halt_on_error=1:symbolize=1

./ffmpeg -v error -i poc.h264 -frames:v 1 -f null -

Иногда автоматическая символизация стека не срабатывает с первого раза. Тогда прогоняем отдельно addr2line -e ffmpeg_g <адрес> на несимволизированный бинарник. Впрочем, подобная перестраховка не повредит в любом случае, ведь совпадение offset и размера операции — это не стопроцентное доказательство срабатывания искомого бага. Окончательную уверенность может дать только получение точного имени функции и номера строки.

Разбор кейса: легкой прогулки не ждите

Всё, что мы обсудили выше, это важно и интересно, но у многих наверняка есть вопросы: как упростить поиск RCE или как в рамках реального пентеста или баг-баунти всё быстро проверить по известным уязвимостям (CVE, паттернам, сигнатурам), подготовить отчет и с чистой совестью пойти отдыхать.

Увы, такого обещать не могу — автоматизированные проверки редко дают готовый RCE. Не верите? Тогда вот вам реальный кейс из практики — тестирование приложения на базе VLC ( libvlc.so ) с уязвимостью PixelSmash [4].

Базовый прогон strings libvlc.so | grep magicyuv дал совпадение, но повторю, что наличие строки codec-descriptor само по себе ничего не гарантирует. Поэтому следующим шагом я проверил nm -D:

 nm -D libvlc.so | grep -i magicyuv

ff_magicyuv_decoder

ff_magicyuv_encoder

Что же, символ и вправду экспортируется, а MagicYUV декодер собран. Ему соответствует уязвимость PixelSmash (CVE-2026-8461 [4]). Для проверки ее применимости мне пришлось построчно разобрать дизассемблированный код.

Поскольку целевая функция (magy_decode_frame) является статической, в nm D она не резолвится. Единственным выходом оказалась техника string-xref — поиск не по имени, а по адресу известной строки, на которую ссылается искомая функция.

Я нашел offset строки "invalid slice height: %d" в .rodata и прогнал objdump -d по всем ~8,5 млн строк кода. В итоге всплыла лишь пара инструкций adrp+add (стандартный для ARM64 способ вычислить адрес этой строки через смещение от текущей страницы памяти), которые резолвились в этот адрес. Я прочитал control flow вручную — оба общеизвестных фикса PixelSmash отсутствовали.

Далее я подготовил ASan-сборки нужной версии FFmpeg (посмотрел, что совпадает с версийной строкой в libvlc.so) и прогнал через нее crafted AVI с MagicYUV-видео, нечетным slice_height и полным демультиплексор-пайплайном.

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 7
AddressSanitizer: heap-buffer-overflow
WRITE of size 640 at ... 0 bytes after 10319-byte region
 #2 magy_decode_slice ffmpeg-7.1.1/libavcodec/magicyuv.c:292
 #4 magy_decode_frame ffmpeg-7.1.1/libavcodec/magicyuv.c:660

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

Переменная

Описание переменной

p->data[1]

начало Cb-плоскости в памяти,

Dst

куда фактически идет запись — начало OOB

p->buf[1]

AVBufferRef, обертка вокруг буфера

*p->buf[1] (сам AVBuffer )

структура, которую мы будем перезаписывать

Чтобы адреса не менялись при каждом запуске, я выключил ASLR (setarch -R). Теперь, когда адреса стали стабильны, достаточно снять их один раз и подставить в эксплойт как константы.

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 8
543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 9

Как видно из дампа, dst и адрес AVBuffer разделяют 128 байт. Выходит, структура попадает внутрь тех же 640 байт, что переполняет один OOB-ряд Cb-плоскости.

На всякий случай поясню, что AVBuffer — это стандартная C-структура с заданным порядком полей. Каждое из них находится через определенное количество байтов от стартового адреса — этот сдвиг называется offset. 

Далее разберу, какие сдвиги трех полей, которые нужны нам для перехвата (refcount, free, opaque).

Поле

Сдвиг от начала AVBuffer

Абсолютная позиция в payload’е

refcount

0x90 (144 байта)

dst + 128 + 144 = dst + 272

free

0x98 (152 байта)

dst + 128 + 152 = dst + 280

opaque

0xa0 (160 байт)

dst + 128 + 160 = dst + 288

Нужные нам цифры — в третьей колонке. Поскольку payload собирается относительно dst (именно туда идет OOB-запись), прибавляем к сдвигу поля те самые 128 байт от dst до начала AVBuffer. 

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 10

Теперь собираем 640-байтовый payload. В нем на заранее рассчитанных байтовых позициях (согласно приведенной выше таблице) размещены данные, необходимые для подмены содержимого AVBuffer, а также сама исполняемая команда.

Смещение в

payload

Значение

Назначение

byte 0

строка "id > /tmp/pwned"

команда для system()

+272

refcount = 1

поле AVBuffer.refcount

+280

адрес system() в том же дебаг-билде)

поле AVBuffer.free —

именно сюда попадет управление

+288

адрес байта 0 этого же payload

поле AVBuffer.opaque — будущий аргумент для system()

Я не просто так задействовал начальный байт (смещение 0). Этот участок памяти заполнялся бы нулями при штатном декодировании без переполнения, и туда можно было бы поместить любые данные. Поэтому командная строка и значения для перезаписи AVBuffer размещены в едином массиве «пиксельных» данных. Не нужно никакой дополнительной инъекции в адресное пространство: декодер сам копирует информацию за пределы буфера в ходе OOB-записи.

Вот только нельзя просто вписать готовый payload в файл. Поскольку MagicYUV использует delta-кодирование (LEFT-предсказание), каждый хранящийся байт отличается от предыдущего. Проблему я решил, прогнав желаемые байты через ту же кодировку в обратную сторону (payload[i] - payload[i-1]). В результате после штатного decode-шага декодер сам восстановил именно те байты, которые нужны для эксплойта. К счастью, MagicYUV field encoding подробно разбирать не пришлось — его воспроизвели по формату из декодера.

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 11

Итак, осталось только инкапсулировать кадр в корректный AVI‑контейнер: в него помещается видеопоток MagicYUV с требуемыми параметрами — разрешением 1280×32 и нечетным значением slice_height, равным 31. Далее подготовленный файл передается в рабочий декодирующий пайплайн через стандартные вызовы avformat_open_input и avcodec_send_packet. В ходе обработки происходит OOB‑запись, которая модифицирует реальную структуру AVBuffer в динамической памяти (в куче) строго в соответствии с ранее вычисленными смещениями и значениями.

Штатный код FFmpeg срабатывает дальше уже без участия человека. При освобождении кадра срабатывает функция av_frame_unref(), которая инициирует вызов buf->free(buf->opaque, buf->data). Иными словами, система обращается к функции по адресу, хранящемуся в поле free, и передает ей два аргумента. В соответствии с соглашением о вызовах System V AMD64 (оно является стандартом для Linux на архитектуре x86_64) передача первых двух аргументов функции осуществляется строго через определенные регистры процессора.

Аргумент вызова buf-

>free(…)

Регистр

Результат после перехвата

buf->opaque (1-й аргумент)

rdi

адрес строки “id > /tmp/pwned”

buf->data (2-й аргумент)

rsi

Адрес пиксельных данных — не используется

Теперь в поле free хранится не адрес штатного деаллокатора, а указатель на функцию system(). При этом system(). задействует лишь первый переданный ей аргумент (он размещается в регистре rdi), второй же параметр остается нетронутым и никак не обрабатывается. В результате фактически выполняется вызов system(opaque), что на практике означает исполнение system("id > /tmp/pwned"):

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 12
cat /tmp/pwned
uid=1000(tpad) gid=1000(tpad) groups=1000(tpad),...

Итак, мы получаем заветный RCE.

Бонус

Описанная в статье методология помогает эффективно отлавливать эти бреши. Правда, ни одна методика не гарантирует вам RCE в каждом конкретном приложении. Многие подтвержденные WRITE-баги не приводят к реальному воздействию на систему. И даже при наличии impact-а дойти до рабочего эксплойта — тот еще квест. Но попытаться пройти его, я считаю, стоит.

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

543 уязвимости в индустриальном стандарте: отлавливаем бреши в FFmpeg - 13

Полезные ресурсы

*Еще стоит обратить внимание на DFVULN-116…127 (у них нет выданного CVE): heap/stack overflow, integer over/underflow в RTP-депакетайзерах (AV1, JPEG, LATM, MPEG-4), AVIF, CAF, AVI, RTSP-сервере.

Автор: garda_team

Источник [31]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37017

URLs in this post:

[1] отчета: https://go.quokka.io/state-of-mobile-app-security-2026?spm=a2ty_o01.29997173.0.0.58c3c921kUKUP0

[2] опыт: http://www.braintools.ru/article/6952

[3] внимания: http://www.braintools.ru/article/7595

[4] CVE-2026-8461: https://nvd.nist.gov/vuln/detail/CVE-2026-8461

[5] памяти: http://www.braintools.ru/article/4140

[6] логикой: http://www.braintools.ru/article/7640

[7] Проблема: https://nvd.nist.gov/vuln/detail/CVE-2016-10190

[8] репозитории: https://github.com/muzalam/FFMPEG-exploit

[9] уязвимость: https://github.com/DepthFirstDisclosures/ffmpeg-dfvuln127

[10] AVBuffer.free: http://AVBuffer.free

[11] поведения: http://www.braintools.ru/article/9372

[12] поведение: http://www.braintools.ru/article/5593

[13] отчету: https://zeropath.com/blog/autonomously-finding-7-ffmpeg-vulnerabilities-with-ai-2025

[14] забываем: http://www.braintools.ru/article/333

[15] CVE-2026-8461: https://nvd.nist.gov/vuln/detail/cve-2026-8461

[16] https://github.com/FFmpeg/FFmpeg: https://github.com/FFmpeg/FFmpeg

[17] https://ffmpeg.org/security.html: https://ffmpeg.org/security.html

[18] CVE-2025-9951: https://nvd.nist.gov/vuln/detail/cve-2025-9951

[19] https://github.com/0xBlackash/CVE-2026-8461: https://github.com/0xBlackash/CVE-2026-8461

[20] CVE-2024-22862: https://nvd.nist.gov/vuln/detail/cve-2024-22862

[21] https://github.com/google/sanitizers/wiki/AddressSanitizer: https://github.com/DepthFirstDisclosures/ffmpeg-dfvuln127https://github.com/google/sanitizers/wiki/AddressSanitizer

[22] https://depthfirst.com/research/21-zero-days-in-ffmpeg: https://depthfirst.com/research/21-zero-days-in-ffmpeg

[23] https://nvd.nist.gov/developers/vulnerabilities: https://nvd.nist.gov/developers/vulnerabilities

[24] CVE-2018-14394: https://nvd.nist.gov/vuln/detail/cve-2018-14394

[25] https://pentestnotes.ru/notes/: https://pentestnotes.ru/notes/

[26] CVE-2016-3062: https://nvd.nist.gov/vuln/detail/cve-2016-3062

[27] CVE-2017-7859: https://nvd.nist.gov/vuln/detail/cve-2017-7859

[28] CVE-2016-10190: https://nvd.nist.gov/vuln/detail/cve-2016-10190

[29] CVE-2023-51793: https://nvd.nist.gov/vuln/detail/cve-2023-51793

[30] CVE-2021-38291: https://nvd.nist.gov/vuln/detail/cve-2021-38291

[31] Источник: https://habr.com/ru/companies/garda/articles/1094702/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1094702

www.BrainTools.ru

Rambler's Top100