- BrainTools - https://www.braintools.ru -
Меня зовут Егор Боровинских, старший аналитик безопасности веб-приложений в компании «Гарда».
Сегодня хочу поговорить о 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, ну или хотя бы умеют подключать к анализу нейронки.
Дисклеймер: все материалы статьи предоставлены исключительно в образовательных целях, и мы не несем ответственности за ее неправомерное применение.
В качестве разминки предлагаю взглянуть на несколько известных уязвимостей в FFmpeg. Разбор всех 543 существующих CVE потянет на «Войну и Мир» и «Тихий Дон» вместе взятые. Так что тут ограничусь тремя примерами (но в конце статьи привел список CVE, которые, на мой взгляд, заслуживают внимания [3]).
В июне 2026 года специалисты из JFrog представили детальный анализ уязвимости PixelSmash (CVE-2026-8461 [4], CVSS 8.8). Баг — запись за пределы выделенной памяти [5] (heap OOB write) в декодере MagicYUV (libavcodec/magicyuv.c), доведенный до полного RCE.
Кратко опишу основную механику.
Алгоритм MagicYUV разбивает все кадры на горизонтальные сегменты (slice) с фиксированным параметром slice_height. Каждый сегмент обрабатывается автономно. Если slice_height принимает нечетное значение, возникает несоответствие между механизмом выделения памяти под кадр и логикой [6] декодера. Дополнительные строки пикселей записываются за пределы выделенного буфера.
FFmpeg размещает структуру AVBuffer непосредственно после пиксельного массива каждой плоскости. Таким образом, позиция этой структуры относительно начала переполняемой области вычисляется заранее и становится предсказуемой.
Запись за границы буфера модифицирует три атрибута AVBuffer. В поле free с указателем на функцию освобождения подставляется адрес system(). В поле opaque, которое передается этой функции в качестве первого аргумента, записывается адрес командной строки злоумышленника. Наконец, в refcount устанавливается значение 1, чтобы освобождение структуры не произошло до момента вызова.
Когда декодированный кадр отработал свое, FFmpeg вызывает штатную процедуру av_frame_unref() для освобождения памяти. Внутри этой функции содержится конструкция buf->free(buf->opaque, buf->data), где free — упомянутое выше поле структуры, содержащее адрес деаллокатора (как раз оно было заменено на шаге 3). Поскольку теперь free указывает на system(), а opaque — на строку с командой атакующего, стандартный вызов деаллокации фактически превращается в system(attacker_cmd), и команда исполняется с привилегиями процесса.
Речь идет о CVSS 9.8, RCE. Проблема [7] локализована в обработчике HTTP-протокола libavformat/http.c. Публично доступный рабочий эксплойт размещен в репозитории [8].
Механика эксплуатации следующая.
При использовании chunked transfer encoding HTTP-сервер передает тело ответа отдельными чанками, указывая перед каждым его размер в байтах в текстовом виде. FFmpeg считывает эту текстовую строку и конвертирует ее в числовое значение посредством функции strtoll(). Результат сохраняется в переменную типа int64_t, причем число вполне может оказаться отрицательным. Вредоносный HTTP-сервер этим и пользуется, отправляя, к примеру -1.
На следующем этапе это значение проходит через выражение size = FFMIN(size, s->chunksize). FFMIN выбирает меньшее из двух значений. Отрицательное число при знаковом сравнении всегда оказывается меньше положительного дефолтного размера буфера (0x8000). Соответственно, при дальнейшей проверке и отсечении слишком крупных чанков система выбирает отрицательное значение как минимальное.
То же самое число прогоняется через функцию recv(), отвечающую за чтение данных из сокета. Однако у нее беззнаковый параметр размера — size_t. Поэтому битовое представление -1 превращается в 0xffffffffffffffff. В итоге FFmpeg дает recv() команду считать из сети порядка 18 эксабайт (18 миллионов терабайт) информации и поместить ее в буфер, реальная емкость которого составляет всего 0x8000 байт.
Сама функция recv() не соотносит запрошенный объем с фактической вместимостью буфера, а автоматом записывает в память все поступающие от атакующего по сети байты. В итоге запись расползается далеко за пределы буфера и достигает соседней структуры AVIOContext, расположенной в памяти на фиксированном смещении 0x8060 от начала. А далее перезаписывается поле read_packet (тут хранится адрес функции, которая отвечает за чтение следующего пакета данных).
При последующем вызове функции avio_read() выполняется код s->read_packet(s >opaque, buf, size). Таким образом, происходит переход по сохраненному в поле read_packet адресу. Это поле и было заменено на предыдущем шаге. В итоге вызывается уже не легитимный read_packet, а вредоносный код злоумышленника.
Эту уязвимость [9] нашел в 2026 году автономный агент depthfirst в RTP-депакетайзере AV1 (компоненте, который восстанавливает кадры из отдельных RTP-пакетов) — libavformat/rtpdec_av1.c, функция av1_handle_packet(). Эксплуатация бреши позволяет достичь RCE без аутентификации с помощью одного RTP-пакета размером 183 байта. PoC (полноценный Docker-репро) доступен тут [9].
И снова кратко рассмотрим механику уязвимости.
Передаваемый через RTP Видеопоток AV1 дробится на OBU (Open Bitstream Unit), то есть самостоятельные блоки битстрима. Границы между кадрами обозначаются специальным OBU-маркером Temporal Delimiter (TD). Депакетайзер объединяет все OBU обратно в единый пакет с кадром, отслеживая две независимые позиции. При обнаружении TD он немедленно продвигает переменную pktpos вперед на величину obu_size, указанную злоумышленником в заголовке OBU. Важно, что реальная память под такой сдвиг не выделяется, а buf_ptr (позиция чтения входных данных) не перемещается вовсе. В итоге pktpos начинает указывать за пределы фактически аллоцированного буфера.
Последующий OBU в потоке вызывает функцию av_grow_packet, которая выделяет память под изначальный размер (напомню, что о сдвиге на шаге 1 ей ничего не известно). Функция приступает к записи байтов этого OBU, начиная с позиции pkt->data[pktpos]. Запись попадает в чужую память за его пределами, так как pktpos уже указывает далеко за границы реально выделенного на данном шаге буфера.
Злоумышленник записывает нужный адрес через содержимое следующего OBU в поле AVBuffer.free [10], которое находится на фиксированном смещении 152 байта от начала данных в уже поврежденной области памяти. Причем атакующий не трогает соседнее поле refcount (счетчик ссылок). Если последнее обнулится преждевременно, структура освободится раньше времени, и вся цепочка атаки нарушится.
Снова вызывается av_grow_packet, и счетчик ссылок refcount обнуляется в штатном режиме. Это и есть сигнал, что структура больше не нужна, и далее происходит вызов b->free(…). Напомню, что данное поле уже было подменено. И управление переходит на указанный злоумышленником адрес.
В отличие от вышеописанной PixelSmash, здесь не требуется, чтобы жертва открывала файл вручную. Атакующему достаточно одного RTP-пакета в процессе стандартного воспроизведения потока. Это оптимальный сценарий для захвата сервера, с которого жертва атаки загружает видео. Например, это может быть IP-камера или скомпрометированный RTSP-релей.
Прежде чем искать уязвимости, нужно отловить сам 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


Итак, пакет распакован — теперь проверяем, есть ли вообще 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 {}'

Существуют четыре основных архитектурных паттерна интеграции FFmpeg. Эта информация важна для дальнейшего анализа, поскольку от типа паттерна зависит область поиска точки входа в библиотеку.
|
Паттерн |
Как этот паттерн выявить |
|
Статическая компиляция FFmpeg вшит прямо в SDK-библиотеку (отдельных |
|
|
Динамическая линковка FFmpeg подключается отдельными |
|
|
Java/Kotlin-обертка Используются готовые библиотеки, такие как mobile-ffmpeg или FFmpegKit. |
|
|
CLI-бинарник как asset Готовый исполняемый файл лежит в ресурсах и запускается через |
|

Рекомендую проверять также нестандартные варианты названий библиотек. К примеру, в реальных проектах встречаются наименования наподобие loadLibrary("ffmpg") вместо стандартного "ffmpeg" — это трудно обнаружить простым поиском по точному совпадению. Поэтому есть смысл искать по частичному совпадению имени (например, *ffmp*) или же по характерным сигнатурным строкам внутри самого .so файла (FFmpeg version, Lavc).
Теперь определим версию фреймворка, ведь от этого могут зависеть актуальные уязвимости и особенности поведения [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 год), обычно говорит о заброшенности одной из зависимостей.
Стоит учитывать, что некоторые вендоры закрывают конкретную уязвимость точечным патчем, не обновляя строку версии. Такая метка может быть жестко зафиксирована еще на этапе
./configureи впоследствии может никак не изменяться. Тогда не остается ничего, кроме как выполнить сравнительный анализ кода.
Есть еще один момент, который важно проверить заранее, — наличие не собранных в конкретный бинарник уязвимостей в демультиплексоре, декодере или фильтре.
Не скомпилированные в код бреши в компонентах нельзя использовать, какая бы версия 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_]+"

Бывает, что строка удалена более агрессивным стриппером. Тогда экспортируем 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 о данном кодеке. Далеко не факт, что под него собран декодер.
А что насчет протоколов? Они зачастую становятся точками входа, ведь для их эксплуатации атакующему даже не обязательно располагать собственным медиафайлом. Достаточно контролировать URL/путь, который попадает в функцию avformat_open_input().
Найти их можно следующей командой:
strings libtarget.so | grep -oE '--enable-protocol=[a-z0-9_]+'
Опасные протоколы:
|
Протокол |
Риск |
|
|
Чтение произвольного локального файла (LFI), если приложение ожидает |
|
|
Конкатенация нескольких путей одним URL-параметром |
|
|
Чтение произвольного диапазона байт файла ( |
|
|
Классический SSRF, если AllowAutoRedirect-подобное поведение [12] не ограничено на internal/loopback адреса |
|
|
Тот же SSRF-паттерн, реже валидируется отдельно от http |
|
|
Произвольный filter-граф как источник потенциального DoS, в некоторых версиях фигурировал в CVE как command-injection- подобный вектор через |
Впрочем, мало найти скомпилированный опасный протокол. Нужно еще удостовериться, существует ли реальный путь от приложения до этого вызова. Так что спускаемся на уровень 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-слое. Реальный же путь шел через второй класс — именно там и скрывалась уязвимость.
В целом ряде 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.
Если после предыдущих шагов достижимость сузилась до конкретных файлов (обычно так всегда и происходит), постепенно переходим к их точечной сверке.
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);
Обнаруживается этот баг следующим образом:
Выявляем скомпилированные image-декодеры.
Сверяем коммиты для указанных файлов, отфильтровав по ключевым словам: overflow, INT_MAX и av_image_check_size. Если в анализируемой версии вызов этой валидирующей функции отсутствует — это хороший сигнал.
Выполняем построчный анализ кода там, где проверка производится непосредственно перед выделением памяти. Подготовить PoC для данной категории уязвимостей, как правило, относительно просто — достаточно сформировать заголовок с аномально большими значениями ширины и высоты.
Перепроверка — наше всё. Поэтому открываем исходник непосредственно на исследуемом теге и проверяем уязвимый участок вручную. Только так получится понять, действительно ли баг присутствует в конкретной версии.
К счастью, 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
Вот на что нужно обратить внимание, помимо исправленного участка кода:
Присутствует ли в рассматриваемой версии требуемое поле или переменная в структуре.
Имел ли уязвимый механизм место в предшествующих сборках.
Исправление может вовсю применяться в актуальных версиях, но как быть с устаревшими релизами? Если они вышли до появления самой уязвимости, то и добавленный в ходе фикса патч по логике отсутствует. Выходит, такая сборка уязвима?
Не торопитесь с выводами. При детальном изучении индексации массива непосредственно на указанном теге часто выясняется, что путь безопасен: сам уязвимый сценарий возник лишь в результате более позднего рефакторинга. Получается, в анализируемой версии уязвимый код изначально не был реализован.
По завершении построчного сопоставления эталонного исходника необходимо сверить его с фактическим дизассемблированным кодом целевой функции в исследуемом .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-версии для эмпирического подтверждения. Это даст нам автоматический отчет о выходе за границы памяти и неопределенном поведении.
Компилируем исключительно нужные декодеры и демультиплексоры. Не забываем [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 и полным демультиплексор-пайплайном.

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, которые на каждом декоде печатают четыре адреса:
|
Переменная |
Описание переменной |
|
|
начало Cb-плоскости в памяти, |
|
|
куда фактически идет запись — начало OOB |
|
|
AVBufferRef, обертка вокруг буфера |
|
|
структура, которую мы будем перезаписывать |
Чтобы адреса не менялись при каждом запуске, я выключил ASLR (setarch -R). Теперь, когда адреса стали стабильны, достаточно снять их один раз и подставить в эксплойт как константы.


Как видно из дампа, 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.

Теперь собираем 640-байтовый payload. В нем на заранее рассчитанных байтовых позициях (согласно приведенной выше таблице) размещены данные, необходимые для подмены содержимого AVBuffer, а также сама исполняемая команда.
|
Смещение в payload |
Значение |
Назначение |
|
byte 0 |
строка |
команда для |
|
|
|
поле |
|
|
адрес system() в том же дебаг-билде) |
поле именно сюда попадет управление |
|
|
адрес байта 0 этого же payload |
поле |
Я не просто так задействовал начальный байт (смещение 0). Этот участок памяти заполнялся бы нулями при штатном декодировании без переполнения, и туда можно было бы поместить любые данные. Поэтому командная строка и значения для перезаписи AVBuffer размещены в едином массиве «пиксельных» данных. Не нужно никакой дополнительной инъекции в адресное пространство: декодер сам копирует информацию за пределы буфера в ходе OOB-записи.
Вот только нельзя просто вписать готовый payload в файл. Поскольку MagicYUV использует delta-кодирование (LEFT-предсказание), каждый хранящийся байт отличается от предыдущего. Проблему я решил, прогнав желаемые байты через ту же кодировку в обратную сторону (payload[i] - payload[i-1]). В результате после штатного decode-шага декодер сам восстановил именно те байты, которые нужны для эксплойта. К счастью, MagicYUV field encoding подробно разбирать не пришлось — его воспроизвели по формату из декодера.

Итак, осталось только инкапсулировать кадр в корректный 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"):

cat /tmp/pwned
uid=1000(tpad) gid=1000(tpad) groups=1000(tpad),...
Итак, мы получаем заветный RCE.
Описанная в статье методология помогает эффективно отлавливать эти бреши. Правда, ни одна методика не гарантирует вам RCE в каждом конкретном приложении. Многие подтвержденные WRITE-баги не приводят к реальному воздействию на систему. И даже при наличии impact-а дойти до рабочего эксплойта — тот еще квест. Но попытаться пройти его, я считаю, стоит.
И, как и обещал, бонус для любителей нейросетевого анализа мобильных приложений: небольшой чек-лист с вопросами, которые необходимо поэтапно задавать агенту в ходе разбора мобилки.

|
National Vulnerability Database* |
Github |
Прочее |
|
CVE-2026-8461 [15] |
||
|
CVE-2025-9951 [18] |
||
|
CVE-2024-22862 [20] |
https://github.com/google/sanitizers/wiki/AddressSanitizer [21] |
|
|
CVE-2018-14394 [24] |
https://github.com/DepthFirstDisclosures/ffmpeg-dfvuln127 [21] |
|
|
CVE-2016-3062 [26] |
||
|
CVE-2017-7859 [27] |
||
|
CVE-2016-10190 [28] |
||
|
CVE-2023-51793 [29] |
||
|
CVE-2021-38291 [30] |
*Еще стоит обратить внимание на 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
Нажмите здесь для печати.