↓Skip to main content

FFmpeg Pentest Guide

·4675 words·22 mins·
Author
FaLLenSkiLL
Table of Contents

⚠ Важно: Все материалы предоставлены исключительно в образовательных целях. Использование этой информации без разрешения владельца системы/правообладателя незаконно. Автор не несёт ответственности за её неправомерное применение.

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

Вступление
#

Вы знали, что использование FFmpeg является индустриальным стандартом в мобильных приложениях, которым требуется обработка аудио и видео? А ещё под FFmpeg на момент написания статьи существует 543 зарегистрированных CVE. И да, эти уязвимости встречаются на bugbounty.

Согласно исследованию Quokka “State of Mobile App Security 2026” (выборка 150 000+ приложений), критичные CVE в сторонних компонентах нашлись в 11% Android-приложений и 13% на iOS, high-severity - у 65% Android-приложений. FFmpeg тут конечно не единственный с уязвимостями, но точно один из часто встречающихся.

image_2026-09-25_20-19-33.png

Для понимания контекста, сначала разберём основные термины:

  • FFmpeg - open-source библиотека/фреймворк для работы с видео/аудио. Разбирает контейнеры (MP4, MOV, AVI, RTSP…) на отдельные потоки - это называется демультиплексированием, обратная операция (собрать потоки обратно в контейнер) - ремультиплексирование. Кроме этого, FFmpeg умеет декодировать кодеки (H.264, HEVC, MJPEG…) и фильтровать видео/аудио. Состоит из нескольких библиотек: libavformat (демультиплексоры/протоколы), libavcodec (декодеры/энкодеры), libavutil, libswscale, libswresample.
  • Demuxer (демультиплексор) - конкретный модуль, который производит демультиплексирование и передаёт их дальше декодеру.
  • Decoder - превращает закодированные байты потока (например H.264 NAL-юниты) в сырые пиксели/сэмплы.
  • Protocol handler - модуль FFmpeg, отвечающий за источник байтов, например file://, http://, rtsp://, concat:, subfile: и т.д.
  • ABI (Application Binary Interface) - соглашение о том, как скомпилированный код взаимодействует с системой на низком уровне. Можно сказать, что это набор инструкций процессора, размеры/выравнивание типов, порядок передачи аргументов в регистрах.
  • NDK (Native Development Kit) - набор инструментов Android для сборки нативного (C/C++) кода в .so. Дата создания такого набора в бинарнике - это грубый индикатор возраста всей сборки.
  • ASan (AddressSanitizer) - режим компиляции (Clang/GCC), который встраивает в бинарник проверки на каждое обращение к памяти и ловит на этом heap/stack buffer overflow, use-after-free и подобные уязвимости в реальном времени. Но сам бинарник за счёт этих проверок работает медленнее и потребляет больше памяти.
  • UBSan (UndefinedBehaviorSanitizer) - похожий на ASan режим компиляции, ловит integer overflow, разыменование NULL, выход индекса за границы массива и другие случаи неопределённого поведения по стандарту C/C++. Часто собирается вместе с ASan одной командой.
  • ASLR (Address Space Layout Randomization) - защита операционной системы, которая рандомизирует адреса кучи/стека/библиотек при каждом запуске процесса. Ломает эксплойты, где нужны захардкоженные адреса, поэтому при отладочной калибровке эксплойта её обычно временно отключают (setarch -R на Linux), чтобы адреса между запусками не менялись.
  • RCE (Remote Code Execution) - выполнение атакующим произвольного кода на целевой системе. Конечная и самая опасная точка эксплуатации большинства уязвимостей.
  • OOB (Out-Of-Bounds) READ/WRITE - чтение/запись за пределами выделенного буфера. READ даёт максимум DoS/утечку соседних байт памяти, WRITE - может вести к повреждению управляющих структур и, в благоприятных условиях, к RCE.
  • OSS-Fuzz - сервис continuous fuzzing от Google, проводит автоматические фаззинг тесты над крупными open-source проектами (в т.ч. FFmpeg) и отправляет найденные уязвимости в bugs.chromium.org/p/oss-fuzz (публично видны примерно через 90 дней после фикса).

К некоторым терминам ещё вернёмся по ходу статьи.

Ищем FFmpeg в APK
#

Итак, предположим, что мы уже вытащили нужный APK. Например, взяли из скоупа bug bounty. Для его распаковки обычно хватает apktool d app.apk. Если приложение раздаётся через Android App Bundle (split APK), важно вытащить все сплиты сразу. Нужный .so файл может лежать в отдельном config.arm64_v8a.apk. Используем команды bundletool или adb pull всех APK с устройства (pm path <package> покажет пути ко всем сплитам).

apktool d app.apk

image_2026-09-23_16-05-11.png

Pasted image 20260923160557.png

После распаковки, первый шаг - понять, есть ли вообще 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 {}'

Pasted image 20260923160919.png

Встречается четыре архитектурных паттерна интеграции. От того, какая попалась в нашем случае, дальше зависит, где нужно искать точку входа:

ПаттернКак этот паттерн выявить
FFmpeg скомпилирован статически прямо в SDK-библиотеку (отдельных библиотеки декодеров/энкодеров libavcodec.so и библиотеки демультиплексоров/протоколов libavformat.so в APK нет)strings libtarget.so | grep "FFmpeg version" найдёт версийную строку прямо в бинарнике;
nm -D libtarget.so | grep -E "avcodec_|avformat_" покажет, экспортированы ли FFmpeg-символы наружу
FFmpeg идёт отдельными 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" .
CLI-бинарник как asset, запускается через Runtime.exec()find . -type f -name "ffmpeg" -exec file {} \;, плюс grep -rl "com.github.hiteshsondhi88.libffmpeg"

Pasted image 20260923195629.png

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

Точное определение версии
#

Версия 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"

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

Pasted image 20260923195930.png

(На скриншоте пример r18b. Он выпущен в сентябре 2018 года)

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

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

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

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

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

strings libtarget.so | grep -oE "\-\-enable-(decoder|demuxer|protocol|muxer|filter|parser)=[a-z0-9_]+"

Pasted image 20260923200829.png

Если строка вырезана более агрессивным стриппером, то реконструируем список по экспортированным 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

Для полностью статически слинкованных бинарников без динамических символов, например strings по .rodata-секции, такой подход почти всегда прокатывает, потому что имена demuxer/decoder присутствуют как строковые литералы независимо от уровня strip. Они используются в логировании и сообщениях об ошибках.

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

Protocol handlers
#

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

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

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

Опасные протоколы (если скомпилированы и достижимы с attacker-controlled входом):

ПротоколРиск
fileЧтение произвольного локального файла (LFI), если приложение ожидает http(s)://, но не валидирует схему перед передачей в FFmpeg
concat:Конкатенация нескольких путей одним URL-параметром
subfile:Чтение произвольного диапазона байт файла (subfile:,,start,N,end,M,,:/path)
http/https с авто-редиректамиКлассический SSRF, если AllowAutoRedirect-подобное поведение не ограничено на internal/loopback адреса
rtmp/rtsp/mmshТот же SSRF-паттерн, реже валидируется отдельно от http
lavfiПроизвольный filter-граф как источник потенциального DoS, в некоторых версиях фигурировал в CVE как command-injection-подобный вектор через movie= фильтр

Живой код vs мёртвый код
#

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

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"

Предположим, что у приложения два похожих native-класса для работы с видеопотоком - один с полноценным auto-probe с демультиплексором (avformat_open_input(iformat=NULL), на бумаге уязвимый к целому пласту CVE), второй - прямой вызов декодера на сырых байтах без демультиплексора вообще.

Как думаете, какой в итоге выстрелил?

Первый класс на поверку оказался нигде не инстанциируемым мёртвым кодом (нет внешних вызовов и нет упоминаний в Dart-слое). А реальный путь шёл через второй класс - и именно там пряталась уязвимость.

Pasted image 20260925001545.png

Hardware decode fallback
#

Многие Android-приложения сначала пытаются использовать аппаратный декодер android.media.MediaCodec и переключаются на FFmpeg только при неудаче. Если найденный уязвимый FFmpeg-путь на самом деле не основной, то достижимость вектора атаки под вопросом.

Для того, чтобы это проверить, сначала находим условие выбора (обычно это try { MediaCodec... } catch { FFmpeg fallback } или явная проверка через MediaCodecList). Если выбор форсируется конкретной комбинацией кодек+профиль+уровень, которую атакующий может обеспечить через специально сконструированный поток, то достижимость подтверждается.

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

Сам Android-мост над MediaCodec тоже стоит держать в уме отдельно от условной достижимости. По данным декабрьского исследования ZeroPath 2025 года по FFmpeg, один из 7 найденных ими багов - это 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_slice.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. Провести commit-сверку по этим файлам с фильтром overflow/INT_MAX/av_image_check_size. Отсутствие вызова этой проверочной функции в исследуемой версии для нас будет очень кстати.
  3. Провести построчную сверку конкретного вызова проверки перед аллокацией. PoC для этого класса обычно проще, чем под остальные типы уявимостей. Т.к здесь обычный заголовок с экстремальными значениями width/height.

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

Всегда всё перепроверяем. К счастью, у опенсорса видна вся история.

curl -s "https://raw.githubusercontent.com/FFmpeg/FFmpeg/n2.8.3/libavcodec/h264_slice.c" > before.c
curl -s "https://raw.githubusercontent.com/FFmpeg/FFmpeg/<fix-commit-hash>/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-версии для эмпирического подтверждения
#

Собираем только те decoder/demuxer, которые реально нужны. Флаги --disable-everything + точечный --enable-* сильно ускоряет сборку. Список должен соответствовать скомпилированному в мобильной сборке build-конфигу.

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)"

Если для триггера нужна конкретная форма кадра (N слайсов H.264, определённый JPEG-паттерн, экстремальные значения width/height), то придётся генерировать PoC программно (Python-скрипт, собирающий Annex-B NAL-юниты руками, или энкодер с нужными флагами).

Для многих decoder/demuxer в FFmpeg существуют публичные libFuzzer-таргеты в самом репозитории (tools/target_dec_fuzzer.c). А публичный OSS-Fuzz issue часто содержит приложенный минимизированный 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/размер операции как достаточное доказательство совпадения с известным багом до получения точного имени функции и номера строки.

Разбираем CVE
#

Из 543 существующих на данный момент CVE, могу выделить следующие:

  • CVE-2026-8461 (PixelSmash) - heap OOB WRITE в MagicYUV-декодере, полный exploit-chain до RCE, CVSS 8.8. Работает в AVI/MKV/MOV.
  • CVE-2025-9951 - heap-buffer-overflow в JPEG2000-декодере (cdef atom), RCE, CVSS 7.2.
  • CVE-2024-22862 - integer overflow → heap overflow в JPEG XL parser, CVSS 9.8.
  • CVE-2026-39210…39218 (batch depthfirst - 21 zero-day разом), CVSS 9.8 у CVE-2026-39210.
  • DFVULN-116…127 (нет выданного CVE) - heap/stack overflow, integer over/underflow в RTP-депакетайзерах (AV1, JPEG, LATM, MPEG-4), AVIF, CAF, AVI, RTSP-сервере. RCE.
  • CVE-2018-14394 - divide-by-zero в модуле записи MOV/MP4-контейнеров libavformat/movenc.c при записи WAV-файла, CVSS 6.5.
  • CVE-2016-3062 - memory corruption в демультиплексоре MOV/MP4 mov.c, функция mov_read_dref(), CVSS 8.8.
  • CVE-2017-7859 - заявленный heap-buffer-overflow WRITE в H.264-декодере h264dec.c, ff_h264_slice_context_init. Применимость к очень старым версиям спорна.
  • CVE-2016-10190 - heap-buffer-overflow в обработчике HTTP-протокола libavformat/http.c, CVSS 9.8. RCE.
  • CVE-2023-51793 - heap-buffer-overflow в общих утилитах для работы с image-буферами libavutil/imgutils.c, функция image_copy_plane(), CVSS 7.8.
  • CVE-2021-38291 - integer overflow в парсере G.729-аудиокодека libavcodec/g729_parser.c, CVSS 7.5.

Парочку разберём далее.

PixelSmash (CVE-2026-8461)
#

В июне 2026 команда JFrog опубликовала разбор PixelSmash (CVE-2026-8461, CVSS 8.8) - heap OOB write в MagicYUV-декодере (libavcodec/magicyuv.c), доведённый до полного RCE.

Если коротко, то механика следующая:

  1. MagicYUV делит каждый кадр на горизонтальные полосы (slice) заданной высоты (slice_height) и декодирует их независимо друг от друга. При нечётной переменной slice_height между аллокатором кадра и декодером возникает рассинхрон, лишние строки пикселей вылезают за границы буфера.
  2. FFmpeg аллоцирует структуру AVBuffer сразу после пиксельных данных каждой plane-плоскости. Это предсказуемое смещение от начала переполняемого буфера.
  3. OOB write перезаписывает три поля структуры AVBuffer. В поле free (указатель на функцию-деаллокатор) записывается адрес system(). В поле opaque (указатель на данные, который эта функция получит первым аргументом) записывается адрес управляемой атакующим командной строки. В поле refcount (счётчик ссылок) записывается цифра 1, чтобы структура не была освобождена раньше времени.
  4. Когда декодированный кадр больше не нужен, FFmpeg штатно освобождает память под него через функцию av_frame_unref(). Внутри неё есть код вида buf->free(buf->opaque, buf->data), где free - это имя поля структуры buf, в котором лежит адрес функции-деаллокатора (то самое поле, которое мы перезаписали на шаге 3). Поскольку в free теперь лежит адрес system(), а в opaque - адрес командной строки атакующего, этот штатный вызов на практике превращается в system(attacker_cmd) и команда выполняется от имени процесса.

Главная практическая сложность - это калибровка heap layout. Аллокатор памяти glibc (malloc) хранит перед каждым выделенным блоком памяти (чанком) короткий служебный заголовок, который из себя представляет несколько 8-байтовых слов (qword) с размером этого чанка и служебными флагами, по которым сам аллокатор потом проверяет целостность кучи. OOB write из PixelSmash затирает не только AVBuffer, но и задевает эти заголовки соседних чанков. Если записать туда что попало, malloc/free при следующем обращении увидят испорченные метаданные и вызовут abort() раньше, чем выполнится подготовленный payload, поэтому эти служебные байты в пейлоаде нужно воспроизвести такими, чтобы аллокатор их принял за валидные. Способ калибровки зависит от конкретной реализации PoC, где-то это живой дамп памяти отладчиком, где-то заранее подготовленный файл с параметрами heap-состояния под конкретное окружение (например, poc-lab требует отдельный calib.json под целевую среду).

Если найденный WRITE-баг переполняет буфер, сразу за которым предсказуемо располагается AVBuffer-подобная структура, то это уже конкретный путь к RCE. Стоит целенаправленно проверять раскладку памяти вокруг переполняемого буфера на этот паттерн.

HTTP chunked overflow (CVE-2016-10190)
#

CVSS 9.8, RCE. Уязвимость в обработчике HTTP-протокола libavformat/http.c, готовый эксплойт есть в открытом доступе - muzalam/FFMPEG-exploit.

Механика следующая:

  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. Функция recv() получает от FFmpeg команду прочитать из сети примерно 18 эксабайт (18 миллионов терабайт) данных и записать их в буфер, который на самом деле размером всего 0x8000 байт.
  4. Сама функция recv() не проверяет соответствие размера буферу, она просто пишет в память ровно столько байт, сколько реально придёт по сети, пока атакующий продолжает их слать. Поэтому запись уходит далеко за пределы буфера и долетает до соседней структуры AVIOContext, которая в памяти лежит на фиксированном смещении 0x8060 от начала. Там перезаписывается поле read_packet - оно хранит адрес функции, которая отвечает за чтение следующего пакета данных.
  5. При следующем вызове функции avio_read() выполняется код s->read_packet(s->opaque, buf, size), то есть вызов по адресу, который лежит в поле read_packet. Это то самое поле, которое мы перезаписали на шаге 4, так что вызывается уже не настоящий read_packet, а то, на что атакующий его подменил.

AV1 RTP depacketizer (DFVULN-127)
#

В 2026 автономный агент depthfirst нашёл эту уязвимость в RTP-депакетайзере AV1 (собирает поток из отдельных RTP-пакетов обратно в кадры) libavformat/rtpdec_av1.c, функция av1_handle_packet(). RCE без аутентификации через один RTP-пакет размером 183 байта. PoC (полноценный Docker-репро) - DepthFirstDisclosures/ffmpeg-dfvuln127.

Механика следующая:

  1. Видео в формате AV1 внутри RTP-потока разбито на 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. На фиксированном смещении 152 байта от начала данных в этой уже испорченной области памяти лежит поле AVBuffer.free. Атакующий записывает туда нужный ему адрес через содержимое следующего OBU, но при этом оставляет соседнее поле refcount (счётчик ссылок) нетронутым. Если оно обнулится раньше времени, структура освободится преждевременно и цепочка сломается.
  4. Ещё один вызов функции av_grow_packet наконец обнуляет refcount штатным образом. Обнуление счётчика ссылок означает, что структура больше не нужна и инициирует вызов b->free(...). Поскольку это поле уже подменено на шаге 3, управление переходит на адрес, который выбрал атакующий.

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

Пример из bugbounty (RCE)
#

Всё выше перечисленное конечно безусловно очень круто и интересно, но у многих наверняка возникает вопрос. А что там по RCE то? Как в реальном приложении на пентесте или bugbounty всё быстренько проанализировать и пойти пить чаёк?

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

Анализируемое приложение было построено на VLC (libvlc.so). Обычная проверка strings libvlc.so | grep magicyuv нашла совпадение, но, как уже обсуждали в разделе про build-конфиг, строка codec-descriptor таблицы ещё ничего не доказывает. Поэтому дополнительно проверил nm -D:

nm -D libvlc.so | grep -i magicyuv
ff_magicyuv_decoder
ff_magicyuv_encoder

Хорошо, символ действительно экспортируется. MagicYUV декодер скомпилирован. Под него есть известная уязвимость - PixelSmash (CVE-2026-8461).

Применимость этой уязвимости получилось подтвердить только построчным разбором дизассемблированного кода. Целевая функция (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 и полным демультиплексор-пайплайном.

Pasted image 20260924023027.png

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

ПеременнаяОписание переменной
p->data[1]начало Cb-плоскости в памяти
dstкуда фактически идёт запись - начало OOB
p->buf[1]AVBufferRef, обёртка вокруг буфера
*p->buf[1] (сам AVBuffer)структура, которую мы будем перезаписывать

Ещё на этом шаге я выключил ASLR (setarch -R) - без этого адреса менялись бы от запуска к запуску, и константы из fprintf не подошли бы к следующему прогону. С выключенным ASLR адреса стабильны: снял их один раз и подставил в эксплойт как константы.

Pasted image 20260924023139.png

Pasted image 20260924023209.png

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

AVBuffer - это обычный C-struct с фиксированным расположением полей. У каждого поля есть свой сдвиг (offset) от начала структуры - то есть сколько байт нужно отступить, чтобы попасть на это поле. Три поля, которые нужны для перехвата (refcount, free, opaque), лежат внутри AVBuffer на следующих сдвигах:

ПолеСдвиг от начала AVBufferАбсолютная позиция в payload’е
refcount0x90 (144 байта)dst + 128 + 144 = dst + 272
free0x98 (152 байта)dst + 128 + 152 = dst + 280
opaque0xa0 (160 байт)dst + 128 + 160 = dst + 288

Третья колонка и есть то, что нам нужно. payload я собираю относительно dst (именно туда идёт OOB-запись), поэтому к сдвигу поля прибавляются те самые 128 байт от dst до начала AVBuffer.

Pasted image 20260924023258.png

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

Смещение в payload’еЗначениеНазначение
byte 0строка "id > /tmp/pwned\0"команда для system()
+272refcount = 1поле AVBuffer.refcount
+280адрес system() (снят через dlsym(RTLD_DEFAULT, "system") в том же дебаг-билде)поле AVBuffer.free - именно сюда попадёт управление
+288адрес байта 0 этого же payload’аполе AVBuffer.opaque - будущий аргумент для system()

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

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

Pasted image 20260924023328.png

Осталось запаковать этот кадр в валидный AVI-контейнер с MagicYUV-видеопотоком нужного разрешения (1280x32, нечётный slice_height=31) и скормить через настоящий пайплайн avformat_open_input/avcodec_send_packet. OOB write переписывает реальную структуру AVBuffer в куче именно так, как посчитано выше.

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

Аргумент вызова buf->free(...)РегистрРезультат после перехвата
buf->opaque (1-й аргумент)rdiадрес строки "id > /tmp/pwned"
buf->data (2-й аргумент)rsiадрес пиксельных данных - не используется

free теперь указывает не на настоящий деаллокатор, а на system(). А system() читает только первый аргумент (rdi), второй просто игнорирует. В итоге исполняется system(opaque), то есть system("id > /tmp/pwned"):

Pasted image 20260924023407.png

cat /tmp/pwned
uid=1000(tpad) gid=1000(tpad) groups=1000(tpad),...

Получаем RCE

Немного статистики
#

Исследование “Too Quiet in the Library” (UC Irvine + EPFL, датасет - топ-200 бесплатных приложений Google Play, 2013-2020, 7678 версий, 66684 native-библиотеки) показывает, что уязвимости в библиотеках - это системная проблема.

  • 26.5% (53 из 200) топовых приложений были подвержены уязвимой версии библиотеки хотя бы раз за ~6.5 лет наблюдения; 7% (14 из 200) оставались уязвимы на момент завершения исследования.
  • Уязвимые библиотеки в среднем устарели на 859 ± 137 дней к моменту обнаружения.
  • Разработчики приложений применяют патчи в среднем через 528 дней после того, как сама библиотека выпустила фикс - при том, что сама библиотека патчится в среднем за 55 дней. То есть разработчики приложений отстают от апстрима примерно в 10 раз.
  • FFmpeg прямо назван в числе проблемных библиотек, наравне с OpenSSL, OpenCV, GIFLib, WebP, libpng.

Бонус
#

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

  1. Распаковали APK, вытащили все сплиты (все ABI, все config.*.apk)?
  2. Собрали список всех .so файлов по всем ABI-сплитам?
  3. Нашли строку FFmpeg version/Lavc хотя бы в одной из них?
  4. Определили паттерн интеграции (статически вкомпилирован / отдельные libavcodec.so+libavformat.so / Java-обёртка / CLI-бинарник)?
  5. Определили точную версию FFmpeg (FFmpeg version и Lavc<major>.<minor>.<micro>)?
  6. Проверили дату NDK-тулчейна (набора инструментов) как индикатор возраста сборки?
  7. Извлекли build-конфиг - список скомпилированных decoder/demuxer/protocol/filter?
  8. Подтвердили этот список через nm -D (экспортированные символы)?
  9. Нашли в списке протоколов потенциально опасные (file, concat:, subfile:, http/https с редиректами, lavfi)?
  10. Нашли Java/Kotlin/Dart-класс, который передаёт данные в FFmpeg?
  11. Проверили обратную достижимость этого класса - кто его вызывает, есть ли он в манифесте, не мёртвый ли это код?
  12. Проверили, нет ли перед FFmpeg аппаратного fallback через MediaCodec, который перекрывает путь?
  13. Составили список конкретных .c-файлов FFmpeg для сверки, исходя из build-конфига и живого пути?
  14. Нашли через GitHub API коммиты с Bug-Id: CVE-/Fixes:/overflow/OOB/heap по этим файлам?
  15. Сверили исходник построчно на теге версии из бинарника до фикса и после?
  16. Проверили, что уязвимый механизм вообще был внесён в код до этой версии?
  17. Подтвердили находку дизассемблированным кодом .so файла?
  18. Собрали минимальную ASan/UBSan-сборку под найденный build-конфиг?
  19. Определили реальный impact?
  20. Воспроизвели найденное поведение на архитектуре и аллокаторе таргета (Android ARM64 + Scudo, с включенным ASLR)?
  21. Собрали рабочий эксплойт целиком (перехват управления с контролируемыми аргументами) и проверили его на практике?

При этом, хоть и FFmpeg подтвержен множеству уязвимостей, включая RCE, сама методология не гарантирует RCE в каждом конкретном приложении. У части подтверждённых WRITE-багов реального impact просто нет. А там, где impact есть, дойти до рабочего эксплойта - это отдельный, значительный кусок работы. Но попытаться, мне кажется, стоит.

References
#

Github
#

National Vulnerability Database
#