← All reports

[3] IOSurface from renderer send right consumed without validation

HighWebKit UI process IPC surfaceOOB

The privileged process took the sandboxed one's word for a buffer's shape.

c374428

High — 권한을 가진 host process가 sandbox에 갇힌 child process의 말만 믿고 공유 buffer의 geometry를 그대로 받아들인 뒤 이를 순회합니다. 이 primitive는 읽기 전용이라는 점에서 그 자체로 완결된 escape보다는 낮은 수준에 머물지만, UI process crash를 넘어서는 확장 여부는 결과 이미지가 attacker에게 관찰 가능한지에 달려 있습니다.

WebKit은 작업을 여러 process로 분리합니다. UI process는 embedding application과 WebKit API를 host하며 renderer의 sandbox에 갇히지 않는 반면, WebContent는 페이지를 렌더링하는 sandbox 안의 child process입니다. Pixel 데이터는 두 process 사이를 IOSurface로 이동합니다. IOSurface는 커널이 관리하는 공유 bitmap으로, width, height, bytesPerRow, pixelFormat, allocSize 같은 자기 서술적 metadata를 함께 가지고 있으며, process 경계를 넘어서는 참조는 MachSendRight — 전달 가능한 Mach port right로, 수신 측에서 실제 surface로 resolve됩니다 — 를 통해 이루어집니다. 수신 측이 기대하는 바는, IPC로 도착한 surface가 WebKit 자체의 IOSurface::create()가 생성한 것이어야 한다는 점입니다. 즉 압축되지 않은 단일 plane이며, metadata 필드끼리 서로 일관되어야 합니다.

관전 포인트: renderer에서 code execution을 확보한 attacker는 snapshot 요청에 대해 악의적인 surface로 응답할 수 있고, 이를 통해 권한을 가진 UI process가 mapping된 allocation 범위를 넘어 읽도록 유도할 수 있습니다. 최소한 브라우저 세션 전체가 crash하며, 결과 이미지가 관찰 가능한 경우에는 인접 메모리 노출로 이어질 가능성도 있습니다.

In WebPageProxy::takeSnapshot(), validate IOSurface from MachSendRight

WebPageProxy::takeSnapshot() now uses the new IOSurface::createFromUntrustedSendRight(), which has stronger checks of the MachSendRight-provided IOSurface, expecting a valid IOSurface as produced uncompressed and uni-planar from IOSurface::create().

Source/WebCore/platform/graphics/cocoa/IOSurface.mm

+template <unsigned bytesPerElement>
+static std::unique_ptr<IOSurface> validateAndCreateFromUntrustedSurface(IOSurfaceRef surface)
+{
+ static_assert(bytesPerElement > 0);
+ auto width = IOSurfaceGetWidth(surface);
+ auto height = IOSurfaceGetHeight(surface);
+ auto bytesPerRow = IOSurfaceGetBytesPerRow(surface);
+ if (!width || !height || !bytesPerRow)
+ return nullptr;
+ auto maxSize = IOSurface::maximumSize();
+ if (width > size_t(maxSize.width()) || height > size_t(maxSize.height()))
+ return nullptr;
+ auto rowBytes = CheckedSize { width } * bytesPerElement;
+ if (rowBytes.hasOverflowed() || rowBytes.value() > bytesPerRow)
+ return nullptr;
+ auto totalBytes = CheckedSize { bytesPerRow } * height;
+ auto allocSize = IOSurfaceGetAllocSize(surface);
+ if (totalBytes.hasOverflowed() || totalBytes.value() > allocSize)
+ return nullptr;
+
+ return IOSurface::createFromSurface(surface, { });
+}
+
+std::unique_ptr<IOSurface> IOSurface::createFromUntrustedUncompressedWebKitSendRight(const MachSendRight&& sendRight)
+{
+ ASSERT(ProcessCapabilities::canUseAcceleratedBuffers());
+
+ auto surface = adoptCF(IOSurfaceLookupFromMachPort(sendRight.sendRight()));
+ if (!surface)
+ return nullptr;
+
+ unsigned pixelFormat = IOSurfaceGetPixelFormat(surface.get());
+ switch (pixelFormat) {
+ case kCVPixelFormatType_32BGRA:
+ case kCVPixelFormatType_32RGBA:
+#if ENABLE(PIXEL_FORMAT_RGB10)
+ case kCVPixelFormatType_30RGBLEPackedWideGamut:
+#endif
+ return validateAndCreateFromUntrustedSurface<4>(surface.get());
+
+#if ENABLE(PIXEL_FORMAT_RGBA16F)
+ case kCVPixelFormatType_64RGBAHalf:
+ return validateAndCreateFromUntrustedSurface<8>(surface.get());
+#endif
+
+ default:
+ break;
+ }
+
+ return { };
+}

Source/WebKit/UIProcess/WebPageProxy.cpp

, [&image] (MachSendRight& machSendRight) {
 
- if (auto surface = WebCore::IOSurface::createFromSendRight(WTF::move(machSendRight)))
+ if (auto surface = WebCore::IOSurface::createFromUntrustedUncompressedWebKitSendRight(WTF::move(machSendRight)))
image = WebCore::IOSurface::sinkIntoImage(WTF::move(surface));
}

Tools/TestWebKitAPI/Tests/WebCore/cocoa/IOSurfaceTests.mm

+TEST(IOSurfaceTest, createFromUntrustedUncompressedWebKitSendRightYUV422)
+{
+ auto original = WebCore::IOSurface::create(nullptr, { 5, 5 }, WebCore::DestinationColorSpace::ExtendedRec2020(), WebCore::IOSurface::Name::Default, WebCore::IOSurface::Format::YUV422);
+ ASSERT_NE(original, nullptr);
+
+ auto roundTripped = WebCore::IOSurface::createFromUntrustedUncompressedWebKitSendRight(original->createSendRight());
+ ASSERT_EQ(roundTripped, nullptr);
+}

이번 변경은 두 갈래로 나뉩니다. WebCore의 Cocoa IOSurface wrapper 안에 새로운 검증 import 경로가 추가되었고, UI process의 단일 호출 지점이 이 경로를 사용하도록 전환되었으며, 새 계약을 고정하는 GTest도 함께 추가되었습니다.

WebCore 쪽에서는 파일 내부 template인 validateAndCreateFromUntrustedSurface<bytesPerElement>(IOSurfaceRef)IOSurfaceGetWidth, IOSurfaceGetHeight, IOSurfaceGetBytesPerRow를 읽어 0 값을 거부하고, IOSurface::maximumSize()를 초과하는 width/height를 거부합니다. 이어서 새로 #import <wtf/CheckedArithmetic.h>로 들여온 CheckedSize를 사용해 width * bytesPerElement <= bytesPerRowbytesPerRow * height <= IOSurfaceGetAllocSize(surface)를 요구하며, overflow나 불일치가 있으면 IOSurface::createFromSurface로 넘어가기 전에 nullptr을 반환합니다. 공개 factory인 IOSurface::createFromUntrustedUncompressedWebKitSendRight(const MachSendRight&&)ProcessCapabilities::canUseAcceleratedBuffers()를 assert한 뒤 IOSurfaceLookupFromMachPort를 수행하고, IOSurfaceGetPixelFormat으로 분기합니다. kCVPixelFormatType_32BGRA, kCVPixelFormatType_32RGBA, kCVPixelFormatType_30RGBLEPackedWideGamut는 4-byte-per-element validator로, kCVPixelFormatType_64RGBAHalf는 8-byte-per-element validator로 향하며, 그 외 모든 pixel format은 return { }로 빠집니다.

WebKit 쪽에서는 takeSnapshot() reply handler의 MachSendRight 분기가 기존의 IOSurface::createFromSendRight() — 단순히 IOSurfaceLookupFromMachPortcreateFromSurface만 수행하고 어떤 property 검사도 없던 함수 — 대신 새 검증 factory를 사용하도록 바뀌었습니다. GTest는 세 가지 케이스로 구성됩니다. SRGB BGRA surface와 RGBA16F surface는 round-trip에서 살아남아야 하고, YUV422 surface는 nullptr로 거부되어야 합니다.

하위 권한 process에서 넘어온 공유 메모리 handle에 딸린 자기 서술적 metadata를 그대로 신뢰하고, 이를 사용하기 전에 buffer의 geometry 제약 조건을 다시 검증하지 않은 패턴.

WebKit process model. UI process는 embedding application과 WebKit API를 host하며, WebContent는 페이지를 렌더링하는 sandbox 안의 child process입니다. WKWebView 계열의 snapshot API는 WebPageProxy::takeSnapshot()이 처리하는데, 이 함수는 renderer에게 특정 영역을 그려달라고 요청한 뒤 결과 pixel을 ShareableBitmap으로 받거나, 이번 patch가 다루는 분기에서는 MachSendRight로 참조되는 IOSurface로 받습니다. 이후 IOSurface::sinkIntoImage()가 이 surface를 호출자를 위한 CGImageRef로 변환합니다.

IOSurface. Cocoa/커널이 관리하는 공유 pixel buffer 객체입니다. IOSurfaceGetWidth, IOSurfaceGetHeight, IOSurfaceGetBytesPerRow, IOSurfaceGetPixelFormat, IOSurfaceGetAllocSize처럼 자기 서술적인 metadata를 함께 가지고 있어 소비자는 이 값들을 읽어 mapping된 byte를 해석합니다. IOSurfaceCreate는 호출자가 이 property들을 서로 독립적으로 지정할 수 있게 허용합니다.

Stride와 alloc size. bytesPerRow(stride)는 한 pixel row의 시작에서 다음 row 시작까지의 byte 거리입니다. 정상적인 surface라면 최소 width * bytesPerElement이며, alignment padding으로 인해 더 클 수도 있습니다. allocSize는 surface의 backing allocation 전체 크기입니다.

MachSendRight. 전달 가능한 Mach port right입니다. IOSurfaceLookupFromMachPort()는 이를 수신 측 process에서 실제 IOSurfaceRef로 변환합니다. 이 과정이 곧 surface가 process 경계를 넘어 공유되는 방식입니다.

Pixel format. kCVPixelFormatType_* 형태의 4문자 code로, pixel당 byte 수와 channel 배치를 나타냅니다. YUV 4:2:2를 비롯한 일부 format은 여러 plane으로 구성되거나 chroma subsampling을 사용하기 때문에, base + y * bytesPerRow 같은 단일 linear 모델로는 메모리 구조를 전혀 설명할 수 없습니다.

CheckedSize. WTF의 overflow 검사가 내장된 size_t wrapper입니다. hasOverflowed()는 산술 연산 과정에서 값이 wrap되었는지를 알려줍니다.

이 버그의 본질은 process 신뢰 경계에서 attacker가 제공한 resource handle에 대한 검증이 누락되었다는 점입니다. 그 결과로 out-of-bounds read가 발생하며, non-linear pixel format에 대해서는 layout/format confusion 측면도 함께 존재합니다.

  WebContent (sandboxed)                 UI process (privileged)
  ──────────────────────                 ───────────────────────
  IOSurfaceCreate(                       takeSnapshot() reply handler
    width  = 0x10000,   ──MachSendRight──►  createFromSendRight()
    bytesPerRow = 64,                          └─ IOSurfaceLookupFromMachPort
    allocSize   = small,                       └─ createFromSurface  (no checks)
    format = 32BGRA)                              └─ sinkIntoImage()
                                                     walks width * 4 bytes/row
                                                     for height rows  ──► OOB read
                            ▲
                            └── the boundary the bug crosses

Fix 이전에는 UI process가 들어온 send right를 IOSurface::createFromSendRight()로 resolve했습니다. 이 함수는 IOSurfaceLookupFromMachPort()createFromSurface()만 수행할 뿐, resolve된 surface의 어떤 property도 검사하지 않았습니다. 여기서 빠져 있던 invariant는, IPC로 도착한 surface가 반드시 IOSurface::create()가 만든 것이라는 보장이 없다는 점입니다. 송신 측은 IOSurfaceCreate를 직접 호출해 임의의 kIOSurfacePixelFormat, 임의의 kIOSurfaceWidth/kIOSurfaceHeight를 지정할 수 있고, 무엇보다 kIOSurfaceBytesPerRowkIOSurfaceAllocSize를 이들과 무관하게 독립적으로 설정할 수 있습니다. 커널이 이런 불일치한 생성을 허용한다는 점은 이번 fix 전체의 전제에 해당합니다. diff나 제공된 source 어디에도 이 사실이 직접 명시되어 있지는 않지만, patch가 설치하는 조건들의 형태 자체가 이 전제가 성립할 때만 의미를 가집니다.

createFromSurface() 이후의 소비자들 — 이 호출 지점에서는 sinkIntoImage()createImage() — 은 surface를 압축되지 않은 단일 plane, pixel당 N byte 이미지로 취급하고, 읽어야 할 범위를 surface 자신의 metadata로부터 유도합니다. 이번 fix는 서로 구분되는 세 가지 metadata 불일치를 막습니다.

  1. 선언된 format과 선언된 stride 사이의 어긋남. bytesPerElementIOSurfaceGetPixelFormat 자체에서 선택되므로, width * bytesPerElement <= bytesPerRow 검사는 특이한 소형 element format을 겨냥한 것이 아닙니다. 그런 format은 switch문의 default:로 빠집니다. 실제로 이 검사가 막는 것은, kCVPixelFormatType_32BGRA처럼 allowlist에 포함된 format(즉 pixel당 4byte)을 선언하면서도 큰 widthwidth * 4보다 작은 bytesPerRow를 함께 선언하는 경우입니다. 이런 surface를 row당 4byte pixel width개씩 순회하는 소비자는 각 declared row의 끝을 넘어서게 되고, 누적되면 allocation 끝까지 넘어서게 됩니다.
  2. Row와 alloc 사이의 불일치. bytesPerRow * heightIOSurfaceGetAllocSize()를 초과하거나 overflow되면, height row만큼 순회할 때 mapping 끝을 넘어 실행될 수 있습니다. 이 때문에 CheckedSize로 곱한 값을 alloc size와 비교합니다.
  3. Format/layout confusion. Pixel format allowlist는 처리 가능한 4가지 압축되지 않은 단일 plane format 이외의 모든 것을 거부합니다. 여기에는 multi-planar format과 chroma-subsampled format이 포함되는데, 이런 layout은 linear 모델로는 backing memory를 전혀 설명할 수 없습니다. nullptr을 assert하는 YUV422 regression test는, 이런 surface가 fix 이전에는 그대로 createFromSurface()까지 흘러 들어갔음을 보여주는 직접적인 증거입니다.

maximumSize() clamp와 0 dimension 거부는 같은 metadata 공간의 극단적인 degenerate 값들을 함께 처리합니다.

이 취약점이 성립하려면 snapshot reply를 생성하는 process에서 사전에 code execution을 확보해야 합니다. 어떤 send right를 메시지에 담을지는 오직 reply를 보내는 쪽만 결정하므로, 일반적인 JavaScript에서는 도달할 수 없는 경로입니다. WebPageProxy.cpp 발췌 부분은 takeSnapshot 본문보다 훨씬 앞에서 잘려 있지만, 송신자가 WebContent라는 점은 process model과 새 factory 이름 자체 — 입력을 명시적으로 untrusted라고 표기한 이름 — 에서 유추할 수 있습니다. 이 전제가 성립한다는 가정 하에, 실제 trigger는 다음과 같습니다. embedder나 WebKit 내부가 snapshot을 요청하여 WebPageProxy::takeSnapshot()에 도달하면, 정상적인 상황이라면 IOSurface::create()로 만든 surface의 send right로 응답해야 하지만, 이미 손상된 송신 측은 대신 IOSurfaceCreate를 직접 호출해 악의적인 property dictionary를 만들고 그 port로 응답합니다.

Crash를 넘어서는 확장은 조건부이며, 다음 조건이 모두 충족되어야 합니다. 먼저 attacker가 UI process의 VM layout을 surface mapping 인접 영역까지 충분히 통제하고 있어서 over-read가 의미 있는 데이터에 도달해야 합니다. Surface는 별개의 커널 backed mapping이므로 그 뒤에 무엇이 위치하는지는 제공된 context만으로는 확인되지 않습니다. 다음으로 over-read된 byte가 unmapped page에서 fault를 일으키지 않고 생성된 CGImageRef까지 살아남아야 합니다. 마지막으로 결과 이미지가 attacker에게 관찰 가능한 경로가 있어야 합니다. 이 마지막 조건과 관련해, snapshot 이미지는 web content가 아니라 embedding application의 completion handler로 전달되므로, 손상된 renderer 입장에서는 이를 읽기 위한 별도의 channel이 필요합니다. 반대로 attacker가 악의적인 페이지를 구동하는 악성 application 쪽이라면 이미지를 직접 관찰할 수 있고, 이 경우 over-read는 UI process 메모리 노출로 이어질 수 있습니다. 이런 조건들이 충족되지 않는다면, 안정적으로 관찰되는 결과는 out-of-bounds read fault로 인한 권한을 가진 UI process의 crash입니다.

이 primitive는 attacker가 크기를 정한 mapping의 base를 기준으로 attacker가 영향을 미치는 out-of-bounds read이며, 읽는 범위 역시 송신자가 결정합니다. 읽기 전용이라는 점도 특징입니다. Validator나 takeSnapshot reply 경로의 UI process 쪽 어디에도 surface에 쓰기를 수행하는 코드는 없습니다. 취약한 소비자는 process 분리 구조에서 권한을 가진 쪽에 위치하므로, 성공적인 exploitation은 renderer sandbox의 반대편에서 동작하는 셈이며 추가적인 sandbox escape를 요구하지 않습니다. 다만 읽기 전용이라는 성격 때문에 이 자체로 완결된 escape라기보다는 info leak, ASLR defeat, host process에 대한 DoS 등 escape chain의 한 고리로 기능합니다.

이 버그의 발견 과정은 renderer와 UI process 사이 경계를 대상으로 한 패턴 점검에 가까워 보입니다. 권한을 가진 process가 sandbox 안의 renderer가 생성한 resource handle을 소비하는 지점들을 체계적으로 나열하고, 각 지점에서 handle이 스스로 선언한 property가 검증되는지를 확인했을 가능성이 높습니다. 이 fix가 해당 호출 지점 하나에만 inline check를 넣는 대신 범용 helper인 createFromUntrustedUncompressedWebKitSendRight로 구현된 점은, IOSurface import 경로 전반에 대한 내부 audit이 있었을 가능성을 시사합니다. GPU process의 IOSurface 소비 경로에 대한 이전 hardening 작업에 이은 variant analysis일 가능성도 있습니다. Regression test들이 crash를 재현하기보다는 새 계약을 고정하는 방식으로 작성된 점은 fuzzing보다는 audit 기반 발견을 뒷받침합니다.

이 취약점은 renderer와 UI process 사이의 신뢰 경계를 약화시킵니다. Security model은 renderer를 신뢰할 수 없는 쪽으로 간주하고, renderer가 UI process에 넘기는 모든 resource를 attacker가 통제하는 데이터로 취급해야 한다고 가정합니다. Fix 이전의 takeSnapshot()은 이 원칙 대신, send right가 가리키는 surface가 IOSurface::create()로 생성된 것 — 압축되지 않고 단일 plane이며 metadata가 자체적으로 일관된 것 — 이라고 암묵적으로 가정하고 있었습니다. 이미 renderer에서 code execution을 확보한 attacker는 악의적인 surface로 이를 치환함으로써, 더 높은 권한을 가진 UI process가 mapping된 surface allocation 범위를 넘어 읽도록 유도할 수 있습니다. 최소한으로는 단일 tab이 아니라 브라우저 세션 전체를 무너뜨리는 원격 트리거 가능한 UI process crash로 이어지고, 최선의 경우에는 결과 이미지에 인접 메모리의 byte가 반영되는 통제된 읽기로 이어질 수 있습니다. 이는 그 자체로 완결된 escape라기보다는 sandbox escape chain의 한 단계에 해당하는 권한 경계 침해입니다.

함수 이름 자체가 security model을 그대로 담고 있습니다. createFromUntrustedUncompressedWebKitSendRight라는 이름은 세 가지를 말합니다. 입력이 untrusted라는 것, 기대하는 형태가 압축되지 않은 것이라는 것, 그리고 기대하는 생성 주체가 WebKit 자신의 IOSurface::create()라는 것입니다. Trust level을 주석이 아니라 API 계약 자체에 담아낸다는 점에서 본받을 만한 패턴입니다. 구조적으로 주목할 점은, createFromSendRight()가 아무 표시 없이 코드베이스에 그대로 남아 있다는 사실입니다. 이 함수를 여전히 사용하는 모든 호출 지점은 이제 암묵적으로 "내 send right는 신뢰할 수 있는 생산자에게서 왔다"고 주장하는 셈이지만, 이 주장을 정당화하도록 강제하는 장치는 API 어디에도 없습니다. 또한 이 fix가 다루지 않는 부분도 눈여겨볼 만합니다. Validation 이후에도 pixel content 자체는 여전히 송신 측 process가 쓸 수 있는 상태로 남습니다. 즉 이번 fix는 content에 대한 신뢰 문제가 아니라 metadata 검증 문제를 다룬 것입니다. Geometry field들은 surface 생성 시점에 고정되어 이후 변경할 수 없다는 점 — 이는 제공된 context에서 직접 확인되는 사실이라기보다 IOSurface의 일반적인 동작 방식입니다 — 이 바로 이 지점에서 check-then-use 패턴이 안전하게 성립하는 이유이며, pixel data라면 같은 패턴이 성립하지 않았을 것입니다.