← All reports

[4] When captured as a video frame, canvas has to be tainted if cross-origin image are drawn into it

HighWebCore Modules/mediastreamCrossOrigin

Canvas tainting blocked getImageData — nobody asked the capture path.

9391ef1

High. Canvas taint flag은 렌더링된 pixel에 대한 same-origin policy를 전적으로 책임지는 장치인데, 두 개의 frame 생성 경로 중 하나가 이 flag를 전혀 확인하지 않았습니다. 메모리 손상도, race도, 페이지 로드 이상의 사용자 조작도 필요하지 않습니다. 이 read는 항상 동일하게 재현됩니다.

Canvas taint 메커니즘은 pixel에 대한 same-origin policy를 강제합니다. Cross-origin image가 canvas에 그려지면 origin-clean flag가 해제되고, 이후 read-back API인 getImageData(), toDataURL(), toBlob()SecurityError를 던지게 됩니다. 한편 captureStream()은 canvas를 live MediaStreamTrack으로 전환하는 기능으로, 그 출력을 <video> 요소에서 재생하거나 녹화하거나 ImageCapture로 스냅샷을 뜰 수 있습니다. 이 flag가 담고 있는 전제는, taint 여부가 pixel data 자체의 속성이어야 한다는 것입니다. 즉 canvas를 벗어나는 어떤 경로를 통해서도 script가 읽을 수 없어야 하며, 이는 가장 직관적인 경로 하나에만 국한되지 않습니다.

관전 포인트: attacker page는 canvas를 getImageData() 대신 capture pipeline으로 우회시켜서, victim의 브라우저가 쿠키를 포함해 fetch할 수 있는 모든 cross-origin image의 pixel을 읽어낼 수 있습니다.

HTMLCanvasElement::captureStream()은 canvas 출력을 <video> 요소로 스트리밍할 수 있게 해줍니다. 이 video의 track frame은 CanvasCaptureMediaStreamTrack::Source::grabFrame()에서 얻어집니다. 이 함수는 HTMLCanvasElement::toVideoFrame()을 호출해 조건 없이 VideoFrame을 가져옵니다. Cross-origin image가 canvas에 그려진 경우 이 canvas는 tainted 상태가 되어야 하며, 그래야 getImageData()로 cross-origin image의 pixel을 볼 수 없게 됩니다.

Source/WebCore/Modules/mediastream/CanvasCaptureMediaStreamTrack.cpp

RefPtr<VideoFrame> CanvasCaptureMediaStreamTrack::Source::grabFrame()
{
if (!canvas)
return nullptr;
 
+ if (!canvas->originClean())
+ return nullptr;
+
#if ENABLE(WEBGL)
if (RefPtr gl = dynamicDowncast<WebGLRenderingContextBase>(canvas->renderingContext()))
return gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer);
#endif
...
void CanvasCaptureMediaStreamTrack::Source::captureCanvas()
{
 
- if (!canvas->originClean())
 
- return;
-
 
- RefPtr videoFrame = [&]() -> RefPtr<VideoFrame> {
-#if ENABLE(WEBGL)
 
- if (RefPtr gl = dynamicDowncast<WebGLRenderingContextBase>(canvas->renderingContext()))
 
- return gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer);
-#endif
 
- return canvas->toVideoFrame();
 
- }();
+ RefPtr videoFrame = grabFrame();
if (!videoFrame)
return;

LayoutTests/http/tests/canvas/resources/cross-origin-image-capture-video-frame.html

+ // 1. Capture the stream of the source canvas while it is still origin-clean.
+ var stream = srcCanvas.captureStream(0);
+ var track = stream.getVideoTracks()[0];
+ track.requestFrame();
+
+ // 2. Load the cross-origin image.
+ var img = new Image();
+ img.src = 'http://localhost:8000/canvas/resources/100x100-lime-rect.svg';
+ await new Promise(function(ok, fail) { img.onload = ok; });
+
+ // 3. Draw the the cross-origin image onto the canvas - this taints it; direct reads are now blocked.
+ var srcCtx = srcCanvas.getContext('2d');
+ srcCtx.drawImage(img, 0, 0);
+
+ // 4. grabFrame() snapshots the tainted canvas.
+ var bmp = await new ImageCapture(track).grabFrame();
+
+ // 5. Draw the frame to a fresh canvas - nothing should be drawn from the tainted canvas.
+ var destCtx = destCanvas.getContext('2d');
+ destCtx.drawImage(bmp, 0, 0);

CanvasCaptureMediaStreamTrack.cpp에는 두 가지 변경이 이루어졌습니다. 먼저 Source::grabFrame()에 early-out이 추가되었습니다. m_canvasRefPtr canvas로 resolve한 뒤, !canvas->originClean()인 경우 WebGL 경로(gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer))와 2D 경로(canvas->toVideoFrame()) 어느 쪽도 VideoFrame을 생성하기 전에 nullptr을 반환하게 됩니다. 다음으로 Source::captureCanvas()가 리팩터링되었습니다. 자체적으로 갖고 있던 if (!canvas->originClean()) return; 가드와 WebGL/2D frame 생성 로직을 중복 구현하던 inline lambda가 제거되었고, 이제는 단순히 RefPtr videoFrame = grabFrame();을 호출합니다.

결과적으로 origin-clean check가 개별 호출자 하나에서 공유 frame 생성 helper 쪽으로 이동했습니다. 따라서 Source::grabFrame()을 사용하는 모든 소비자가 이 check를 자동으로 물려받게 됩니다. Layout test 세 개가 추가되었습니다. Reftest 쌍 하나와, cross-origin sub-resource를 이용해 captureStream(0)track.requestFrame()drawImage(crossOriginImg)new ImageCapture(track).grabFrame() → 두 번째 canvas에 drawImage(bmp) 순서로 동작을 유도하는 테스트가 그것입니다. 기대 결과는 세 번째 박스가 red로 유지되는 것, 즉 tainted canvas로부터 어떤 pixel도 넘어오지 않는 것입니다.

동일한 보호 자원에 도달하는 sibling accessor 중 한쪽에만 security predicate가 적용되고 다른 한쪽에는 누락된 패턴.

Canvas origin-clean flag (tainting). 모든 canvas는 same-origin(또는 CORS로 승인된) content만 그려졌는지를 기록하는 boolean 값을 갖고 있습니다. HTMLCanvasElement::originClean()이 이 값을 보고하며, pixel read-back API들은 이 값을 확인해 false일 경우 SecurityError를 던집니다. CORS 승인 없이 cross-origin image를 그리면 이 flag가 해제됩니다.

HTMLCanvasElement::captureStream(frameRate). MediaStream을 반환하며, 이 MediaStream의 단일 video track은 CanvasCaptureMediaStreamTrack에 의해 뒷받침됩니다. 덕분에 canvas 출력을 <video> 요소에서 재생하거나 MediaRecorder/WebRTC로 전달할 수 있습니다. frameRate0이면 자동 frame 생성은 없으며, script가 track.requestFrame()을 호출할 때만 frame이 생성됩니다.

CanvasCaptureMediaStreamTrack::Source. RealtimeMediaSource의 subclass입니다. Canvas를 관찰하며(canvasChanged, canvasDisplayBufferPrepared), zero-delay timer에서 captureCanvas()를 스케줄링해 새로운 frame을 track에 push합니다. 이와 별도로 Source::grabFrame()은 요청 시점에 VideoFrame을 생성하는데, WebGL display buffer에서는 surfaceBufferToVideoFrame()을 통해, 2D backing store에서는 HTMLCanvasElement::toVideoFrame()을 통해 생성합니다.

ImageCaptureImageBitmap. ImageCapture는 video MediaStreamTrack 위에 구성되는 binding입니다. grabFrame()은 track의 현재 frame을 스냅샷한 ImageBitmap으로 resolve되는 promise를 반환합니다. ImageBitmapCanvasRenderingContext2D::drawImage()가 받아들이는 immutable bitmap이며, 이를 그렸을 때 destination canvas가 taint되는지 여부는 그 bitmap의 source가 갖고 있는 origin metadata에 따라 달라집니다.

Reftest structure. -expected.html 파일은 순수 CSS box로 의도된 시각적 결과를 렌더링합니다. 실제 test가 이와 pixel 단위로 동일하게 렌더링될 때만 통과합니다. 여기서는 expected 파일이 세 번째 box를 red로 칠하고 있습니다. 즉 frame에서 파생된 canvas는 반드시 비어 있어야 합니다.

Taint invariant는 canvas frame 생성으로 들어가는 두 개의 진입점 중 정확히 하나에만 적용되어 있었습니다. Source::captureCanvas()는 timer/observer 기반으로 <video> 재생을 위해 MediaStreamTrackPrivate에 frame을 push하는 경로로, canvas->originClean()을 확인해 bail out했습니다. 반면 Source::grabFrame()은 public CanvasCaptureMediaStreamTrack::grabFrame() forwarder(Ref source = static_cast<Source&>(this->source()); return source->grabFrame();)를 통해 도달 가능한 on-demand 경로로, canvas가 null인지만 확인한 뒤 조건 없이 VideoFrame을 생성했습니다.

  Before:                                    After:
  captureCanvas()      grabFrame()           captureCanvas()      grabFrame()
    ├─ originClean? ✓    ├─ canvas? ✓          └─► grabFrame() ──►  ├─ canvas? ✓
    └─► toVideoFrame()   └─► toVideoFrame()                          ├─ originClean? ✓
                              ▲                                      └─► toVideoFrame()
                              │
                     ImageCapture.grabFrame()
                     (tainted pixels escape here)

이는 memory-safety 문제가 아니라 logic error에 해당합니다. Capture 경로는 pixel을 다른 object graph를 거쳐 세탁합니다. canvas → VideoFrame → (ImageCapture를 경유해) ImageBitmap 순서입니다. Script에 반환되는 ImageBitmap은 같은 document 내 media track에서 유래하기 때문에, 이를 여전히 origin-clean 상태인 두 번째 canvas에 그려도 그 canvas는 다시 taint되지 않습니다. 즉 이 disclosure는 VideoFrame/ImageBitmap 쌍이 origin metadata를 전혀 전달하지 않는다는 지점에서 성립하며, reftest는 이를 read-back이 허용되었는지를 직접 assert하는 대신 시각적 결과(세 번째 box가 red)로만 검증하고 있습니다. grabFrame()이 애초에 source canvas가 clean한지 물어본 적이 없었기 때문에, taint 상태는 이 type boundary를 넘어가는 pixel과 함께 이동하지 않았던 것입니다. Regression test에서 taint가 발생하기 전에 requestFrame()을 먼저 호출하는 순서는 이 leak과는 부수적인 관계입니다. grabFrame()은 grab 시점에 canvas를 live하게 읽기 때문에, 그 스냅샷에는 한 단계 앞서 그려진 cross-origin image가 그대로 담기게 됩니다. Fix는 taint를 VideoFrame으로 전파시키는 대신, frame 자체를 만들지 않고 nullptr을 반환하는 강한 차단 방식을 선택했습니다.

도달 경로는 일반적인 web content 수준입니다. 특권 API도, 페이지 로드 이상의 user gesture도 필요하지 않습니다. Regression test 자체가 곧 동작하는 trigger입니다.

  1. srcCanvas.captureStream(0)CanvasCaptureMediaStreamTrack을 생성하며, 이 시점의 Source는 아직 origin-clean 상태인 canvas를 관찰하기 시작합니다. track.requestFrame()은 track을 미리 준비시킵니다.
  2. Cross-origin image가 다른 port/host(127.0.0.1:8000에서 서빙되는 frame 안에서 http://localhost:8000/...)로부터 crossorigin attribute 없이 로드됩니다. 따라서 CORS 승인은 얻어지지 않습니다.
  3. srcCtx.drawImage(img, 0, 0)이 canvas의 origin-clean flag를 해제합니다. 이 시점부터 source에 대한 getImageData()SecurityError를 던지게 됩니다.
  4. new ImageCapture(track).grabFrame()은 on-demand 경로인 Source::grabFrame()으로 진입하는데, fix 이전에는 if (!canvas)만 확인한 뒤 tainted 2D canvas에 대해 canvas->toVideoFrame()을 호출했습니다(WebGL context라면 gl->surfaceBufferToVideoFrame()). 그 결과 cross-origin pixel을 담은 VideoFrame이 반환됩니다.
  5. 결과 ImageBitmapdestCanvas에 그려지는데, 이 canvas는 cross-origin content에 한 번도 노출된 적이 없으므로 origin-clean 상태를 유지합니다. VideoFrame/ImageBitmap 쌍이 origin metadata를 전달하지 않는다면, 이후 destCtx.getImageData(0, 0, 100, 100)은 cross-origin pixel 값을 그대로 반환하게 됩니다. Reftest는 read-back이 허용되었는지를 직접 검증하지 않고 시각적 결과만 assert합니다.

Reftest는 5단계의 fix 이후 의도된 동작을 시각적으로 인코딩하고 있습니다(세 번째 box가 red면 아무것도 그려지지 않은 것). 실제 attacker라면 lime SVG 대신 credential이 필요한 cross-origin resource를 넣고, decode된 pixel을 평범한 fetch()로 빼돌릴 수 있습니다. WebGL branch도 cross-origin texture upload로 taint된 WebGL canvas에 대해 surfaceBufferToVideoFrame()을 통한 동일한 경로를 제공합니다. Attacker는 source(victim의 브라우저가 쿠키를 포함해 fetch할 수 있는 모든 image URL), geometry(canvas 크기와 draw transform), timing(grabFrame()은 script로 구동되며 반복 가능) 모두를 제어할 수 있습니다. 메모리 손상, control-flow에 대한 영향, write primitive는 존재하지 않습니다.

모든 과정은 WebContent process 내부에서 일어납니다. 이는 policy/isolation 실패이지 sandbox boundary 실패가 아니며, bug의 가치가 data 자체에 있으므로 escape가 필요하지 않습니다.

이 vulnerability는 canvas taint 메커니즘이 강제하는 same-origin boundary를 약화시킵니다. 이 보안 모델은 cross-origin image가 canvas에 한 번 그려지면 그 pixel이 canvas를 벗어나는 어떤 경로를 통해서도 script에서 읽을 수 없어야 한다고 전제합니다. Origin-clean flag는 특정 accessor 하나의 속성이 아니라 pixel data 자체의 속성이어야 한다는 것입니다. Fix 이전에는 attacker page가 로드할 수 있는 모든 cross-origin image의 pixel을 읽어낼 수 있었고, 여기에는 victim session에 credential과 함께 서빙되는 profile photo, QR/barcode content, chart나 document 렌더링, SVG로 렌더링된 account data 등이 포함됩니다. 이로써 얻어지는 것은 image로 렌더링 가능한 모든 데이터의 cross-origin disclosure입니다. Code execution이 아니라, 표적화된 read-only same-origin-policy bypass에 해당합니다.

Canvas taint는 본래 pixel data의 속성이어야 하지만, WebKit은 이를 accessor별로 강제하고 있었습니다. Source에는 둘 다 HTMLCanvasElement::toVideoFrame()에 도달하는 accessor가 두 개 있었는데, 하나는 guard가 있었고 다른 하나는 없었습니다. 이 fix의 실질적인 내용은 추가된 세 줄이 아니라, captureCanvas()grabFrame()을 경유하도록 만든 리팩터링에 있습니다. 이를 통해 두 개의 frame 생성 경로가 하나로 합쳐졌고, 향후 세 번째 consumer가 같은 gap을 다시 만들어낼 여지가 사라졌습니다. 설계상의 선택도 눈여겨볼 만합니다. Fix는 taint를 VideoFrame으로 전파하는 대신 frame 자체를 아예 차단하는 쪽을 택했습니다. 이는 보수적인 선택이지만, 그만큼 pixel이 canvas type을 벗어나는 순간 taint flag가 여전히 함께 이동하지 않는다는 것을 의미합니다. 따라서 canvas pixel을 non-canvas type으로 구체화하는 다른 pipeline이 있다면 동일하게 잠재적인 문제를 안고 있으며, 각각 자체적인 guard가 필요합니다.

Taint 또는 provenance 메타데이터가 type boundary를 넘지 못하는 패턴. HTMLCanvasElement의 픽셀이 VideoFrame을 거쳐 ImageBitmap이 될 때, origin-clean bit는 원래의 객체 쪽에만 남아 있습니다. 그 결과 파생된 객체는 origin 검증 없이도 clean한 것으로 암묵적으로 취급됩니다. 좁게 보면, canvas나 <video>로부터 VideoFrame, ImageBitmap, NativeImage, ImageBuffer를 생성하는 모든 WebCore 변환 지점을 추적해서, destination 쪽이 origin metadata를 함께 갖는지 혹은 변환 자체가 거부되는지 확인해야 합니다. Source/WebCore/Modules/mediastream/Source/WebCore/html/ImageBitmap.cpp부터 살펴보는 것이 출발점입니다. 판별 기준은 다음과 같습니다. 입력 타입에는 origin/taint field가 있는데 출력 타입에는 대응하는 field가 없는 constructor나 factory 함수입니다. 조금 더 넓게 보면, 같은 클래스의 문제는 payload가 아니라 wrapper에 붙어 있는 provenance flag 전반에 적용됩니다. Response의 CORS mode 대비 추출된 ArrayBuffer, crossOriginIsolated 상태 대비 transfer된 SharedArrayBuffer, secure-context flag 대비 캐시된 객체가 그 예입니다. 가장 넓게 보면, 파생 데이터는 원본의 confidentiality label을 그대로 물려받거나, 그렇지 않다면 파생 자체가 거부되어야 한다는 원칙으로 이어집니다. 이는 전형적인 information-flow labelling 문제이며, 점검할 때 가져가야 할 질문은 "이 값이 type을 바꿀 때, 그 label은 지금 누가 들고 있는가?"입니다.