← All reports

[1] WebGL paint readback returns uninitialized PixelBuffer on rejected read

HighWebCore ANGLE WebGL backendUninitializedMemory

A WebGL2 page could get the GPU process to paint stale heap where its frame belonged.

6b90afd

High. 임의의 페이지가 WebGL2 호출 두 번만으로 GPU process가 초기화되지 않은 heap allocation을 canvas paint 경로에 넘기도록 만들 수 있었습니다. GPU process는 이 allocation을 렌더링된 frame처럼 취급했습니다. 영향은 read-only leak에 머뭅니다. ASLR 우회나 cross-context leak으로 확장하려면 buffer가 어떤 해제된 메모리 위에 할당되는지를 공격자가 유도할 수 있어야 합니다.

WebKit의 WebGL 구현은 GPU process에서 ANGLE 위에 올라가 동작합니다. canvas를 paint할 때마다 렌더링된 frame을 CPU 쪽 pixel buffer로 다시 읽어 옵니다. 이 readback은 내부 helper인 readPixelsForPaintResults()가 담당합니다. 이 함수는 WebGL drawing buffer를 PixelBuffer로 복사합니다. PixelBuffer는 zero-fill 없이도 할당할 수 있는 raw byte container입니다. 문제는 이 readback이 페이지의 WebGL 호출과 동일한 GL context를 사용한다는 점입니다. 원래 지켜져야 할 규칙은 단순합니다. 초기화 없이 할당된 buffer는 모든 byte를 덮어써야 할 GL read가 실제로 성공한 뒤에만 다음 단계로 전달되어야 합니다.

관전 포인트: WebGL2 페이지가 GPU process로 하여금 렌더링된 frame 대신 초기화되지 않은 heap allocation을 반환하게 만들 수 있었습니다. 이 이미지가 script로 읽을 수 있는 sink에 도달하면, 페이지는 GPU process의 stale 메모리를 read-only로 leak할 수 있게 됩니다.

WebGL: readPixelsForPaintResults에서 GL_READ_BUFFER desync로 인해 초기화되지 않은 heap 메모리가 leak되는 문제입니다. client가 default framebuffer의 read buffer를 변경한 경우 이를 다시 설정하도록 수정되었습니다. 원래 316606.99@safari-7625-branch로 반영된 수정입니다.

Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp

RefPtr<PixelBuffer> GraphicsContextGLANGLE::readPixelsForPaintResults()
...
ScopedBufferBinding scopedPixelPackBufferReset(GL_PIXEL_PACK_BUFFER, 0, m_isForWebGL2);
setPackParameters(1, 0, false);
+ updateErrors();
GL_ReadPixelsRobustANGLE(0, 0, pixelBuffer->size().width(), pixelBuffer->size().height(), GL_RGBA, GL_UNSIGNED_BYTE, pixelBuffer->bytes().size(), nullptr, nullptr, nullptr, pixelBuffer->bytes().data());
+ // pixel buffer는 초기화되지 않은 상태이므로, 어떤 이유로든 read가 거부되었다면 절대 반환하지 않는다.
+ if (updateErrors())
+ return nullptr;
return pixelBuffer;
}

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

+TEST_F(GraphicsContextGLCocoaTest, CopyNativeImageWithReadBufferNoneWebGL2)
+{
+ attributes.isWebGL2 = true;
+ ...
+ gl->bindFramebuffer(GL::FRAMEBUFFER, 0);
+ gl->readBuffer(GL::NONE);
+ ...
+ RefPtr readbackImage = gl->GraphicsContextGLANGLE::copyNativeImage(buffer);
+ ASSERT_NE(readbackImage, nullptr);
+ EXPECT_TRUE(nativeImagePixelsIs(Color::green, *readbackImage, FloatPoint(5, 5)));
+ EXPECT_TRUE(gl->getErrors().isEmpty());

이번 patch로 GraphicsContextGLANGLE::readPixelsForPaintResults()가 fail closed 방식으로 동작하게 되었습니다. 먼저 GL_ReadPixelsRobustANGLE(...) 호출 직전에 updateErrors()가 추가되었습니다. 이 호출이 이미 쌓여 있던 GL error를 비워 두기 때문에, 이후 검사에서는 이번 read의 결과만 확인하게 됩니다. read 직후에도 updateErrors()가 한 번 더 호출되며, error가 하나라도 발생했다면 nullptr를 반환합니다. 패치 이전에는 함수가 조건 없이 pixelBuffer를 반환했습니다. 새로 추가된 주석은 이 invariant를 그대로 명시합니다.

한편 commit message에는 ANGLEUtilities.cpp/.h의 ScopedReadBuffer 변경도 언급되어 있습니다. 다만 해당 hunk는 이번 main branch commit에 포함되지 않았습니다. 대신 main의 ANGLEUtilities.h에는 ScopedScratchReadFramebufferBinding이 존재합니다. 이 클래스의 주석에는 scratch framebuffer를 bind하여 "read가 READ_BUFFER 같은 애플리케이션이 볼 수 있는 default framebuffer 상태의 영향을 받지 않도록" 한다고 적혀 있습니다. 즉 main에서는 Safari branch 수정 중 fail closed error 검사만 가져온 셈입니다. 새로 추가된 API test CopyNativeImageWithReadBufferNoneWebGL2는 두 경로를 모두 다룹니다. 하나는 platform copyNativeImage()의 surface copy이고, 다른 하나는 base GraphicsContextGLANGLE::copyNativeImage()의 GL readback입니다. 각각 DrawingBuffer와 DisplayBuffer 양쪽에 대해 검증합니다.

실패할 수 있는 fill 작업 이후 실제 성공 여부를 확인하지 않고 초기화되지 않은 output buffer를 내보내는 패턴. 특히 client가 제어하는 공유 상태로 fill 실패를 강제할 수 있는 경우.

이 코드가 있는 위치. GPU process가 활성화되어 있으면, WebContent process에서 호출한 WebGL API는 IPC로 직렬화되어 GPU process의 RemoteGraphicsContextGL에 전달됩니다. 이 객체는 ANGLE 위에서 GraphicsContextGLANGLE을 구동합니다. ANGLE은 GL 호출을 각 플랫폼의 native graphics API로 변환하는 Google의 translation layer입니다. WebGL canvas를 paint하거나 export하려면 drawing buffer 또는 display buffer를 PixelBuffer나 이미지로 다시 읽어 와야 합니다. base readback 경로에서는 readPixelsForPaintResults()가 이 작업을 수행합니다.

Emulate된 default framebuffer. 페이지는 실제 window system framebuffer를 받지 않습니다. 콘텐츠가 보는 "framebuffer 0"은 WebKit이 내부 texture와 surface(drawing buffer와 display buffer) 위에 emulate한 것입니다. 이 때문에 페이지가 framebuffer 0에 설정한 상태는 WebKit 내부 작업이 사용하는 GL context에 그대로 남게 됩니다.

WebGL2 readBuffer. WebGL2 / OpenGL ES 3.0에서 readBuffer(mode)는 현재 bind된 read framebuffer의 어느 color buffer를 readPixels와 copyTexImage가 읽을지 선택합니다. default framebuffer에서 유효한 값은 BACK과 NONE입니다.

GL_ReadPixelsRobustANGLE과 GL error queue. GL_ReadPixelsRobustANGLE은 ANGLE이 제공하는 bounds check 버전의 glReadPixels로, destination 크기를 명시적으로 전달받습니다. 호출이 거부되면 GL은 error code를 기록하고 destination은 건드리지 않습니다. updateErrors()는 GraphicsContextGLANGLE의 helper입니다. GL error queue를 비워 context가 추적하는 error 집합에 옮기고, 새 error가 발생했는지 여부를 반환합니다.

PixelBuffer. format과 크기 정보를 가진 WebCore의 raw pixel byte container입니다. 호출자가 모든 byte를 덮어쓸 예정이라면 zero-fill 없이 할당할 수 있습니다.

근본 원인은 실패할 수 있는 producer의 결과를 확인하지 않고 신뢰한 데 있습니다. 그 결과 초기화되지 않은 메모리가 노출됩니다.

  Before:                                   After:
  readPixelsForPaintResults()               readPixelsForPaintResults()
    ├─► PixelBuffer (uninitialized)           ├─► PixelBuffer (uninitialized)
    ├─► GL_ReadPixelsRobustANGLE(...)         ├─► updateErrors()        (drain)
    │     content READ_BUFFER == NONE         ├─► GL_ReadPixelsRobustANGLE(...)
    │     → GL_INVALID_OPERATION              │     rejected → error recorded
    │       0 bytes written                   ├─► updateErrors() == true
    └─► return pixelBuffer ──► paint sink     └─► return nullptr     (fail closed)
          (stale heap bytes)

다이어그램 왼쪽의 상황부터 보면, 페이지는 이미 emulate된 default framebuffer에 대해 bindFramebuffer(FRAMEBUFFER, 0)과 readBuffer(NONE)을 차례로 호출한 상태입니다. OpenGL ES 3.0은 read buffer가 NONE일 때 ReadPixels를 GL_INVALID_OPERATION으로 거부하고 아무것도 기록하지 않습니다. 내부 paint readback도 페이지가 설정한 READ_BUFFER 값을 그대로 사용했기 때문에 read가 거부되었습니다. 그런데도 함수는 손대지 않은 buffer를 렌더링된 frame인 것처럼 반환했습니다. 이 buffer에는 해당 allocation에 마지막으로 남아 있던 내용이 그대로 들어 있습니다. 이후 paint/composite 경로로 흘러가 canvas 이미지가 됩니다.

이 버그가 성립하려면 두 가지가 동시에 잘못되어야 했습니다. 먼저 페이지가 제어하는 상태가 내부 작업에 새어 들어갔습니다. 다음으로 그 내부 작업은 producer가 buffer를 채웠다고 가정했습니다. 두 문제는 각각 두 층의 방어에 대응합니다. 하나는 격리(Safari branch의 ScopedReadBuffer, main의 ScopedScratchReadFramebufferBinding)이고, 다른 하나는 fail closed입니다. 이번 commit은 이 중 fail closed 층을 추가했습니다. 그 덕분에 incomplete framebuffer나 향후 등장할 다른 state desync 변종처럼, read가 거부되는 다른 원인도 함께 막힙니다.

regression test는 trigger 과정을 처음부터 끝까지 재현합니다.

  1. WebGL2 context를 생성하고 초록색으로 clear합니다.
  2. framebuffer 0을 bind하고 readBuffer(GL::NONE)을 설정합니다. 이로써 페이지에서 보이는 read 상태가 read를 거부하는 구성이 됩니다.
  3. DrawingBuffer와 DisplayBuffer 각각에 대해, platform copyNativeImage()와 base GraphicsContextGLANGLE::copyNativeImage() GL readback 두 경로로 이미지를 얻습니다.
  4. 이미지가 null이 아니고, (5, 5) 위치의 pixel이 초록색이며, getErrors()가 비어 있는지 assert합니다.

fail closed 검사가 들어간 이후에는 read가 거부되면 nullptr가 반환되고, 4단계의 ASSERT_NE가 실패하게 됩니다. 따라서 초록색 pixel이 나왔다면 NONE 설정에도 불구하고 read가 성공했다는 뜻입니다. 이는 base 경로에서 scratch framebuffer 격리가 동작하고 있다는 해석과 맞아떨어집니다. 다만 모든 platform 호출자에서 격리가 이루어진다는 것까지 증명하지는 않습니다. 비어 있는 error 집합은 또 하나의 기대를 담고 있습니다. 내부 read가 getError()를 통해 콘텐츠에 error를 노출해서는 안 된다는 점입니다. base class readback을 별도로 테스트하는 데에도 이유가 있습니다. platform copyNativeImage() override가 취약한 base 경로를 가려 버릴 수 있기 때문입니다. 버그 제목의 "GL_READ_BUFFER desync"라는 표현으로 볼 때, framebuffer 0에 적용되는 WebGL2 상태를 체계적으로 나열하고 어떤 내부 readback이 그 상태를 따르는지 점검하는 과정에서 발견되었을 가능성이 있습니다.

trigger 자체는 평범한 WebGL2 API 사용이며 특별한 권한이 필요하지 않습니다. 노출 범위는 PixelBuffer allocation 하나로 제한되며, 크기는 drawing buffer의 width × height × 4 byte입니다. paint readback마다 buffer를 새로 할당한다면 노출도 반복됩니다. write primitive나 control-flow primitive는 제공하지 않습니다.

이 문제가 실질적인 leak이 되려면 두 가지 조건이 필요합니다. 먼저 이미지가 script로 읽을 수 있는 sink에 도달해야 합니다. canvas export나 읽기 가능한 2D canvas로의 compositing이 그 예입니다. 플랫폼별로 어떤 web-facing 경로가 GPU process의 readPixelsForPaintResults() 출력을 그런 sink까지 전달하는지, 혹은 그런 경로가 있기는 한지는 아직 열려 있는 점검 과제입니다. 다음으로 stale 내용을 공격자가 유도할 수 있어야 합니다. 콘텐츠는 canvas 크기와 reshape로 drawing buffer의 크기를 제어합니다. 따라서 공격자가 allocator size class를 고르고, 의미 있는 데이터를 담고 있던 GPU process의 해제된 메모리 쪽으로 초기화되지 않은 allocation을 겨냥하는 것도 이론적으로 가능합니다.

두 조건이 모두 성립하면, 이 leak은 더 큰 exploit chain 안에서 ASLR 우회나 cross-context data leak의 구성 요소로 이어질 가능성이 있습니다. leak된 이미지가 WebContent로 전달되어 script로 읽을 수 있는 sink에 도달한다고 가정할 수 있습니다. 이 경우에도 WebContent/GPU process 경계를 넘는 것은 기밀성 측면뿐이며, code execution이나 sandbox escape로 이어지지는 않습니다.

이 vulnerability는 process 간 메모리 기밀성을 약화시킵니다. 보안 모델은 GPU process가 WebGL canvas에 대해 반환하는 pixel data가 해당 context 자신의 렌더링 결과만 담고 있다고 가정합니다. 패치 이전에는 WebGL2 페이지가 frame 대신 GPU process의 stale heap을 반환받을 수 있었습니다. 여기에는 다른 context나 다른 web process를 위해 처리된 데이터가 포함되어 있을 가능성이 있습니다. GPU process의 ASLR을 우회하는 데 쓸 수 있는 pointer 값이 섞여 있을 가능성도 존재합니다.

Insight: main의 ScopedScratchReadFramebufferBinding은 내부 read를 READ_BUFFER로부터 격리하도록 설계되어 있습니다. readPixelsForPaintResults()의 모든 platform 호출자가 이 binding을 거친다면, 이번 commit은 defense in depth 역할을 하게 됩니다. 어느 쪽이든 이번 변경이 수정의 두 축 중 더 오래 유효한 쪽입니다. 앞으로 콘텐츠가 볼 수 있는 다른 상태 때문에 ReadPixels가 거부되는 desync가 생기더라도, 이제는 출력 단계에서 차단됩니다. 상태 입력을 하나하나 찾아 격리하지 않아도 되는 셈입니다.