
Analyse und PoC für CVE-2025-14174 - ANGLE Metal OOB write (iOS Safari, macOS Chrome)
Technische Analyse und Proof-of-Concept für CVE-2025-14174
| CVE | CVE-2025-14174 |
| Schweregrad | Hoch |
| Exploited ITW | Ja – gezielte Angriffe auf iOS < 26 |
| Betroffen | iOS Safari, macOS Chrome/Chromium/Electron (nicht macOS Safari) |
| Status | Gepatched in ANGLE Commit 95a32cb |
| Credit | Apple, Google Threat Analysis Group |
Laut Apple wurde CVE-2025-14174 als Teil eines „extrem ausgeklügelten Angriffs gegen bestimmte gezielte Personen“ auf iOS-Versionen vor iOS 26 ausgenutzt.
Die Angriffskette umfasste:
Eine Out-of-Bounds (OOB)-Schreibschwachstelle existiert im Metal-Backend von ANGLE beim Hochladen von Tiefentexturen über einen Staging-Buffer. Die Größe des Staging-Buffers wird unter Verwendung von GL_UNPACK_IMAGE_HEIGHT anstelle der tatsächlichen Texturhöhe berechnet. Wenn UNPACK_IMAGE_HEIGHT < height ist, alloziert ANGLE einen zu kleinen Buffer und schreibt anschließend height Zeilen hinein, was zu einer GPU-Speicherverfälschung im Renderer-Prozess führt.
Diese Schwachstelle betrifft Anwendungen, die das Metal-Backend von ANGLE für WebGL verwenden:
| Plattform | Software | Betroffen | Anmerkungen |
|---|---|---|---|
| iOS | Safari | Ja | WebKit auf iOS verwendet ANGLE Metal für WebGL |
| macOS | Chrome / Chromium | Ja | Verwendet ANGLE Metal Backend |
| macOS | Electron Apps | Ja | Verwendet Chromiums ANGLE-Implementierung |
| macOS | Safari | Nein | Verwendet WebKits nativem Metal WebGL, nicht ANGLE |
iOS Safari ist betroffen. Auf iOS verwendet WebKit ANGLE als sein WebGL-Backend, was Safari auf iPhone und iPad angreifbar macht.
macOS Safari ist NICHT betroffen. Auf macOS verwendet Safari WebKits eigene native WebGL-Implementierung, die direkt mit Metal interagiert und ANGLE vollständig umgeht.
macOS Chrome ist betroffen. Google Chrome, das unter macOS 26.1 lief, schien während Tests angreifbar, da es ANGLEs Metal-Backend für WebGL verwendet.
Der anfällige Codepfad existiert in ANGLEs TextureMtl-Klasse (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Schweregrad | Beschreibung |
|---|---|
| Bestätigt | GPU/Metal-Backend schreibt über das Ende des Staging-Buffers hinaus |
| Bestätigt | Reproduzierbar über WebGL2 + PBO + DEPTH_COMPONENT32F |
| Wahrscheinlich | GPU-Prozess-Absturz oder Kontextverlust unter Speicherdruck |
| Theoretisch | Ressourcenübergreifende Verfälschung im GPU-Speicher (nicht demonstriert) |
Wesentliche Merkmale:
NO_ERROR zurück)Im D32F-Tiefentextur-Uploadpfad berechnet ANGLE pixelsDepthPitch aus GL_UNPACK_IMAGE_HEIGHT und verwendet diesen Wert zur Größenermittlung des Staging-MTLBuffer. Die nachfolgende Compute-Dispatch (Tiefensättigung) verwendet jedoch die tatsächliche Texturhöhe für die Operation, was zu einem OOB-Schreibzugriff führt, wenn die Parameter abweichen.
Für width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
| Parameter | Berechnung | Wert |
|---|---|---|
| Zeilenabstand | width * sizeof(float) | 4 Bytes |
| Staging-Buffer (alloziert) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 Bytes |
| Compute-Dispatch (geschrieben) | rowPitch * actualHeight | 2048 Bytes |
| OOB-Schreibzugriff | 2048 - 512 | 1536 Bytes |
Alle folgenden müssen zutreffen:
DEPTH_COMPONENT32F (bestätigt; andere Tiefenformate könnten ebenfalls betroffen sein, sind aber nicht getestet)PIXEL_UNPACK_BUFFER gebundenGL_UNPACK_IMAGE_HEIGHT auf einen Wert kleiner als die tatsächliche Texturhöhe gesetztGL_UNPACK_IMAGE_HEIGHT ist laut GL-Spezifikation für 3D/Array-Textur-Uploads vorgesehen, nicht für 2D-Texturen. Für TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height für 2D-Texturen nicht abWebGL 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
│ └── Berechnet pixelsDepthPitch = rowPitch × UNPACK_IMAGE_HEIGHT
│
├── TextureMtl::setPerSliceSubImage
│ └── mtl::Buffer::MakeBufferWithStorageMode(context, 0, pixelsDepthPitch, ...) ← ZU KLEIN
│
└── SaturateDepth
├── getComputeCommandEncoder()
├── setBuffer(stagingBuffer, index=2)
└── dispatchThreads(MTLSize{width, actualHeight}) ← VERWENDET ECHTE HÖHE
| Funktion | Adresse | Rolle |
|---|---|---|
setSubImageImpl | 0x272fa9028 | Berechnet zu kleinen depthPitch |
setPerSliceSubImage | 0x272fac240 | Alloziert zu kleinen Staging-Buffer |
MakeBufferWithStorageMode | 0x272ef19bc | Erstellt MTLBuffer mit falscher Größe |
SaturateDepth | 0x272facfa4 | Dispatch Compute mit tatsächlichen Dimensionen |
; setSubImageImpl - berechne zu kleinen depthPitch
0x272fa90f4: ldr w8, [x25, #0x10] ; Lade UNPACK_IMAGE_HEIGHT
0x272fa9100: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - rufe MakeBufferWithStorageMode mit zu kleinem depthPitch auf
0x272fac5bc: mov x2, x19 ; x2 = Größe (zu kleiner depthPitch)
0x272fac5c4: bl #0x272ef19bc ; rufe MakeBufferWithStorageMode auf
| Funktion | Adresse | Rolle |
|---|---|---|
setSubImageImpl | 0x22c6d10f4 | Berechnet zu kleinen depthPitch |
setPerSliceSubImage | 0x22c6d4398 | Alloziert zu kleinen Staging-Buffer |
MakeBufferWithStorageMode | 0x22c619490 | Erstellt MTLBuffer mit falscher Größe |
SaturateDepth | 0x22c6d5144 | Dispatch Compute mit tatsächlichen Dimensionen |
; setSubImageImpl - berechne zu kleinen depthPitch
0x22c6d11c0: ldr w8, [x25, #0x10] ; Lade UNPACK_IMAGE_HEIGHT
0x22c6d11cc: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - rufe MakeBufferWithStorageMode mit zu kleinem depthPitch auf
0x22c6d461c: ldr x20, [sp, #0x48] ; Lade depthPitch vom Stack
0x22c6d4628: bl MakeBufferWithStorageMode