Привет, Хабр!

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

Сегодня хочу поговорить о 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.

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

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

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

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

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

HTTP chunked overflow (CVE-2016-10190)

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

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

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

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

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

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

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

AV1 RTP depacketizer (DFVULN-127)

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

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

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

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

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

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

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

Ищем FFmpeg в APK

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

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

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

apktool d app.apk

Итак, пакет распакован — теперь проверяем, есть ли вообще 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-библиотеку (отдельныхlibavcodec.so и libavformat.so в APK нет).

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

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

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

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

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

Java/Kotlin-обертка

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

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

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

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

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

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

Рекомендую проверять также нестандартные варианты названий библиотек. К примеру, в реальных проектах встречаются наименования наподобие 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 год), обычно говорит о заброшенности одной из зависимостей. 

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

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

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

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

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

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

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

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

(decoder|demuxer|protocol|muxer|filter|parser)=[a-z0-9_]+"

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

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

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

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

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

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

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

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

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

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

Протокол

Риск

file

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

concat:

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

subfile:

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

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

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

rtmp / rtsp / mmsh

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

lavfi

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

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

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

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

"^smali/com/pkg/SomeClass"

grep -n "SomeClass" AndroidManifest.xml

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

"^smali/com/pkg/SomeClass"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

curl -s

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

e.c" 

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

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

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

int buf_size = width  height  bytes_per_pixel;

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

uint8_t *buf = av_malloc(buf_size);

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

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

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

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

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

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

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

curl -s

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

e.c" > before.c

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

libavcodec/h264_slice.c" > after.c

diff before.c after.c

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

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

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

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

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

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

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

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

> clean_disasm.txt

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

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

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

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

Компилируем исключительно нужные декодеры и демультиплексоры. Не забываем про флаги --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, которые на каждом декоде печатают четыре адреса:

Переменная

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

p->data[1]

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

Dst

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

p->buf[1]

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

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

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

Чтобы адреса не менялись при каждом запуске, я выключил 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

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

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

+272

refcount = 1

поле AVBuffer.refcount

+280

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

поле AVBuffer.free —

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

+288

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

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

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

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

Итак, осталось только инкапсулировать кадр в корректный 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-а дойти до рабочего эксплойта — тот еще квест. Но попытаться пройти его, я считаю, стоит.

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

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

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

Комментарии (0)