[4] Canvas2D filter switcher UAF under save-stack exhaustion
The RAII guard that trusted save() to always make room.
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 버그가 나타납니다. CanvasRenderingContext2D는 save()/restore()를 통해 이 stack을 조작하며, 각 state는 targetSwitcher를 가질 수 있습니다. targetSwitcher는 CanvasLayerContextSwitcher로, 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이 됩니다. 대부분의 코드에서는 문제가 없지만, CanvasFilterContextSwitcher는 targetSwitcher를 바꾸기 위해 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
Source/WebCore/html/canvas/CanvasFilterContextSwitcher.h
LayoutTests/fast/canvas/canvas-filtered-drawing-after-stack-exhaustion.html
Patch Details
이 패치는 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_context는 const WeakRef로 바뀌었고, header에는 #include "Filter.h"가 추가되었습니다. save-stack 소진 이후 filtered drawing을 검증하는 layout test 2개도 함께 추가되었습니다.
무조건적인 state push에 의존하던 lifetime invariant는, 그 push가 한도에 걸려 막히는 순간 깨집니다. 그 결과 아직 참조 중인 소비자가 남아 있는 상태에서 자원이 제자리에서 교체되어 버립니다.
Background
The 2D state stack.
CanvasRenderingContext2D는 save()/restore()로 조작되는 drawing state stack을 유지하며, 이 stack은 MaxStackSize(16384개)로 상한이 걸려 있습니다. Stack이 가득 차면 save()는 문서화된 대로 아무것도 push하지 않는 no-op이 됩니다.
Target switchers.
각 state는 targetSwitcher를 가질 수 있습니다. targetSwitcher는 CanvasLayerContextSwitcher로, drawing을 filter로 라우팅하는 데 쓰이는 RefPtr 관리 객체입니다. CanvasFilterContextSwitcher는 scoped RAII helper로, filtered draw가 진행되는 동안 layer switcher를 context의 active targetSwitcher로 설치했다가 소멸 시 제거합니다.
How a layer switcher borrows its context.
CanvasLayerContextSwitcher::create는 context.effectiveDrawingContext()(현재 active switcher의 drawing context)를 가져와 GraphicsContextSwitcher에 넘기고, 이 GraphicsContextSwitcher는 그 context에 대한 참조를 계속 보유합니다.
Triggering a filtered draw.
context.filter를 none이 아닌 값으로 설정한 뒤 fillRect 같은 draw 호출을 실행하면, 해당 draw에 대해 CanvasFilterContextSwitcher의 생성이 트리거됩니다.
Analysis
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
패치 이전의 CanvasFilterContextSwitcher는 context.save()가 새 state를 push해 줄 것이라고 전제하고 있었습니다. 그래야 modifiableState().targetSwitcher에 대한 대입이 stack top의 새 state에 반영되고, 이전 state의 targetSwitcher와 그것이 소유한 drawing context는 그대로 살아남을 수 있습니다. 그런데 16384개 한도에 도달하면 save()는 조용히 no-op이 되어버립니다. 이 조건에서는 modifiableState().targetSwitcher에 대한 write가 이미 이전 switcher를 담고 있던 바로 그 top state를 덮어씁니다. CanvasLayerContextSwitcher::create는 context.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가 닫히는 동시에 코드의 의도도 훨씬 분명해집니다.
Audit directions
- State를 변경하는 호출이 silent하게 capped되거나 best-effort로 실패할 수 있는데, 그 호출의 정상 동작을 전제로 정합성이 성립하는 scoped/RAII helper. (여기서는
MaxStackSize에서save()가 no-op이 되는 경우입니다.) Invariant는 "내가 새롭고 독립된 slot을 만들었다고 가정한 연산이, 실제로도 그런 slot을 만들었는가"입니다. 좁게는:WebCore/html/canvas내에서save()/realizeSaves()/restore()쌍이나modifiableState()write 중, 별개의 state가 push되었다는 전제를 깔고 있는 다른 코드를 검색해 보십시오.CanvasRenderingContext2DBase자체의 layer/filter 설정부터 살펴볼 만합니다. Match tell은,save/restore로 감싸져 있으면서도 stack이 실제로 늘어났는지를 재확인하지 않는modifiableState().Xwrite입니다. - 다른 객체가 소유한 자원에 대한 borrowed reference를 들고 있는데, 그 자원이 in-place로 교체될 수 있는 경우. Invariant는 "borrowed drawing context를 제공하는 쪽이 이를 캡처한 모든 소비자보다 오래 살아있어야 한다"는 것입니다. 넓게는:
GraphicsContextSwitcher/CanvasLayerContextSwitcher와,effectiveDrawingContext()나GraphicsContext&를 받아서 저장하는 factory들을 점검하십시오. Source context의 lifetime이 소비자가 살아있는 동안Ref/RefPtr로 고정되어 있는지, 덮어쓰기 가능한 stack slot에 의존하고 있지는 않은지 확인해야 합니다. Match tell은,GraphicsContext&/DrawingContext&타입의 생성자 parameter가 대응하는 owning ref 없이 객체에 저장되는 패턴입니다. - Overflow 동작이 "loudly fail"이 아니라 "silently drop"인 capacity-capped stack/pool. 가장 넓게는: bounded-push-becomes-noop 정책을 가진 모든 container로 일반화됩니다. Clamp된 graphics-state save/restore stack이나, fixed-capacity pool과 짝을 이루는 일반적인 RAII guard가 여기에 해당합니다. Canvas 코드에서
save()의 모든 호출부를 나열하고 각각이 no-op 경로를 견딜 수 있는지 확인하십시오. 이식 가능한 invariant는, push가 silent하게 실패할 수 있다면 그 push가 성공했다고 가정한 모든 downstream reference가 잠재적인 aliasing/UAF hazard가 된다는 점입니다.