
PoC для запуска CVE-2023-5217 через интерфейс WebCodecs или MediaRecorder браузера.
CVE-2023-5217 — это эксплуатируемая в реальных условиях уязвимость libvpx, обнаруженная Клеманом Лесинем из Группы анализа угроз Google (Threat Analysis Group), нацеленная на Chrome.
Этот репозиторий показывает, как вызвать CVE-2023-5217 в браузере с помощью API WebCodecs и MediaRecorder. CVE-2023-5217 позволяет выполнить переполнение буфера кучи с контролируемой длиной переполнения и перезаписью повторяющегося малого 4-байтового значения. В настоящее время неизвестно, как CVE-2023-5217 эксплуатировалась в реальных условиях.
Примерно на момент публичного раскрытия было выпущено два патча для libvpx и один для Chromium, которые устраняли CVE-2023-5217. Патчи для libvpx включали отключение изменений количества потоков VP8 и тест для многопоточного кодирования. Патч Chromium отключил регулировку количества потоков в WebCodecs.
libvpx — это библиотека для кодирования и декодирования VP8/VP9.
Ключевая проблема CVE-2023-5217 заключается в том, что уменьшение количества потоков при одновременном увеличении высоты кадра в сеансе кодирования VP8 в libvpx вызывает линейное переполнение кучи с контролируемой длиной и контролируемой перезаписью повторяющегося малого 4-байтового значения. Разница в высоте кадра контролирует длину перезаписи, а новая ширина кадра контролирует 4-байтовое значение, которое повторно записывается. Эта уязвимость может быть использована несколько раз для последовательной записи различных малых 4-байтовых значений путём уменьшения высоты в каждой последующей конфигурации.
Кодировщик VP8 в libvpx поддерживает массив mt_current_mb_col, который хранит текущий столбец, обрабатываемый потоком кодировщика. Этот массив выделяется только в том случае, если потоков больше одного, и его размер зависит от mb_rows, где mb_rows = frame_height >> 4, а frame_height округляется вверх до ближайшего кратного 16.
// https://github.com/webmproject/libvpx/blob/6512f994da13e2f27e6a7bd449efee0a374b55b7/vp8/common/alloccommon.c#L85
// The width and height are rounded up to a multiple of 16 and then assigned to `mb_rows` and `mb_cols`
int vp8_alloc_frame_buffers(VP8_COMMON *oci, int width, int height) {
...
// Round up the width/height up to the nearest multiple of 16
if ((width & 0xf) != 0) width += 16 - (width & 0xf);
if ((height & 0xf) != 0) height += 16 - (height & 0xf);
...
oci->mb_rows = height >> 4;
oci->mb_cols = width >> 4;
...
}
// https://github.com/webmproject/libvpx/blob/6512f994da13e2f27e6a7bd449efee0a374b55b7/vp8/encoder/onyx_if.c#L1232
// This snippet shows the allocation of `mt_current_mb_col`
void vp8_alloc_compressor_data(VP8_COMP *cpi) {
...
// Only allocate if we have more than 1 thread
if (cpi->oxcf.multi_threaded > 1) {
int i;
vpx_free(cpi->mt_current_mb_col);
// sizeof(*cpi->mt_current_mb_col) is 4
CHECK_MEM_ERROR(&cpi->common.error, cpi->mt_current_mb_col,
vpx_malloc(sizeof(*cpi->mt_current_mb_col) * cm->mb_rows));
for (i = 0; i < cm->mb_rows; ++i)
vpx_atomic_init(&cpi->mt_current_mb_col[i], 0);
}
...
}
После завершения кодирования кадра libvpx сохраняет количество закодированных столбцов плюс mt_sync_range в mt_current_mb_col.
// https://github.com/webmproject/libvpx/blob/6512f994da13e2f27e6a7bd449efee0a374b55b7/vp8/encoder/onyx_if.c#L1212
// Snippet where the mt_sync_range value is set, based on the width.
void vp8_alloc_compressor_data(VP8_COMP *cpi) {
...
#if CONFIG_MULTITHREAD
if (width < 640) {
cpi->mt_sync_range = 1;
} else if (width <= 1280) {
cpi->mt_sync_range = 4;
} else if (width <= 2560) {
cpi->mt_sync_range = 8;
} else {
cpi->mt_sync_range = 16;
}
#endif
...
}
// https://github.com/webmproject/libvpx/blob/6512f994da13e2f27e6a7bd449efee0a374b55b7/vp8/encoder/encodeframe.c#L560
// Function where the attacker chosen value is written
static void encode_mb_row(...) {
...
const int nsync = cpi->mt_sync_range; // This value is set in vp8_alloc_compressor_data
vpx_atomic_int rightmost_col = VPX_ATOMIC_INIT(cm->mb_cols + nsync);
...
if (vpx_atomic_load_acquire(&cpi->b_multi_threaded) != 0) {
current_mb_col = &cpi->mt_current_mb_col[mb_row];
}
...
if (vpx_atomic_load_acquire(&cpi->b_multi_threaded) != 0) {
// current_mb_col is a reference to mt_current_mb_col
vpx_atomic_store_release(current_mb_col,
vpx_atomic_load_acquire(&rightmost_col));
}
...
}
Для переполнения mt_current_mb_col требуются три конфигурации кодирования:
configinit: Во время инициализации libvpx устанавливает initial_width и initial_height. Это максимально возможные границы для последующих конфигураций. Количество потоков здесь не имеет значения. Нам нужна промежуточная конфигурация, потому что никакие последующие комбинации ширины/высоты не могут быть больше исходных.
configvuln: При перенастройке, если используется более одного потока, libvpx создаёт уязвимое выделение mt_current_mb_col на основе configvuln.height. Эта новая высота должна быть меньше configinit.initial_height, иначе мы получим ошибку.
configattack: При перенастройке, если используется только один поток, libvpx не будет перераспределять mt_current_mb_col, оставляя его в уязвимом состоянии. libvpx будет многократно записывать значение (configattack.width >> 4) + 1 (где 1 — это переменная mt_sync_range, а ширина округляется вверх до ближайшего кратного 16) за пределы ранее выделенной области, если выполняется следующее условие:
\text{ceil}(\text{config}_{\text{init}}.\text{height}/16) \geq \text{ceil}(\text{config}_{\text{attack}}.\text{height}/16) \gt \text{ceil}(\text{config}_{\text{vuln}}.\text{height}/16)$$

Более конкретно: предположим, мы инициализируем конфигурацию кодирования VP8 с configinit с шириной = 1200, высотой = 1200, потоками = 4. Атака выглядит следующим образом:
configvuln перенастраивает кодировщик с шириной = 500 (округлено до 512), высотой = 700 (округлено до 704) и потоками = 2. Переменная mb_rows устанавливается в 704/16=44, массив mt_current_mb_col выделяется размером (44)*4 = 176 байт. Записанное значение, хранящееся в mt_current_mb_col, равно 512/16 + 1 = 33.
configattack перенастраивает кодировщик с шириной = 18 (округлено до 32), высотой = 1000 (округлено до 1008) и потоками = 1. Поскольку mt_current_mb_col перераспределяется только при наличии более одного потока, он остаётся того же размера, но mb_rows теперь устанавливается в 1008/16 = 63. Когда libvpx вызывает encode_mb_row, он перезаписывает (63-44)*4 = 68 байт после выделения mt_current_mb_col, многократно записывая значение 32/16 + 1 = 3, где 32 — округлённая ширина, а 1 — значение mt_sync_range.
Злоумышленник может повторно использовать эту уязвимость с высотой, меньшей, чем в configattack, но всё же большей, чем в configvuln, чтобы записать другое значение. Например, злоумышленник может создать configattack' с шириной = 34 и высотой = 990, установив mb_rows = 992/16 = 62 и mb_cols = 48/16 = 3, записав только (62-44)*4 = 64 байта после исходного выделения значением 4.
Для эксплуатации этой уязвимости злоумышленник должен иметь возможность контролировать высоту, ширину и количество потоков кодирования. Первые два параметра контролировать просто, но для последнего требуется найти места, где количество потоков кодирования перенастраивается.
// https://github.com/webmproject/libvpx/blob/67bfb41ed8598edfb25bd6f245f9c39a68808548/vp8/vp8_cx_iface.c#L301
static vpx_codec_err_t set_vp8e_config(VP8_CONFIG *oxcf,
...
oxcf->multi_threaded = cfg.g_threads;
В Firefox мы можем контролировать количество потоков, регулируя площадь кадра, который мы кодируем в VP8TrackEncoder. Если площадь кадра больше 307 200 (кадр 640x480) и на машине более 2 ядер, то будет использоваться более одного потока.
// https://searchfox.org/mozilla-central/source/dom/media/encoder/VP8TrackEncoder.cpp#97
nsresult CreateEncoderConfig(...) {
...
int32_t number_of_cores = PR_GetNumberOfProcessors();
if (aWidth * aHeight > 1920 * 1080 && number_of_cores >= 8) {
config->g_threads = 4; // 4 threads for > 1080p.
} else if (aWidth * aHeight > 1280 * 960 && number_of_cores >= 6) {
config->g_threads = 3; // 3 threads for 1080p.
} else if (aWidth * aHeight > 640 * 480 && number_of_cores >= 3) {
config->g_threads = 2; // 2 threads for qHD/HD.
} else {
config->g_threads = 1; // 1 thread for VGA or less
}
...
Мы обнаружили, что API MediaRecorder использует VP8TrackEncoder, и мы можем изменять ширину и высоту, меняя размер записываемого холста. См. раздел MediaRecorder ниже о том, как это вызвать.
Chrome аналогичным образом регулирует количество потоков на основе площади кодируемого кадра с учётом количества ядер.
// https://source.chromium.org/chromium/chromium/src/+/main:media/video/vpx_video_encoder.cc;l=84
EncoderStatus SetUpVpxConfig(...) {
...
// Set the number of threads based on the image width and num of cores.
config->g_threads = GetNumberOfThreadsForSoftwareEncoding(opts.frame_size);
}
// https://source.chromium.org/chromium/chromium/src/+/main:media/base/video_encoder.cc;drc=f5bdc89c7395ed24f1b8d196a3bdd6232d5bf771;l=33
int GetNumberOfThreadsForSoftwareEncoding(gfx::Size frame_size) {
int area = frame_size.GetCheckedArea().ValueOrDefault(1);
// Default to 1 thread for less than VGA.
int desired_threads = 1;
if (area >= 3840 * 2160) {
desired_threads = 16;
} else if (area >= 2560 * 1080) {
desired_threads = 8;
} else if (area >= 1280 * 720) {
desired_threads = 4;
} else if (area >= 640 * 480) {
desired_threads = 2;
}
// Clamp to the number of available logical processors/cores.
desired_threads =
std::min(desired_threads, base::SysInfo::NumberOfProcessors());
return desired_threads;
}
Этот путь используется API WebCodecs VideoEncoding, где мы можем напрямую изменять ширину/высоту кодирования. См. раздел WebCodecs, чтобы узнать, как это работает.
Файл mediarecorder.html показывает, как создать сеанс MediaRecorder из холста и изменить ширину/высоту, чтобы вызвать перенастройку кодирования VP8 и тем самым спровоцировать CVE-2023-5217 в уязвимом браузере. При изменении параметров ширины и высоты холста мы используем setTimeout, чтобы гарантировать, что сеанс кодирования VP8 успеет перенастроиться. Параметр таймаута можно настроить для повышения надёжности.
Статус
Для тестирования в Firefox вы можете использовать fuzzfetch, чтобы получить сборку ASAN до исправления этой CVE, выполнив команду fuzzfetch --build 2023-09-27 -a, а затем открыть mediarecorder.html напрямую.

Файлы webcodecs.html и webcodecs.js показывают, как использовать API WebCodecs в Worker для запуска CVE-2023-5217 в уязвимом браузере. У нас больше контроля над вызовами для кодирования кадра в WebCodecs, чем в MediaRecorder, но мы всё ещё полагаемся на таймаут для выполнения каждого из трёх шагов.
Статус
Для тестирования в Chromium вы можете использовать get_asan_chrome.py, чтобы получить уязвимую версию Chrome с помощью команды python get_asan_chrome.py --version 117.0.5938.131. Затем вам нужно будет запустить локальный HTTP-сервер с SSL. См. gen_server_key.sh и server.py для генерации ключа сервера и запуска сервера. После этого вы можете просто открыть страницу в уязвимом Chromium, чтобы увидеть результат.

См. combined.html, который использует MediaRecorder как запасной вариант, если WebCodecs не найден. Этот комбинированный файл можно использовать для атаки как на Chrome, так и на Firefox с одной и той же страницы.
Эта уязвимость демонстрирует проблемы и опасности предоставления удалённому злоумышленнику доступа к сложным медиа-библиотекам. Используя такие инструменты, как RLBox, браузеры могут изолировать потенциальные уязвимости в медиа-библиотеках. Firefox уже поставляет эту функцию в отдельных библиотеках.
Спасибо за чтение! Приветствуются любые вклады. Не стесняйтесь создавать Issue или открывать PR с любыми другими идеями. Что остаётся изучить — это то, как эта небольшая 4-байтовая перезапись может привести к выполнению кода.
Спасибо Ананду Баладжи за отзывы о предыдущей версии.