[1] WebGL paint readback returns uninitialized PixelBuffer on rejected read
A WebGL2 page could get the GPU process to paint stale heap where its frame belonged.
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
Tools/TestWebKitAPI/Tests/WebCore/cocoa/TestGraphicsContextGLCocoa.mm
Patch Details
이번 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 실패를 강제할 수 있는 경우.
Background
이 코드가 있는 위치.
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 없이 할당할 수 있습니다.
Analysis
근본 원인은 실패할 수 있는 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 과정을 처음부터 끝까지 재현합니다.
- WebGL2 context를 생성하고 초록색으로 clear합니다.
- framebuffer 0을 bind하고
readBuffer(GL::NONE)을 설정합니다. 이로써 페이지에서 보이는 read 상태가 read를 거부하는 구성이 됩니다. DrawingBuffer와DisplayBuffer각각에 대해, platformcopyNativeImage()와 baseGraphicsContextGLANGLE::copyNativeImage()GL readback 두 경로로 이미지를 얻습니다.- 이미지가 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가 생기더라도, 이제는 출력 단계에서 차단됩니다. 상태 입력을 하나하나 찾아 격리하지 않아도 되는 셈입니다.
Audit directions
-
실패할 수 있는 API로 채우는 초기화되지 않은 할당. 지켜져야 할 invariant는 producer가 성공을 보고하지 않는 한 초기화되지 않은 buffer가 밖으로 나가서는 안 된다는 것입니다. 좁게는
GraphicsContextGLANGLE/GraphicsContextGLCocoa/GraphicsContextGLGBM/GraphicsContextGLEGL의 helper를 모두 살펴볼 필요가 있습니다. 대상은PixelBuffer나 다른 초기화되지 않은 span을 생성한 뒤GL_ReadPixels*,GL_GetBufferSubData,GL_MapBufferRange,EGL_BindTexImage를 호출하는 helper입니다. 이때 error나 null 반환이 반환값을 막고 있는지 확인해야 합니다. 출발점으로는readCompositedResults()구현체들과RemoteGraphicsContextGL의readPixelsIPC handler가 적합합니다. 더 넓게 보면, WebKit이 초기화되지 않은 allocation(tryCreateUninitialized, zero-fill 없는MallocSpan, POD 타입에 대한Vector::grow)을 아무것도 기록하지 않을 수 있는 producer와 짝지은 곳이면 같은 형태가 나타납니다. IOSurface lock/copy 실패, CoreGraphics의 bitmap draw, image decoder의 partial frame, WebGPU buffer readback 등이 그 예입니다. 가장 넓게는 error 발생 시 초기화되지 않은 출력을 내보내는 일반적인 버그 class에 해당합니다. Chromium GPU process의 readback, false를 반환하는 SkiareadPixels, 일부만 채워진 struct를 넘기는 kernelcopy_to_user도 여기에 포함됩니다. 판별 단서는 다음과 같습니다. 함수가 producer의 성공 flag나 GL error 상태를 한 번도 확인하지 않는 경로에서 buffer를 반환하거나 전달하고, 그 buffer가 할당 시점에 0으로 초기화되지 않았다면 의심해 볼 만합니다. -
client가 볼 수 있는 상태가 내부 작업을 암묵적으로 좌우하는 경우. 지켜져야 할 invariant는 모든 내부 작업이 자신이 의존하는 상태를 직접 설정하거나 명시적으로 격리해야 한다는 것입니다. 좁게는 ANGLE WebGL backend에서 emulate된 default framebuffer를 읽거나, 그리거나, blit하는 내부 작업을 나열하는 것부터 시작할 수 있습니다. paint readback,
prepareTexture, multisample resolve, display buffer copy 등이 대상입니다. 각 작업이READ_BUFFER,DRAW_BUFFERS,PACK_*/UNPACK_*parameter, scissor, color mask,RASTERIZER_DISCARD를 scope로 감싸고 있는지 확인해야 합니다. 이미 처리된 범위는 기존ScopedPixelStorageMode,ScopedGLCapability,ScopedScratchReadFramebufferBinding사용처를 지도로 삼아 파악할 수 있습니다. 더 넓게 보면, 같은 desync는 buffer binding(PIXEL_PACK_BUFFERreset이 기존 guard 중 하나), 내부 blit 중의 sampler·texture binding, 내부 draw 중의 transform feedback 상태에서도 나타납니다. 가장 넓게는 구현과 client가 하나의 context를 공유하는 모든 가상화·다중화된 state machine이 대상입니다. Chromium의 GLES2 command decoder 상태 복원, ANGLE 내부 blit·clear helper, dynamic state를 그대로 물려받는 Vulkan·Metal command buffer wrapper가 그 예입니다. 판별 단서는 다음과 같습니다. 내부 함수가 스스로 설정하지 않은 상태에 의존하고, 그 상태를 public WebGL entry point로 설정할 수 있다면 점검 대상입니다. -
구현과 client가 공유하는 error 채널.
GraphicsContextGLANGLE에서 내부 동작이 client에게 보이는 GL error queue와 어떻게 상호작용하는지 살펴볼 필요가 있습니다. 내부 동작이 client의 pending error를 삼켜버릴 수 있습니다. 반대로 내부에서 발생한 error가 client 쪽으로 새어 나갈 수도 있습니다. 어느 쪽이든 content가 관찰하는 상태와 실제로 발생한 일이 어긋나게 되며, 실패한 internal read를 가리는 결과로 이어질 수 있습니다. 좁게 보면, 내부 동작을 감싸는updateErrors()호출 지점을 하나씩 추적하는 작업이 필요합니다. 이때 internal read에서 발생한 error가 올바르게 귀속되는지, 그리고getError()를 통해 content에 보이는 가짜 error로 드러나지 않는지 확인해야 합니다. 새로 추가된 테스트의getErrors().isEmpty()assertion이 바로 이 기대 동작을 담고 있습니다. 범위를 넓히면, IPC를 거치며 error를 일괄 처리하거나 합성하는RemoteGraphicsContextGL경로에도 같은 기준으로 점검할 수 있습니다. 가장 넓게 보면, 구현 측 작업과 client 작업을 하나의 sticky error flag 위에서 함께 다루는 API 전반이 대상입니다. OpenGLglGetError, POSIXerrno, Win32GetLastError가 대표적입니다. 이런 API에서는 내부 호출 전후로 save/drain/restore를 명시적으로 수행해야 합니다. 판별 단서는 다음과 같습니다. client 호출 사이에 내부 호출이 끼어 있는데, 그 앞에서 drain이 없고 뒤에서도 귀속이 명확한 별도 검사가 없는 경우입니다.