← All reports

[1] Nested DrawDisplayList replay falls back to the singleton ControlFactory

HighWebCore display-list replayUAF

The per-thread isolation held at every level except the one that made levels.

fc4afc4

High. Context마다 격리되어야 할 handle이 정확히 한 단계의 nesting boundary에서 전파를 멈추면서, GPU-process render thread 4개가 하나의 공유 AppKit cell set 위로 합쳐집니다. 이 확장이 성립하려면 attacker가 이미 WebContent에서 code execution을 확보한 상태여야 합니다. 그 지점부터는 singleton fallback 자체는 항상 동일하게 발생하고, 다만 interleaving만 달라집니다.

WebKit의 GPU-process rendering pipeline에서 cross-thread isolation은 각 rendering client가 그림을 그릴 때 사용하는 platform object의 사본을 개별적으로 갖도록 하는 방식으로 유지됩니다. 이렇게 하면 thread-safe하지 않은 native widget을 두 thread가 동시에 건드리는 상황이 발생하지 않습니다. RemoteRenderingBackend는 하나의 client가 사용하는 2D rendering resource를 소유하는 GPU 측 객체인데, 각각 자신만의 전용 work-queue thread에서 동작하며 기록된 drawing command를 그 thread 위에서 replay합니다. 이때 기록되는 command 중에는 native form control을 그리는 DrawControlPart item이 포함되어 있는데, 이 item은 replay마다 개별적으로 전달받은 ControlFactory를 통해 렌더링됩니다. 바로 이 구조 때문에 process 전체에서 공유되는 ControlFactory::singleton()이 render thread에서 건드려지는 일이 원천적으로 없어야 합니다.

관전 포인트: 이미 compromise된 renderer가 control-drawing command를 한 단계 더 감싸면, GPU-process thread 4개가 동일한 공유 AppKit cell object를 동시에 변경하게 만들 수 있습니다. commit message에서는 이를 concurrent use-after-free로 보고하고 있습니다.

Commit message는 mechanism과 reachability를 모두 명확히 서술하고 있습니다.

DisplayList::applyItem() only special-cases DrawControlPart, so a nested DrawDisplayList item falls through to item.apply(context) and calls the single-arg GraphicsContext::drawDisplayList, which substitutes ControlFactory::singleton(). A compromised WCP can wrap DrawControlPart items in a nested DrawDisplayList and replay it on multiple RemoteRenderingBackends, racing the singleton ControlFactoryMac's shared NSCell state across GPU-process work-queue threads.

Source/WebCore/platform/graphics/displaylists/DisplayListItem.cpp

void applyItem(GraphicsContext& context, const AffineTransform& baseTransform, ControlFactory& controlFactory, const Item& item)
{
WTF::switchOn(item,
[&](const DrawControlPart& item) {
item.apply(context, controlFactory);
}, [&](const SetCTM& item) {
item.apply(context, baseTransform);
+ }, [&](const DrawDisplayList& item) {
+ item.apply(context, controlFactory);
}, [&](const auto& item) {
item.apply(context);
}
);
}

Source/WebCore/platform/graphics/displaylists/DisplayListItems.cpp

-void DrawDisplayList::apply(GraphicsContext& context) const
+void DrawDisplayList::apply(GraphicsContext& context, ControlFactory& controlFactory) const
{
 
- return context.drawDisplayList(m_displayList);
+ return context.drawDisplayList(m_displayList, controlFactory);
}

Source/WebCore/platform/graphics/controls/ControlPart.cpp

+void ControlPart::setOverrideControlFactory(RefPtr<ControlFactory>&& controlFactory)
+{
+ if (m_overrideControlFactory == controlFactory)
+ return;
+ m_overrideControlFactory = WTF::move(controlFactory);
+ m_platformControl = nullptr;
+}

LayoutTests/ipc/nested-display-list-draw-control-part-crash.html

+ const items = [
+ ['WebCore::ButtonPart', 8], // Button -> m_buttonCell
+ ['WebCore::ButtonPart', 9], // DefaultButton -> m_defaultButtonCell
+ ['WebCore::ToggleButtonPart', 4], // Checkbox -> m_checkboxCell
+ ['WebCore::ToggleButtonPart', 5], // Radio -> m_radioCell
+ ...
+ ];
...
+ function setupBackend(sizePx) {
+ const rb = createRemoteRenderingBackend();
+ const inner = createDisplayListRecorder(rb);
+ recordControlParts(inner.remoteGraphicsContext, sizePx, 30);
+ const innerDL = createDisplayListFromRecorder(rb, inner.recorderIdentifier);
+ const outer = createDisplayListRecorder(rb);
+ outer.remoteGraphicsContext.DrawDisplayList({ identifier: innerDL.displayListIdentifier });
+ const outerDL = createDisplayListFromRecorder(rb, outer.recorderIdentifier);
+ const ib = createRemoteImageBuffer(rb);
+ return { rb, ib, outerDL: outerDL.displayListIdentifier };
+ }
+ const A = setupBackend(20); const B = setupBackend(200);
+ const C = setupBackend(40); const D = setupBackend(120);
+ for (let i = 0; i < 0x40; i++) {
+ A.ib.remoteGraphicsContext.DrawDisplayList({ identifier: A.outerDL });
+ B.ib.remoteGraphicsContext.DrawDisplayList({ identifier: B.outerDL });
+ C.ib.remoteGraphicsContext.DrawDisplayList({ identifier: C.outerDL });
+ D.ib.remoteGraphicsContext.DrawDisplayList({ identifier: D.outerDL });
+ }

이번 수정은 세 부분이 함께 이루어졌습니다. 먼저 DisplayList::applyItem()DrawDisplayList를 위한 새로운 switchOn 분기가 추가되어, replay 범위의 ControlFactory&를 전달합니다(item.apply(context, controlFactory)). 이는 기존에 있던 DrawControlPart(factory)와 SetCTM(base transform)의 특수 처리와 나란히 놓이는 case입니다. 이전에는 DrawDisplayList가 일반 fallback인 [&](const auto& item) { item.apply(context); }에 그대로 걸렸습니다. DrawDisplayList::apply()의 시그니처는 apply(GraphicsContext&) const에서 apply(GraphicsContext&, ControlFactory&) const로 변경되었고, 본문도 nested list에 replay 범위의 factory를 전혀 전달하지 않는 형태였던 context.drawDisplayList(m_displayList)에서 context.drawDisplayList(m_displayList, controlFactory)로 바뀌었습니다.

ControlPart::setOverrideControlFactory()는 header에 inline으로 있던 setter에서 ControlPart.cpp의 out-of-line WEBCORE_EXPORT 정의로 옮겨졌습니다. factory가 그대로면 즉시 반환하고, 실제로 바뀐 경우에는 캐시된 m_platformControl(std::unique_ptr<PlatformControl>)을 비워, 이전 factory가 만든 PlatformControl이 새 factory 아래에서 재사용되지 않도록 합니다. 부수적으로 IPC Testing API를 통해 GPU process를 직접 구동하는 새로운 regression test와 glib TestExpectations의 skip 항목이 함께 추가되었습니다.

재귀적인 dispatch table을 거치면서 context별 isolation handle 전파가 불완전하게 이루어져, nested case가 조용히 process 전역 singleton으로 대체되는 패턴입니다.

Where this lives. WebCore는 그리기 작업을 DisplayList에 기록합니다. DisplayListVector<Item>이며, ItemSave, Clip, DrawControlPart, DrawDisplayList 등 여러 작은 command 클래스의 variant입니다. Replay는 이 vector를 순회하며 DisplayList::applyItem()을 호출하는데, 이 함수는 WTF::switchOn을 이용해 각 variant를 해당하는 apply() 메서드로 dispatch합니다.

Nesting. DrawDisplayList는 payload로 또 다른 Ref<const DisplayList>를 갖는 display-list item입니다. 그래서 display list는 중첩될 수 있고, replay도 재귀적으로 이루어집니다.

The control-drawing layer. ControlPart는 button, checkbox, radio, menu list, search field 같은 native form control을 플랫폼과 무관하게 표현하는 클래스입니다. ControlFactory로부터 createPlatformControl()을 통해 실제 플랫폼에서 그리는 객체인 PlatformControl을 얻고, 이를 mutable std::unique_ptr<PlatformControl> m_platformControl에 캐시한 뒤 updateCellStates()draw()로 구동합니다. ControlPart::controlFactory()m_overrideControlFactory가 설정되어 있으면 이를 반환하고, 그렇지 않으면 process 전역 ControlFactory::singleton()을 반환합니다. macOS에서는 구체적인 factory가 AppKit NSCell 객체를 기반으로 하는 ControlFactoryMac입니다.

GPU-process rendering IPC. WebContent는 rendering backend마다 stream connection 위에 RemoteRenderingBackend를 하나씩 생성합니다. RemoteRenderingBackend의 생성자는 자체 IPC::StreamConnectionWorkQueue를 만들고, 각 handler는 assertIsCurrent(workQueue())를 통해 검증하므로, backend마다 자신만의 전용 thread에서 메시지를 처리합니다. RemoteDisplayListRecorder는 수신한 RemoteGraphicsContext 메시지를 DisplayList::RecorderImpl에 기록하고, SinkDisplayListRecorderIntoDisplayList가 이를 replay 가능한 DisplayList로 확정하면, RemoteImageBuffer의 context가 이를 replay합니다.

ThreadSafeRefCounted. atomic reference counting을 제공하는 WTF의 base class입니다. refcount 자체가 race 없이 안전하다는 점은 보장하지만, 객체가 가진 멤버 상태까지 thread-safe하게 만들어주지는 않습니다.

IPC Testing API. layout test가 다른 process에 raw하게 직접 작성한 IPC 메시지를 보낼 수 있게 해주는 테스트 전용 WebKit 기능입니다(IPCTestingAPIEnabled=true). compromise된 WebContent process가 보낼 수 있는 메시지를 시뮬레이션하는 데 사용됩니다.

이 버그는 재귀적인 dispatch table에서 isolation-context 전파가 실패하는 문제이며, 결과적으로 thread-safe하지 않은 플랫폼 상태에 대한 data race로 드러납니다.

  Backend A thread        Backend B thread        shared singleton
  ────────────────        ────────────────        ────────────────
  replay outerDL          replay outerDL
    factory = A_factory     factory = B_factory
    item: DrawDisplayList   item: DrawDisplayList
      └► generic arm          └► generic arm
         apply(context)          apply(context)     ControlFactoryMac
           no factory ─────────────┬───────────────► m_buttonCell
                                   │                 m_checkboxCell
    30x DrawControlPart            │                 m_radioCell
      updateCellStates(20px) ──────┤                      ▲
                        (200px) ───┘   concurrent RMW ─────┘

applyItem()이 replay마다 별도의 ControlFactory&를 전달하는 이유는, control-drawing 작업을 호출한 context 자신의 factory instance로만 격리시키기 위함입니다. 이 전달은 추가 인자가 필요한 item 타입, 즉 DrawControlPartSetCTM만을 열거하는 WTF::switchOn으로 구현되어 있고, 그 외 나머지는 모두 일반 item.apply(context) 분기로 넘어갑니다. 이 중 DrawDisplayList는 유일하게 재귀적인 item으로, nested DisplayList 전체에 대해 replay 로직에 다시 진입합니다. 그런데 이 타입이 별도로 열거되지 않았기 때문에 일반 분기로 흘러들어갔고, factory 인자 없이 GraphicsContext::drawDisplayList(m_displayList)를 호출하게 되었습니다. 그 결과 nested replay는 자신의 part들에 replay 범위의 factory가 전혀 설치되지 않은 채로 진행되었습니다. isolation invariant는 depth-0 item에서는 유지되었지만, nesting 경계를 넘을 때마다 조용히 무너졌습니다.

이후 단계를 보면, ControlPart::controlFactory()m_overrideControlFactory ? *m_overrideControlFactory : ControlFactory::singleton()을 반환합니다. 그래서 nested item에 override가 설치되지 않은 상태라면, nested list 안의 모든 DrawControlPart가 singleton으로 귀결됩니다. ControlPart::platformControl()은 그 factory에 PlatformControl을 요청하며 createPlatformControl()을 지연 호출하고, ControlPart::draw()는 이렇게 얻은 객체에 대해 updateCellStates(borderRect.rect(), style)를 호출한 뒤 draw(...)를 이어서 호출합니다. ControlFactoryThreadSafeRefCounted이므로 refcount 자체는 안전하지만, 그것이 내어주는 플랫폼 상태에 대해서는 아무것도 보장하지 않습니다. macOS에서는 이 singleton이 ControlFactoryMac인데, 새 테스트의 inline comment와 commit message에 따르면 이 factory가 지연 생성하는 NSCell 멤버와 공유 control view가 updateCellStates/draw에 의해 변경됩니다. 따라서 여러 work-queue thread에서의 동시 replay가 동일한 플랫폼 객체를 아무런 동기화 없이 읽고 쓰게 됩니다. (DrawControlPart::apply(GraphicsContext&, ControlFactory&)setOverrideControlFactory를 통해 전달받은 factory를 part에 설치한다는 부분은, ControlPart::controlFactory()와 두 번째 fix hunk로부터 도출한 합리적인 추론이며, 제공된 context에서 직접 본문이 확인되는 사항은 아닙니다.)

fix의 나머지 절반은 이와 관련된 staleness 문제를 막습니다. ControlPart는 자신의 PlatformControlmutable std::unique_ptr<PlatformControl> m_platformControl에 캐시해두는데, 기존의 inline setOverrideControlFactory는 이 캐시를 무효화하지 않은 채 factory만 교체했습니다. 그래서 override가 바뀐 뒤에도 part가 이전 factory가 만든 PlatformControl을, 그리고 그 뒤의 플랫폼 상태를 계속 사용할 수 있는 상태가 남아 있었습니다.

regression test 자체가 하나의 trigger 레시피이므로, 그 구조를 단계별로 살펴볼 필요가 있습니다.

  1. CreateRenderingBackend를 네 번 호출해, 각각 자신의 IPC::StreamConnectionWorkQueue thread를 갖는 RemoteRenderingBackend 네 개를 만듭니다.
  2. backend마다 CreateDisplayListRecorder를 호출한 뒤, ButtonPart(Button/DefaultButton/PushButton), ToggleButtonPart(Checkbox/Radio), 그리고 MenuListPart, SearchFieldPart를 순환하는 DrawControlPart 메시지를 30개 보냅니다. 테스트의 comment에 따르면 이들은 각각 macOS factory의 서로 다른 지연 생성 cell 멤버에 대응합니다. SinkDisplayListRecorderIntoDisplayList가 이를 innerDL로 확정합니다.
  3. backend마다 두 번째 recorder가 DrawDisplayList(innerDL) 메시지를 하나 받아 outerDL로 확정되는데, 이 단계가 바로 nesting에 해당합니다.
  4. backend마다 CG 기반 ImageBuffer를 생성하고, 네 connection 각각에서 그 RemoteGraphicsContextDrawDisplayList(outerDL)을 0x40번 전달합니다.

outer replay는 backend 자신의 ControlFactory를 가지고 applyItem()에 진입합니다. 이때 단일 DrawDisplayList item은 일반 분기에 걸려 factory 없이 context.drawDisplayList(m_displayList)를 호출하고, 30개 DrawControlPart item의 nested replay는 ControlPart::controlFactory()의 singleton 분기를 거치게 됩니다. backend마다 서로 다른 control 크기(20, 200, 40, 120)를 전달하므로, ControlPart::draw()platformControl->updateCellStates(borderRect.rect(), style)가 네 thread에서 동시에 같은 공유 cell에 서로 충돌하는 geometry를 기록하게 됩니다.

exploitability 측면을 살펴보면, 이 경로는 오직 compromise된 WebContent process에서만 도달할 수 있습니다. trigger를 발생시키려면 RemoteRenderingBackend/RemoteGraphicsContext IPC를 직접 작성해서 보내야 하며, 정상적인 WebKit recording 코드라면 context별 factory를 올바르게 설치합니다. 이 위치에서 출발하면, nested-replay 경로는 독립된 work-queue thread들에 걸쳐 항상 동일하게 ControlFactory::singleton()으로 수렴합니다. 이때 즉시 관찰되는 영향은 공유된 control-drawing 상태에 대한 동기화되지 않은 concurrent read-modify-write이며, 여기에 factory가 캐시하는 멤버들의 동시 지연 초기화도 함께 일어납니다. 지연 생성되는 멤버의 초기화가 중간에 끊기거나 두 번 일어나면, 이전 instance가 유실되거나 두 번 해제될 가능성이 있습니다. 만약 race가 걸리는 상태 안에 retain/release가 concurrent 경로끼리 서로 얽히는 reference-counted Objective-C 객체가 포함되어 있다면, over-release로 인해 여전히 참조되고 있는 cell이나 control view가 해제될 수 있습니다. 이 경우 다른 thread의 다음 updateCellStates/draw 호출이 이미 해제된 메모리를 건드리게 되는데, commit message는 이 결과를 정확히 "Concurrent NSCell UAF"라고 명시하고 있습니다. 다만 공유 control 상태의 retain/release 경로 자체는 제공된 context에 포함되어 있지 않으므로, 이 부분은 근거를 밝힌 추정으로 제시합니다. 이를 controlled primitive로 발전시키려면 추가로 GPU-process heap을 grooming해, 해제된 AppKit allocation이 살아남은 thread가 그것을 dereference하기 전에 attacker가 원하는 데이터로 재할당되도록 만들어야 합니다. 제공된 context에는 해당 객체들의 allocation size class나 field layout에 대한 근거가 포함되어 있지 않습니다.

이 vulnerability는 GPU process 내부의 thread-isolation을 약화시킵니다. 이 thread-isolation은 compromise된 WebContent process가 IOSurface, media, GPU driver에 접근할 수 있는 더 높은 권한의 process를 손상시키지 못하도록 막는 trust boundary에 해당합니다. 여기서 걸려 있는 security model의 전제는, 각 RemoteRenderingBackend의 replay work-queue thread가 오직 자신만의 context별 ControlFactory만을 건드려서 thread-safe하지 않은 플랫폼 control 상태가 thread 간에 절대 공유되지 않는다는 것입니다. nested DrawDisplayList replay는 모든 backend를 singleton으로 수렴시킴으로써 이 전제를 깨뜨렸습니다. WebContent에서 code execution을 확보한 attacker는 이를 이용해 GPU process 안의 공유 플랫폼 객체에 대해 동기화되지 않은 concurrent mutation을 유발할 수 있습니다. 이는 sandbox가 보호해야 할 권한 수준에서의 memory corruption이며, 웹 페이지에서 직접 도달 가능한 버그라기보다는 sandbox-escape chain의 한 연결 고리 후보에 해당합니다.

이 버그의 본질은 개별 item의 로직이 아니라 dispatcher 자체의 구조에 있습니다. applyItem()은 언뜻 모든 경우를 다 다루는 것처럼 보이는 WTF::switchOn이지만, default 분기가 있어서 case를 빠뜨려도 컴파일 에러가 아니라 조용한 누락으로 처리됩니다. 그리고 하필 재귀적으로 replay에 다시 진입하는 item 타입이, 추가 context 전파가 가장 필요한 바로 그 타입이었습니다. catch-all 분기와 재귀적인 alternative를 함께 가진 dispatch table은 모두 이런 위험을 안고 있습니다. invariant가 새로운 레벨을 만들어내는 그 지점을 제외한 모든 레벨에서만 지켜지는 셈입니다. 구조적으로 더 안전한 형태를 만들려면, auto fallback을 없애 컴파일러가 새로운 item 타입마다 어떤 replay context가 필요한지 명시하도록 강제하거나, factory를 받지 않는 GraphicsContext::drawDisplayList 형태를 replay 코드에서 아예 쓸 수 없게 만들어 singleton fallback에 암묵적으로 도달하지 못하도록 막을 수 있습니다. 한 가지 더 짚을 점은, ControlFactoryThreadSafeRefCounted라는 사실이 여기서는 은근히 오해를 부른다는 것입니다. 이는 handle 자체를 여러 thread에서 공유해도 안전하게 만들 뿐, 그 뒤에 있는 플랫폼 상태까지 안전하게 만들지는 않습니다. 바로 이 지점이 context별 factory로 막으려 했던 혼동입니다.

발견 경위를 보면, GPU-process display-list IPC 표면을 겨냥한 수동 auditing에 강한 variant-analysis 성격이 더해진 형태로 읽힙니다. glib TestExpectations의 hunk에는 이미 존재하던 인접 테스트 ipc/remotedisplaylistrecorder-drawcontrolpart-slidertrackpart-crash.html이 보이는데, 이는 DrawControlPart replay가 이전에도 이미 조사 대상이었음을 뜻합니다. 자연스러운 후속 질문은 어떤 replay 경로가 context별 factory 없이 DrawControlPart에 도달하는지를 찾는 것입니다. 테스트에서 타임아웃을 늘려 직접 작성한 StreamConnectionWebCore::PlatformColorSpace에 대한 CoreIPC.typeInfo override는, 기성 fuzzer가 아니라 손으로 직접 다듬은 IPC harness 작업이었음을 시사합니다.

중첩된 dispatch 테이블이 재귀 호출 시 per-invocation context를 놓치는 패턴. dispatcher는 열거된 case들에 context를 전달하지만, catch-all 분기는 명시적으로 나열되지 않은 항목에 대해 이 context를 누락시킵니다. 문제는 이 invariant가 대부분의 레벨에서는 유지되지만, 새로운 레벨을 만들어내는 재귀 지점에서만 깨진다는 점입니다. 좁게 보면, Source/WebCore/platform/graphics/displaylists/DisplayListItem.cppDisplayList::applyItem()을 점검해서, replay나 drawing으로 암묵적 기본값을 들고 다시 진입하는 다른 item alternative가 있는지 확인해야 합니다. 아울러 factory 없는 형태의 GraphicsContext::drawDisplayList 호출부를 모두 열거해서, replay 경로에 남아 있는 사례가 없는지 확인할 필요가 있습니다. 단서는 apply()가 context parameter를 기본값 처리하거나 아예 생략한 함수로 이어지는 item입니다. 조금 더 넓게 보면, dispatcher가 context를 전달받는 동일한 패턴은 WebKit의 다른 visitor에서도 나타납니다. [&](const auto&) fallback을 가지면서도 다른 분기들은 추가 state를 받는 variant가 그 대상입니다. display-list, filter(FilterResults), serialization 계층의 다른 WTF::switchOn 지점들을 확인해야 하며, 같은 파일 안의 shouldDumpItem/dumpItem에서도 동일한 누락이 반복되는지 점검할 필요가 있습니다. 코드 검색 결과에서의 단서는, lambda들이 서로 다른 argument list를 갖는 switchOn입니다. 가장 넓은 범위에서 보면, 재사용 가능한 invariant는 dispatcher가 per-invocation context를 나르는 경우, 재귀적인 모든 alternative는 반드시 열거된 case여야 하며, default 분기가 context를 기본값 처리하는 API에 도달할 수 없어야 한다는 것입니다. 이 원칙은 default case와 nested/compound node type을 함께 가진 모든 interpreter나 IR walker에 적용됩니다. V8/SpiderMonkey의 bytecode visitor, default를 가진 LLVM pass의 switch-on-opcode, Block variant가 있는 AST에 대한 Rust의 match_ => 구문이 모두 해당됩니다. 코드베이스를 넘나들며 대응시킬 단서는, default/_/auto 분기가 self-recursive한 node kind, 그리고 호출별 context parameter와 공존하는 형태입니다.