← All reports

[1] Canvas 2D `beginLayer()` use-after-free on save-stack overflow

HighWebCore HTML canvasUAF

Two iframes, two Set prototypes, and the compiled code watching the wrong one.

e055837

Severity는 High이며, 가설 수준의 문제가 아닙니다. 매번 재현 가능한 crash가 이미 확인된 영향입니다. Save cap에 도달한 상태에서 beginLayer()를 한 번 더 호출하면 GraphicsContext가 해제됩니다. 문제는 방금 생성된 layer switcher가 이 객체를 이미 raw pointer로 캐싱해 두었다는 점입니다. 더 강한 primitive로 이어질지는 해제된 allocation을 attacker가 무엇으로 재점유할 수 있는지에만 달려 있습니다.

HTML canvas 2D context는 그리기 상태를 담은 State frame을 stack 형태로 관리합니다. 이때 save()는 지연 방식으로 구현되어 있습니다. 호출 시점에는 counter만 증가하고, 실제 frame은 이후 realizeSaves()가 생성합니다. 한편 canvas layers 기능(beginLayer()/endLayer())은 그리기 대상을 중간 buffer로 돌리는 역할을 합니다. 이를 위해 최상단 State의 targetSwitcher 슬롯에 CanvasLayerContextSwitcher를 저장합니다. 이 객체는 GraphicsContextSwitcher 위에 구현된 layer별 redirector입니다. 이 subsystem이 전제하는 invariant는 다음과 같습니다. 각 layer는 자신만의 State frame을 소유하므로 switcher 슬롯은 비어 있는 상태로 시작하고, 아래 layer는 그 밑의 frame에서 계속 살아 있습니다.

관전 포인트: 페이지가 save-stack cap까지 canvas layer를 연 뒤 beginLayer()를 한 번 더 호출하는 경우를 생각할 수 있습니다. 이때 활성 layer의 backing GraphicsContext가 해제될 수 있는데, 살아 있는 switcher는 여전히 그 객체를 raw pointer로 가리키고 있습니다. 결과적으로 이후의 canvas 그리기 동작은 stale pointer를 역참조하게 됩니다.

Commit message에 따르면, beginLayer()는 realizeSaves()가 항상 성공한다고 가정했습니다. 그러나 state stack이 이미 MaxSaveCount에 도달한 상태라면 realizeSavesLoop()는 "Ignoring save()."를 로그로 남기고 stack을 늘리지 않은 채 반환합니다. 이 경우 m_unrealizedSaveCount는 0이 아닌 값으로 남습니다. 수정 사항은 이 조건에서 beginLayer()가 즉시 반환하도록 합니다.

Source/WebCore/html/canvas/CanvasRenderingContext2DBase.cpp

void CanvasRenderingContext2DBase::beginLayer(...)
{
+ if (m_unrealizedSaveCount)
+ return;
...
state().targetSwitcher = CanvasLayerContextSwitcher::create(*this, ...);
}

beginLayer() 시작 부분에 guard 하나가 추가되었습니다. 아직 실현되지 않은 save가 남아 있으면, 새 layer switcher를 생성하기 전에 함수가 반환됩니다. 이 상태는 realizeSavesLoop()가 MaxSaveCount를 넘는 push를 거부할 때 남기는 흔적입니다. Layer 생성 경로의 나머지 부분은 변경되지 않았습니다. 즉, layer가 소유해야 할 frame을 만들 수 없는 경우 layer 생성 자체를 거부하는 방식으로 "layer 하나에 frame 하나"라는 invariant를 복구한 셈입니다.

크기 제한이 있는 push의 실패를 확인하지 않아 새 소유자가 소유 슬롯을 덮어쓰고, 다른 객체가 이미 raw pointer로 캐싱한 대상을 해제하는 패턴.

이 코드가 있는 위치. CanvasRenderingContext2DBase는 CanvasRenderingContext2D, OffscreenCanvasRenderingContext2D, PaintRenderingContext2D가 공유하는 구현체입니다. 세 context 모두 WebContent process에서 실행됩니다.

지연 save stack. save()의 구현은 { ++m_unrealizedSaveCount; }가 전부입니다. 실제 frame은 realizeSaves()/realizeSavesLoop()가 최상단 State를 복사해 m_stateStack에 push하면서 생성됩니다. 다만 realizeSavesLoop()에는 상한이 있습니다. MaxSaveCount에 도달하면 console 경고만 출력하고 push 없이 반환합니다.

Layer switcher. CanvasLayerContextSwitcher::create()는 먼저 context.effectiveDrawingContext()를 호출합니다. 그리고 그 결과를 raw 멤버인 GraphicsContext* m_effectiveDrawingContext에 캐싱합니다. 같은 객체는 std::unique_ptr<GraphicsContextSwitcher> m_targetSwitcher도 함께 보유합니다. 생성된 switcher는 RefPtr을 통해 최상단 State에 저장됩니다.

RefPtr ownership. 유일하게 소유된 switcher를 가리키는 RefPtr이 덮어써지면, 이전 switcher의 refcount가 0이 됩니다. 그 즉시 ~CanvasLayerContextSwitcher()가 호출되고, 소유하던 GraphicsContextSwitcher와 backing GraphicsContext가 함께 파괴됩니다.

이 버그는 확인되지 않은 bounded push에서 비롯된 use-after-free입니다. Cap 상태에서 두 번째 beginLayer()가 호출될 때는 생성 순서가 결정적인 역할을 합니다.

  beginLayer() #2 at MaxSaveCount
  ────────────────────────────────
  create() → effectiveDrawingContext()  → resolves to LAYER #1's ctx
           → cache raw m_effectiveDrawingContext = ctx(#1)   [new switcher]
  state().targetSwitcher = new switcher  → releases LAYER #1 switcher
           → ~CanvasLayerContextSwitcher(#1)
                 → destroys unique_ptr<GraphicsContextSwitcher>
                 → frees ctx(#1)                              ← now freed
  new switcher.m_effectiveDrawingContext ──► ctx(#1)          ← dangles

Stack이 늘어나지 않았기 때문에, 새 switcher는 layer #1의 switcher를 아직 들고 있는 동일한 최상단 State에 저장됩니다. 새 RefPtr을 대입하는 순간 layer #1의 switcher가 해제됩니다. 이 switcher는 유일한 소유자였으므로 곧바로 destructor가 호출됩니다. 그 결과, 새 switcher가 방금 raw pointer로 캐싱한 바로 그 GraphicsContext가 파괴됩니다.

이후 새 switcher를 사용하는 모든 경로는 dangling pointer를 역참조하게 됩니다. 먼저 drawingContext()는 m_targetSwitcher->drawingContext(*m_effectiveDrawingContext)를 수행합니다. 또한 ~CanvasLayerContextSwitcher()는 m_targetSwitcher->endDrawSourceImage(*m_effectiveDrawingContext, ColorSpace::SRGB())를 수행합니다. 두 역참조 지점 모두 제공된 CanvasLayerContextSwitcher.cpp에서 확인됩니다. Layout test에서는 세 번째 beginLayer()가 layer #2의 destructor를 강제로 실행시키는데, 이때 이미 해제된 context 위에서 동작하게 됩니다.

CanvasLayerContextSwitcher::drawingContext()는 m_targetSwitcher->drawingContext(*m_effectiveDrawingContext)를, destructor는 m_targetSwitcher->endDrawSourceImage(*m_effectiveDrawingContext, ColorSpace::SRGB())를 수행합니다. 즉 draw 경로와 teardown 경로 양쪽에서 dangling 멤버를 역참조합니다. state().targetSwitcher = ... 대입문과 슬롯 이름은 commit message를 근거로 삼은 내용이며, friend class CanvasLayerContextSwitcher 선언과 RefPtr을 반환하는 create()와도 일관됩니다.

확인된 영향은 stale 역참조 시점에 항상 동일하게 발생하는 crash입니다. 더 강한 primitive로 확장하려면 추가 조건이 필요합니다. 해제 시점과 다음 그리기 사이에, 해제된 GraphicsContext allocation을 attacker가 제어하는 내용으로 재점유해야 합니다. 이는 예상되는 확장 방향일 뿐이며, 이번 변경이 입증하지 않는 heap 재점유에 의존합니다. 한편 특정 구성에서 CanvasLayersEnabled가 노출되는지는 별개의 문제이며, 이것이 도달 가능성을 좌우합니다.