← All reports

[4] Data race in Range::visitNodesInGCThread during GC

HighWebCore DOMRace

The garbage collector read a DOM node the main thread had just freed

ab48873

High. Garbage-collector marking thread가 main thread에서 방금 마지막 strong reference를 해제한 Node를 dereference할 수 있습니다. 살아있는 Range를 들고 DOM을 변경하는 어떤 script에서도 도달 가능한 memory unsafety입니다. Critical로 격상되지 않는 이유는 reliability 때문입니다. Window가 몇 개의 instruction 폭에 불과하고, 해제된 allocation을 제때 재사용하는 것도 보장되지 않습니다.

DOM Range 객체 — document.createRange()getSelection().getRangeAt()이 반환하는 그것 — 는 두 개의 endpoint를 가지며, document는 트리가 그 아래에서 변경될 때마다 이를 최신 상태로 유지합니다. 각 endpoint는 RangeBoundaryPoint로, container node를 Ref<Node>로 저장합니다. 이는 절대 null이 될 수 없는 reference-counted pointer로, 할당 시 새 pointer를 저장하고 기존 pointer를 release합니다. 한편 JSC의 collector는 전용 marking thread에서 heap을 추적하며, 이때 main thread는 계속 실행됩니다. WebCore는 JS 객체가 아닌 것들을 "opaque root"로 collector에 보고하여 wrapper가 살아남도록 합니다. 이 동작이 안전하려면, marking visitor가 읽는 데이터가 marking 도중 immutable하거나 lock으로 직렬화되어 있어야 합니다.

관전 포인트: 살아있는 Range를 들고 tight loop에서 DOM을 변경하는 페이지는, main thread가 방금 해제한 Node를 collector thread가 읽게 만들 수 있습니다.

m_startm_end를 변경하는 부분에 lock을 추가합니다.

이 data race를 안정적으로 테스트할 방법이 없어 새 테스트는 추가하지 않습니다.

Source/WebCore/dom/Range.h

+#include <wtf/Lock.h>
+#include <wtf/Locker.h>
...
Ref<Document> m_ownerDocument;
RangeBoundaryPoint m_start;
RangeBoundaryPoint m_end;
+ mutable Lock m_boundaryPointLock;

Source/WebCore/dom/Range.cpp

ExceptionOr<void> Range::setStart(Ref<Node>&& container, unsigned offset)
{
...
 
- m_start.set(WTF::move(container), offset, childNode.releaseReturnValue());
 
- if (!is_lteq(treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end))))
+ {
+ Locker locker { m_boundaryPointLock };
+ m_start.set(WTF::move(container), offset, childNode.releaseReturnValue());
+ }
+ if (!is_lteq(treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end)))) {
+ Locker locker { m_boundaryPointLock };
m_end = m_start;
+ }
...
void Range::nodeWillBeRemoved(Node& node)
{
...
+ Locker locker { m_boundaryPointLock };
boundaryNodeWillBeRemoved(m_start, node);
boundaryNodeWillBeRemoved(m_end, node);
...
void Range::visitNodesInGCThread(JSC::AbstractSlotVisitor& visitor) const
{
+ Locker locker { m_boundaryPointLock };
addWebCoreOpaqueRoot(visitor, m_start.container());
addWebCoreOpaqueRoot(visitor, m_end.container());
}

Source/WebCore/dom/Document.cpp

void Document::textInserted(Node& text, unsigned offset, unsigned length)
{
 
- for (auto& range : m_ranges)
 
- range.get().textInserted(text, offset, length);
+ for (Ref range : m_ranges)
+ range->textInserted(text, offset, length);

이번 변경은 mutator/collector 경계 양쪽에서 lock을 획득하도록 하는 것이 핵심이며, 여기에 부수적인 annotation 변경과 그에 맞물린 iteration 수정이 함께 포함되어 있습니다.

WebCore::Rangemutable Lock m_boundaryPointLock 멤버가 추가되었고, <wtf/Lock.h><wtf/Locker.h> include도 함께 들어갔습니다. Write 쪽에서는 Range::setStartRange::setEndLocker scope가 추가되었습니다 (각각 두 곳 — m_start.set(...) / m_end.set(...) 호출 부분 하나, treeOrder 비교 이후의 m_end = m_start / m_start = m_end 정규화 부분 하나). 같은 방식의 LockerRange::collapse, selectNodeContents, nodeChildrenChanged, nodeChildrenWillBeRemoved, nodeWillBeRemoved, textInserted, textRemoved, textNodesMerged, textNodeSplit에도 추가되었습니다. Read 쪽에서는, 기존에 존재하던 Range::visitNodesInGCThread가 — signature 자체는 변경되지 않았고 diff에는 context로만 등장합니다 — addWebCoreOpaqueRoot(visitor, m_start.container()) / m_end.container() 호출 전에 동일한 lock을 획득하도록 바뀌었습니다.

부수적인 변경으로는, Range.h에서 nodeChildrenChanged, textInserted, textRemoved의 선언에서 NODELETE annotation이 제거되었고, 이에 맞춰 Document::textInserted / Document::textRemovedm_ranges iteration 방식이 for (auto& range : m_ranges) range.get()...에서 for (Ref range : m_ranges) range->...로 바뀌었습니다. 이제 NODELETE가 아니게 된 호출 전 구간에서 각 Range가 ref로 보호되도록 하기 위함입니다.

Reference-counted pointer 필드를 동기화 없이 동시에 읽는 패턴입니다. 이 필드의 mutation은 pointer 자체를 재작성하는 동시에 기존 객체의 마지막 reference를 해제합니다.

Live DOM Range. Script에서 생성되며 start와 end boundary point를 갖고, 트리가 변경될 때마다 Document가 최신 상태로 유지하는 객체입니다. Document::m_ranges는 attach된 live range들을 보관하며, text 삽입 같은 DOM mutation은 등록된 각 range를 호출하여 (Document::textInsertedRange::textInserted) boundary offset과 container를 조정할 수 있게 합니다.

RangeBoundaryPointRef<T>. RangeBoundaryPointRange::m_startRange::m_end를 뒷받침하는 실제 storage로, container node를 Ref<Node>로 보관하며 offset과 child-before pointer를 함께 가집니다. Ref<T>는 절대 null이 될 수 없는 reference-counted smart pointer입니다. Ref에 값을 할당하면 새 pointer를 저장하고 기존 pointer를 release하며, 이것이 마지막 reference였다면 기존 객체가 destroy됩니다.

Concurrent marking과 opaque root. JSC의 collector는 marking 작업을 전용 thread에서 수행하며, 그동안 mutator — 즉 main thread — 는 계속해서 JavaScript와 DOM operation을 실행합니다. WebCore는 DOM 트리의 root node 같은 non-JS 객체를 addWebCoreOpaqueRoot(visitor, node)를 통해 collector에 보고합니다. Opaque root가 mark된 wrapper는 살아남게 됩니다.

Lock / Locker. WebKit의 mutex와 그 RAII scope guard입니다. Locker locker { lock }은 construction 시점에 lock을 획득하고 scope 종료 시 release합니다.

NODELETE. 객체 destruction을 유발해서는 안 되는 code path임을 표시하는, WebCore method 선언에 붙는 annotation입니다.

Root cause는 Range::visitNodesInGCThread가 — 이름과 JSC::AbstractSlotVisitor& parameter로 볼 때 marking thread에서 실행됨을 알 수 있습니다 — m_start.container() / m_end.container()를 아무런 동기화 없이 읽는 반면, main thread는 DOM API와 Range API를 통해 같은 멤버들을 자유롭게 mutate했다는 데 있습니다.

  Main thread (mutator)              JSC marking thread
  ─────────────────────              ──────────────────
                                     load m_start.container()
                                       -> Node* N  ──┐
  m_start.set(newContainer, ...)                     │
    store new pointer                                │
    deref old  -> refcount 0                         │
    ~Node()  -> free(N)                               │
                                     addWebCoreOpaqueRoot(visitor, N)
                                       -> dereferences N  ◄─┘  UAF

이번 패치가 guard하게 된 모든 mutation 경로 — set(), setToBeforeContents(), setToAfterContents(), collapse()/setStart()/setEnd() 안의 m_end = m_start 복사 대입, 그리고 boundary 조정 helper인 boundaryNodeWillBeRemoved, boundaryNodeChildrenWillBeRemoved, boundaryTextInserted, boundaryTextRemoved, boundaryTextNodesMerged, boundaryTextNodesSplit — 는 non-atomic한 pointer 덮어쓰기를 수행하는 동시에 이전에 저장되어 있던 Node의 strong reference를 해제합니다. Ref<Node> 할당은 두 단계로 이루어집니다. 새 raw pointer를 저장하고, 기존 pointer를 deref()하는 것입니다. addWebCoreOpaqueRoot(visitor, m_start.container())를 실행하는 marking thread는 해제될 Node*를 load한 뒤, main thread의 deref()가 이미 destructor를 실행한 이후에 그 pointer를 사용하게 될 수 있습니다. 이는 boundary point가 마지막 strong reference를 쥐고 있던 경우에 정확히 문제가 됩니다. addWebCoreOpaqueRoot는 raw pointer 값을 단순 비교하는 게 아니라 node로부터 opaque root를 도출하기 때문에, 해제된 객체가 실제로 dereference됩니다.

같은 race에서 파생되는, 상대적으로 약한 두 번째 실패 양상도 있습니다. Marker가 mutation 이전의 m_start와 mutation 이후의 m_end처럼 서로 다른 시점의 boundary pair를 관찰하는 torn read가 발생할 수 있고, 이 경우 해당 marking increment에서 계산된 opaque-root set은 어떤 Range의 일관된 snapshot에도 대응하지 않게 됩니다.

Race 상태에 도달하는 것 자체는 전적으로 web content가 유발할 수 있습니다. 살아있는 Range를 들고 DOM을 변경하는 어떤 script든 write 쪽을 실행시키고, concurrent marking이 해당 Range를 방문할 때마다 read 쪽이 실행됩니다. 예상 가능한 trigger 시퀀스는 다음과 같습니다. (1) detached subtree의 node를 가리키는 boundary container를 갖는 대량의 Range 객체를 할당하여, RangeBoundaryPointRef<Node>가 해당 node들의 마지막 strong reference가 되는 상태를 노립니다 — RangeBoundaryPoint가 현실적으로 container의 마지막 strong reference를 쥘 수 있는지가 이 trigger의 핵심 전제이며, 실제로는 경험적으로 확인이 필요합니다. (2) allocation 압력을 가해 concurrent marking이 오래 지속되는 window를 만듭니다. (3) tight loop에서 range.setStart(otherNode, 0) / range.collapse() / range.selectNodeContents(other)를 반복 호출하거나, removeChild / splitText / text mutation을 수행하여 boundaryNodeWillBeRemovedboundaryTextNodesSplit이 container를 재작성하게 만듭니다. 매 iteration마다 Ref<Node>가 덮어써지고 기존 node가 release됩니다.

이 조건들로 controlled outcome을 실제로 만들어내려면 (a) 몇 개의 instruction 폭에 불과한 window에서 이겨야 하고, (b) marker가 읽기 전에 해제된 Node allocation을 attacker가 원하는 데이터로 재점유해야 하며, (c) 그 read가 즉시 fault를 유발하지 않고 살아남아야 합니다. 이 세 조건이 모두 성립한다면, marker가 attacker가 영향을 미친 값을 opaque root로 등록할 가능성이 있고, 이는 다시 어떤 wrapper가 collection에서 살아남는지를 흔들 수 있습니다. Torn-read variant에서는 또 다른 방향이 도출됩니다. 동기화되지 않은 read가 실제로는 동시에 존재한 적 없는 boundary pair를 관찰할 수 있기 때문에, collector가 어떤 boundary가 실제로 가리키는 subtree의 opaque root를 등록하지 못할 가능성이 있습니다. 그 opaque root가 유일한 keep-alive였다면, 해당 wrapper들이 premature하게 collect되어 Range가 부분적으로 collapse된 object graph를 가리키는 상태로 남을 가능성이 있습니다. 두 방향 모두 deterministic한 primitive가 아니라 race timing에 의존합니다. 패치된 code path 안에서 attacker-controlled write는 확인되지 않습니다.

발견 경로는 concurrent-GC visit callback에 대한 패턴 감사였을 가능성이 높습니다. Marking thread가 동기화 없이 읽는 WebCore state를 찾아나서는 방식으로, visitNodesInGCThread가 plain Ref<Node> 기반 boundary point 두 개를 그대로 읽는 것은 그 전형적인 사례에 해당합니다. Crash-report telemetry 경로와도 형태가 일치합니다. Marking thread에서 opaque-root 보고 도중 발생하는 산발적이고 재현 불가능한 fault는 정확히 이 race가 만들어내는 증상이며, 이런 report는 보통 targeted review로 이어집니다. 이 정도 폭의 race를 fuzzing으로 찾아냈을 가능성은 낮고, 패치에도 테스트가 추가되지 않았습니다.

이 vulnerability는 mutator와 concurrent collector 사이의 lifetime 계약을 깨뜨림으로써 WebContent process 내부의 memory safety를 약화시킵니다. 패치 이전에는 GC thread가 main-thread DOM 코드가 방금 마지막 strong reference를 해제한 Node를 dereference할 수 있었습니다. 여기서 흔들리는 security-model 전제는, concurrent marking visitor에서 도달 가능한 데이터가 marking 도중 immutable하거나 동기화로 보호되어야 한다는 것입니다. 패치 이전 Range::m_start/m_end는 둘 중 어느 조건도 만족하지 못했습니다. 이 race를 안정적으로 이길 수 있는 attacker라면 collector thread로부터 해제된 Node memory의 read를 얻어낼 수 있고, collector가 root로 취급하는 대상을 흔들 수도 있습니다. 유리한 heap 조건 하에서는 이것이 단순한 crash를 넘어, renderer의 controllable use-after-free로 가는 발판이 될 가능성이 있습니다.

Insight: Ref/RefPtrpointer slot 자체에 대해서는 아무런 보호도 제공하지 않습니다. Refcount의 atomicity는 count 값 자체를 보호할 뿐, field를 보호하지도, "새 pointer 저장"과 "기존 pointer deref" 사이의 순서를 보호하지도 않습니다. Marking thread에서 읽는 것이 legal한 WebCore 멤버는 사실상 shared mutable state인데, type system은 그런 멤버와 평범한 main-thread-only 멤버를 전혀 구분해주지 않습니다. 이 fix가 의도적으로 lock을 걸지 않은 부분도 눈여겨볼 만합니다. setStart/setEnd의 두 Locker scope 사이에 끼어 있는, plain main-thread accessor와 unlocked makeBoundaryPoint(m_start) read가 그것입니다. 이 비대칭은 main thread가 유일한 writer라는 전제 하에서만 안전한데, 이 전제는 patch에서 명시적으로 assert되지 않고 암묵적으로만 인코딩되어 있습니다.