← All issues

[4] Canvas2D filter switcher UAF under save-stack exhaustion

The RAII guard that trusted save() to always make room.

Severity: High | Component: WebCore HTML Canvas 2D | c5d6db5

High. RAII helper의 lifetime 보장 전체가 "save()는 항상 새 state를 push한다"는 가정 하나에 의존하고 있었습니다. 하지만 stack 한도에 도달하면 이 가정은 조용히 깨지고, 살아있는 drawing context가 그 자리에서 그대로 교체되어 버립니다. Stack이 소진된 이후에는 web에서 도달 가능한 UAF가 무조건적으로 발생합니다. 더 강력한 primitive로의 확장은 해제된 slot을 heap grooming으로 재확보할 수 있는지에 달려 있습니다.

WebKit의 2D canvas는 drawing state stack을 유지하며, scoped helper의 ownership 보장이 silent failure mode를 가진 side effect에 암묵적으로 인코딩되어 있을 때 이런 유형의 lifetime 버그가 나타납니다. CanvasRenderingContext2Dsave()/restore()를 통해 이 stack을 조작하며, 각 state는 targetSwitcher를 가질 수 있습니다. targetSwitcherCanvasLayerContextSwitcher로, drawing을 filter graph로 라우팅하는 RefPtr 관리 객체입니다. CanvasFilterContextSwitcher는 scoped RAII helper로서, filtered draw가 진행되는 동안 layer switcher를 context의 active targetSwitcher로 설치하고 소멸 시 제거합니다. 이때 자신의 save() 호출이 새로운 독립된 state slot을 만들어 그곳에 기록한다는 가정을 전제로 하고 있습니다.

관전 포인트: script가 2D save-stack을 한도 이상으로 채운 뒤 context.filter를 설정하고 draw를 수행하면, 진행 중인 filtered draw 밑에서 살아있는 drawing context가 해제되어 버립니다. Canvas rendering을 담당하는 프로세스 내에서 발생하는 use-after-free이며, 제어된 heap 조건 하에서는 memory-corruption의 토대로 사용 가능합니다.

CanvasRenderingContext2D는 stack에 쌓이는 state 수를 MaxStackSize(현재 16384개)로 제한합니다. 한도에 도달하면 save()는 아무것도 push하지 않는 no-op이 됩니다. 대부분의 코드에서는 문제가 없지만, CanvasFilterContextSwitchertargetSwitcher를 바꾸기 위해 state save가 반드시 일어난다고 가정하기 때문에 여기서 문제가 생깁니다. Switcher가 생성될 때는 나중에 targetSwitcher를 교체할 수 있도록 현재 state를 save해 둡니다. 그런데 stack이 소진된 상태라면 아무 state도 push되지 않고, 새 layer switcher는 이전 switcher가 있던 바로 그 top state를 덮어쓰게 됩니다. 이전 switcher가 해당 slot에 의해서만 소유되고 있었다면 이 switcher는 해제됩니다. 문제는 새 switcher가 방금 해제된 switcher의 drawing context에 대한 참조를 그대로 들고 있다는 점입니다.

패치는 state save 자체를 없애는 방식으로 이를 해결합니다. CanvasFilterContextSwitcher는 생성 시점에 기존 targetSwitcher를 직접 보관하고 새것으로 교체한 뒤, 소멸 시점에 기존 것을 다시 되돌려 놓습니다. 이렇게 하면 stack이 소진된 상태에서도 filtered drawing이 정상적으로 동작합니다.

Source/WebCore/html/canvas/CanvasFilterContextSwitcher.cpp

std::unique_ptr<CanvasFilterContextSwitcher> CanvasFilterContextSwitcher::create(...)
{
- auto filterSwitcher = makeUnique<CanvasFilterContextSwitcher>(context);
- auto targetSwitcher = CanvasLayerContextSwitcher::create(context, bounds, WTF::move(filter));
- if (!targetSwitcher)
- return nullptr;
-
- context.modifiableState().targetSwitcher = WTF::move(targetSwitcher);
- return filterSwitcher;
+ return makeUnique<CanvasFilterContextSwitcher>(context, bounds, WTF::move(filter));
}
 
-CanvasFilterContextSwitcher::CanvasFilterContextSwitcher(CanvasRenderingContext2DBase& context)
+CanvasFilterContextSwitcher::CanvasFilterContextSwitcher(CanvasRenderingContext2DBase& context, const FloatRect& bounds, RefPtr<Filter>&& filter)
: m_context(context)
+ , m_contextOldTargetSwitcher(context.state().targetSwitcher)
{
- context.save();
- context.realizeSaves();
+ if (auto targetSwitcher = CanvasLayerContextSwitcher::create(context, bounds, WTF::move(filter)))
+ m_context->modifiableState().targetSwitcher = targetSwitcher;
}
 
CanvasFilterContextSwitcher::~CanvasFilterContextSwitcher()
{
- context()->restore();
+ m_context->modifiableState().targetSwitcher = WTF::move(m_contextOldTargetSwitcher);
}

Source/WebCore/html/canvas/CanvasFilterContextSwitcher.h

- WeakRef<CanvasRenderingContext2DBase> m_context;
+ const WeakRef<CanvasRenderingContext2DBase> m_context;
+ RefPtr<CanvasLayerContextSwitcher> m_contextOldTargetSwitcher;

LayoutTests/fast/canvas/canvas-filtered-drawing-after-stack-exhaustion.html

+ for (let i = 0; i < 20000; ++i)
+ context.save();
+ context.filter = "drop-shadow(-5px -5px)";
+ context.fillRect(100, 100, 100, 100);
+ context.filter = "drop-shadow(5px 5px)";
+ context.fillRect(200, 200, 50, 50);

이 패치는 CanvasFilterContextSwitcher가 active targetSwitcher를 교체하는 방식 자체를 재구성합니다. 기존에는 create가 filter switcher를 생성하면서(생성자에서 context.save()context.realizeSaves()를 호출해 새 state를 push) CanvasLayerContextSwitcher를 만들어 context.modifiableState().targetSwitcher에 대입했고, 소멸자에서 restore()를 호출해 이를 pop했습니다. 이번 변경에서는 이 save()/realizeSaves()/restore() 흐름이 통째로 제거됩니다.

생성자 시그니처는 CanvasFilterContextSwitcher(CanvasRenderingContext2DBase&, const FloatRect&, RefPtr<Filter>&&)로 바뀌었습니다. 이제 modifiableState().targetSwitcher를 새 layer switcher로 덮어쓰기 전에, 기존 switcher를 RefPtr<CanvasLayerContextSwitcher> m_contextOldTargetSwitcher(context.state().targetSwitcher)라는 새 멤버에 먼저 담아 둡니다. 대입은 새로 생성된 layer switcher가 non-null인 경우에만 이루어집니다. 소멸자는 m_context->modifiableState().targetSwitcher = WTF::move(m_contextOldTargetSwitcher)를 통해 저장해 둔 switcher를 되돌립니다. m_contextconst WeakRef로 바뀌었고, header에는 #include "Filter.h"가 추가되었습니다. save-stack 소진 이후 filtered drawing을 검증하는 layout test 2개도 함께 추가되었습니다.

무조건적인 state push에 의존하던 lifetime invariant는, 그 push가 한도에 걸려 막히는 순간 깨집니다. 그 결과 아직 참조 중인 소비자가 남아 있는 상태에서 자원이 제자리에서 교체되어 버립니다.

The 2D state stack. CanvasRenderingContext2Dsave()/restore()로 조작되는 drawing state stack을 유지하며, 이 stack은 MaxStackSize(16384개)로 상한이 걸려 있습니다. Stack이 가득 차면 save()는 문서화된 대로 아무것도 push하지 않는 no-op이 됩니다.

Target switchers. 각 state는 targetSwitcher를 가질 수 있습니다. targetSwitcherCanvasLayerContextSwitcher로, drawing을 filter로 라우팅하는 데 쓰이는 RefPtr 관리 객체입니다. CanvasFilterContextSwitcher는 scoped RAII helper로, filtered draw가 진행되는 동안 layer switcher를 context의 active targetSwitcher로 설치했다가 소멸 시 제거합니다.

How a layer switcher borrows its context. CanvasLayerContextSwitcher::createcontext.effectiveDrawingContext()(현재 active switcher의 drawing context)를 가져와 GraphicsContextSwitcher에 넘기고, 이 GraphicsContextSwitcher는 그 context에 대한 참조를 계속 보유합니다.

Triggering a filtered draw. context.filternone이 아닌 값으로 설정한 뒤 fillRect 같은 draw 호출을 실행하면, 해당 draw에 대해 CanvasFilterContextSwitcher의 생성이 트리거됩니다.

Root cause는 원래 새 slot으로의 push여야 했던 동작이 in-place 교체로 바뀌면서 발생한 use-after-free입니다.

  Stack NOT full (intended)            Stack full, save() no-ops (bug)
  ─────────────────────────            ───────────────────────────────
  save() -> push new state             save() -> no-op, no push
  new switcher written to NEW top      new switcher written to SAME top
  old switcher stays in prior state    old switcher was sole owner -> freed
  old drawing context alive            new switcher borrows freed ctx  <- UAF

패치 이전의 CanvasFilterContextSwitchercontext.save()가 새 state를 push해 줄 것이라고 전제하고 있었습니다. 그래야 modifiableState().targetSwitcher에 대한 대입이 stack top의 state에 반영되고, 이전 state의 targetSwitcher와 그것이 소유한 drawing context는 그대로 살아남을 수 있습니다. 그런데 16384개 한도에 도달하면 save()는 조용히 no-op이 되어버립니다. 이 조건에서는 modifiableState().targetSwitcher에 대한 write가 이미 이전 switcher를 담고 있던 바로 그 top state를 덮어씁니다. CanvasLayerContextSwitcher::createcontext.effectiveDrawingContext()를 읽는데, 이는 바로 그 old top-of-stack switcher가 소유한 drawing context입니다. 새로 생성되는 GraphicsContextSwitcher는 이 context에 대한 참조를 그대로 보유하게 됩니다. 이후 새 switcher가 변경되지 않은 top state에 대입되면, 해당 slot에 의해서만 소유되던 이전 switcher는 소멸되며 그 drawing context도 함께 해제됩니다. 결과적으로 새 switcher는 dangling reference를 들고 있는 상태가 되고, 이후의 filtered draw 연산들이 이 reference를 역참조하게 됩니다.

Regression test는 정확히 이 조건을 재현합니다. context.save()를 20000번 호출해 stack을 소진시킨 뒤, context.filter에 drop-shadow를 설정하고 fillRect draw를 실행하는 구조입니다. 이 버그가 stack이 소진된 특정 조건에서만 도달 가능하다는 점은, 무작위 fuzzing보다는 save-stack 한도를 겨냥한 edge-case 점검이나 수만 번의 save() 호출 뒤 filtered drawing을 수행하는 stress test 쪽이 이런 버그를 찾기에 적합함을 시사합니다.

즉각적으로 관찰되는 영향은 filtered drawing 도중 dangling reference를 역참조하는 것으로, crash 또는 제어된 invalid access로 나타납니다. 이를 확장하려면 공격자가 heap grooming을 통해 해제된 drawing-context allocation을 제어된 데이터로 재확보해야 합니다. 즉 free 직후 같은 크기의 객체들을 연달아 allocate시켜, filter graph가 이 dangling reference를 사용하는 시점에 공격자가 제어하는 메모리를 가리키도록 만드는 방식입니다. Canvas 2D drawing은 rendering context를 호스팅하는 프로세스(WebContent, 또는 canvas가 offload된 경우 GPU process)에서 실행됩니다. Exploit에 성공하면 해당 프로세스 내에서 primitive를 확보할 수 있으며, sandbox를 벗어나려면 별도의 escape가 여전히 필요합니다.

이 vulnerability는 canvas rendering pipeline 내부의 memory safety를 약화시킵니다. 원래 모델은 filter graph에 데이터를 공급하는 switcher가 자신이 참조하는 drawing context보다 먼저 dangle될 수 없다는 전제를 가지고 있었지만, 패치 이전 코드는 2D state stack이 소진될 때마다 이 전제를 위반했습니다. 패치는 이 간접 구조를 제거하고, 이전 switcher를 RefPtr 멤버에 직접 보관하는 방식으로 ownership을 명시적으로 만듭니다. 이로써 UAF가 닫히는 동시에 코드의 의도도 훨씬 분명해집니다.