Привет, Хабр!
Меня зовут Егор Боровинских, старший аналитик безопасности веб-приложений в компании «Гарда».
Сегодня хочу поговорить о FFmpeg. Для тех, кто не в курсе, эта open-source библиотека является индустриальным стандартом для мобильных приложений с функциями обработки аудио и видео, которая разбирает контейнеры (MP4, MOV, AVI, RTSP и т. п.) на отдельные потоки. А знаете, что бонусом к этому фреймворку идет порядка 543 зарегистрированных CVE? Причем эти уязвимости фигурируют в программах bugbounty.
Немного цифр в подтверждение важности темы. По данным отчета Quokka «State of Mobile App Security 2026», охватившего более 150 тыс. приложений, критические уязвимости (CVE) в сторонних компонентах обнаружены в 11% приложений для Android и 13% — для iOS. В 65% Android-приложений зафиксированы уязвимости высокого уровня. Безусловно, FFmpeg — не единственный фреймворк, содержащий уязвимости, но он явно один из лидеров в этом списке.
Под катом разберу, как находить и атрибутировать FFmpeg, как выявлять в нем паттерны, определять версии и, конечно же, эффективно отлавливать уязвимости и баги. А в конце статьи любителей нейросетевого анализа мобильных приложений ждет небольшой бонус.
Статья рассчитана на специалистов, которые уже имеют опыт работы с Android-приложениями, разбираются в reverse engineering ELF-библиотек, обладают базовыми знаниями о memory corruption, ну или хотя бы умеют подключать к анализу нейронки.
Дисклеймер: все материалы статьи предоставлены исключительно в образовательных целях, и мы не несем ответственности за ее неправомерное применение.
Обзор популярных CVE
В качестве разминки предлагаю взглянуть на несколько известных уязвимостей в FFmpeg. Разбор всех 543 существующих CVE потянет на «Войну и Мир» и «Тихий Дон» вместе взятые. Так что тут ограничусь тремя примерами (но в конце статьи привел список CVE, которые, на мой взгляд, заслуживают внимания).
PixelSmash (CVE-2026-8461)
В июне 2026 года специалисты из JFrog представили детальный анализ уязвимости PixelSmash (CVE-2026-8461, CVSS 8.8). Баг — запись за пределы выделенной памяти (heap OOB write) в декодере MagicYUV (libavcodec/magicyuv.c), доведенный до полного RCE.
Кратко опишу основную механику.
Алгоритм MagicYUV разбивает все кадры на горизонтальные сегменты (slice) с фиксированным параметром
slice_height. Каждый сегмент обрабатывается автономно. Еслиslice_heightпринимает нечетное значение, возникает несоответствие между механизмом выделения памяти под кадр и логикой декодера. Дополнительные строки пикселей записываются за пределы выделенного буфера.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), и команда исполняется с привилегиями процесса.
HTTP chunked overflow (CVE-2016-10190)
Речь идет о CVSS 9.8, RCE. Проблема локализована в обработчике HTTP-протокола libavformat/http.c. Публично доступный рабочий эксплойт размещен в репозитории.
Механика эксплуатации следующая.
При использовании 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, а вредоносный код злоумышленника.
AV1 RTP depacketizer (DFVULN-127)
Эту уязвимость нашел в 2026 году автономный агент depthfirst в RTP-депакетайзере AV1 (компоненте, который восстанавливает кадры из отдельных RTP-пакетов) — libavformat/rtpdec_av1.c, функция av1_handle_packet(). Эксплуатация бреши позволяет достичь RCE без аутентификации с помощью одного RTP-пакета размером 183 байта. PoC (полноценный Docker-репро) доступен тут.
И снова кратко рассмотрим механику уязвимости.
Передаваемый через 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, которое находится на фиксированном смещении 152 байта от начала данных в уже поврежденной области памяти. Причем атакующий не трогает соседнее поле refcount (счетчик ссылок). Если последнее обнулится преждевременно, структура освободится раньше времени, и вся цепочка атаки нарушится.
Снова вызывается
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


Итак, пакет распакован — теперь проверяем, есть ли вообще 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).
Определяем версии FFmpeg
Теперь определим версию фреймворка, ведь от этого могут зависеть актуальные уязвимости и особенности поведения. Напомню, что версия 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и впоследствии может никак не изменяться. Тогда не остается ничего, кроме как выполнить сравнительный анализ кода.
Извлекаем 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_]+"

Бывает, что строка удалена более агрессивным стриппером. Тогда экспортируем 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_]+'
Опасные протоколы:
Протокол |
Риск |
|
Чтение произвольного локального файла (LFI), если приложение ожидает |
|
Конкатенация нескольких путей одним URL-параметром |
|
Чтение произвольного диапазона байт файла ( |
|
Классический SSRF, если AllowAutoRedirect-подобное поведение не ограничено на 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-слое. Реальный же путь шел через второй класс — именно там и скрывалась уязвимость.
Hardware decode fallback: основной путь или аварийный вариант?
В целом ряде Android-приложений по умолчанию используется аппаратный декодер android.media.MediaCodec, а FFmpeg задействуется, лишь если что-то пошло не так. Если уязвимый FFmpeg-путь не является основным, еще нужно проверить, насколько реально его можно использовать для атаки. Для этого сначала находим условие, по которому выбирается этот путь: это может быть либо конструкция try {MediaCodec... } catch { FFmpeg fallback }, либо явная проверка через MediaCodecList.
Вектор атаки действительно подтверждается, если выбор определяется конкретной связкой «кодек + профиль + уровень», которую атакующий может воссоздать через специально подготовленный поток.
grep -rl "MediaCodec\|createDecoderByType" --include="*.smali".
Помимо условной достижимости, не стоит упускать из виду и сам Android-мост поверх MediaCodec. Обратимся к отчету 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);
Обнаруживается этот баг следующим образом:
Выявляем скомпилированные image-декодеры.
Сверяем коммиты для указанных файлов, отфильтровав по ключевым словам:
overflow, INT_MAX и av_image_check_size. Если в анализируемой версии вызов этой валидирующей функции отсутствует — это хороший сигнал.Выполняем построчный анализ кода там, где проверка производится непосредственно перед выделением памяти. Подготовить 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
Вот на что нужно обратить внимание, помимо исправленного участка кода:
Присутствует ли в рассматриваемой версии требуемое поле или переменная в структуре.
Имел ли уязвимый механизм место в предшествующих сборках.
Исправление может вовсю применяться в актуальных версиях, но как быть с устаревшими релизами? Если они вышли до появления самой уязвимости, то и добавленный в ходе фикса патч по логике отсутствует. Выходит, такая сборка уязвима?
Не торопитесь с выводами. При детальном изучении индексации массива непосредственно на указанном теге часто выясняется, что путь безопасен: сам уязвимый сценарий возник лишь в результате более позднего рефакторинга. Получается, в анализируемой версии уязвимый код изначально не был реализован.
По завершении построчного сопоставления эталонного исходника необходимо сверить его с фактическим дизассемблированным кодом целевой функции в исследуемом .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-версии для эмпирического подтверждения. Это даст нам автоматический отчет о выходе за границы памяти и неопределенном поведении.
Компилируем исключительно нужные декодеры и демультиплексоры. Не забываем про флаги --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.
Базовый прогон 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). Для проверки ее применимости мне пришлось построчно разобрать дизассемблированный код.
Поскольку целевая функция (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 |
Прочее |
*Еще стоит обратить внимание на DFVULN-116…127 (у них нет выданного CVE): heap/stack overflow, integer over/underflow в RTP-депакетайзерах (AV1, JPEG, LATM, MPEG-4), AVIF, CAF, AVI, RTSP-сервере.