← All reports

[6] Range boundary-point lock discipline follow-up

MediumWebCore DOMRace

621e3bf

Medium 등급인 이유는 이번 commit이 강화하는 lock 자체가 이미 존재하고 있었기 때문입니다. 이번 변경은 Range locking 작업에 대한 review follow-up으로, concurrent reader가 서로 다른 두 상태에서 뽑아낸 start/end pair를 관찰할 수 있는 window를 닫습니다. 이 window가 실제로 위협이 되는지는 GC 측 reader가 synchronized region을 벗어난 뒤에도 bare node pointer를 계속 들고 있는지에 달려 있습니다.

살아있는 DOM Range는 start와 end라는 두 endpoint를 유지하며, 이 둘은 반드시 하나의 관계를 만족해야 합니다. 즉 문서 순서상 start가 end보다 앞서거나 같아야 합니다. 스크립트가 한 endpoint를 다른 endpoint 너머로 설정하면, 스펙은 range를 새로 설정된 endpoint로 collapse하도록 요구합니다. 이 repair는 두 번째 write이며, 두 write는 합쳐서 하나의 논리적 update를 이룹니다. 그 사이에 끼어드는 reader는 inverted range를 보게 되는데, 이는 코드 자체가 invalid로 취급하는 상태입니다. 이 endpoint들은 m_boundaryPointLock으로 보호되며, mutation notification이 호출하는 7개의 static boundary* helper 모두 caller가 이 lock을 들고 있다고 가정합니다. 다만 이번 commit 이전에는 그 가정이 코드상 명시되어 있지 않았습니다.

관전 포인트: 방어적 가치 측면에서 보면, marking thread가 스케줄될 수 있었던 중간 상태 window를 닫고, 명시되지 않았던 lock precondition을 이후 refactor가 조용히 빠뜨릴 수 없는 형태로 바꿔놓습니다.

Unreviewed. Addressing the review comments in the original PR.

Source/WebCore/dom/Range.cpp

ExceptionOr<void> Range::setStart(Ref<Node>&& container, unsigned offset)
{
auto childNode = checkNodeOffsetPair(container, offset);
if (childNode.hasException())
return childNode.releaseException();
 
+ bool shouldAlsoSetEnd = !is_lteq(treeOrder(BoundaryPoint(container.copyRef(), offset), makeBoundaryPoint(m_end)));
{
Locker locker { m_boundaryPointLock };
m_start.set(WTF::move(container), offset, childNode.releaseReturnValue());
+ if (shouldAlsoSetEnd)
+ m_end = m_start;
}
 
- if (!is_lteq(treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end)))) {
 
- Locker locker { m_boundaryPointLock };
 
- m_end = m_start;
 
- }
+
updateAssociatedSelection();
updateDocument();
updateAssociatedHighlight();
...
-static inline void boundaryNodeWillBeRemoved(RangeBoundaryPoint& boundary, Node& nodeToBeRemoved)
+static inline void boundaryNodeWillBeRemoved(Locker<Lock>&, RangeBoundaryPoint& boundary, Node& nodeToBeRemoved)
{
if (boundary.childBefore() == &nodeToBeRemoved)
boundary.childBeforeWillBeRemoved();
...
void Range::nodeWillBeRemoved(Node& node)
{
Locker locker { m_boundaryPointLock };
 
- boundaryNodeWillBeRemoved(m_start, node);
 
- boundaryNodeWillBeRemoved(m_end, node);
+ boundaryNodeWillBeRemoved(locker, m_start, node);
+ boundaryNodeWillBeRemoved(locker, m_end, node);
m_didChangeForHighlight = true;
}

Range.cpp 내부에서 서로 다른 두 가지 변경이 이루어졌습니다.

먼저, Range::setStartRange::setEnd는 더 이상 하나의 논리적 update를 두 개의 별도 Locker { m_boundaryPointLock } scope에 나누어 수행하지 않습니다. 이전 코드는 lock을 획득한 뒤 m_start(또는 m_end)를 쓰고 lock을 해제한 다음, treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end))를 평가했습니다. 그리고 range가 inverted 상태가 되었으면 다시 lock을 획득해 나머지 endpoint를 collapse했습니다. 이제는 순서 비교가 critical section 위쪽으로 옮겨졌고, 들어오는 인자(BoundaryPoint(container.copyRef(), offset))를 현재의 반대편 endpoint와 비교해 계산하며, 그 결과를 bool shouldAlsoSetEnd / bool shouldAlsoSetStart에 저장합니다. 이후 m_start.set(...)과 조건부 m_end = m_start(또는 m_start = m_end)가 하나의 critical section 안에서 함께 일어납니다. 다만 hoisting된 predicate는 여전히 반대편 endpoint를 lock 바깥에서 읽는다는 점에 유의해야 합니다. 이 patch는 write 쪽을 하나의 critical section으로 묶었을 뿐, 그 읽기 부분에 locking을 추가한 것은 아닙니다.

둘째로, 7개의 static boundary-mutation helper — boundaryNodeChildrenChanged, boundaryNodeChildrenWillBeRemoved, boundaryNodeWillBeRemoved, boundaryTextInserted, boundaryTextRemoved, boundaryTextNodesMerged, boundaryTextNodesSplit — 가 이름 없는 Locker<Lock>& 파라미터를 앞에 하나씩 갖게 되었고, 이를 호출하는 Range::nodeChildrenChanged, nodeChildrenWillBeRemoved, nodeWillBeRemoved, textInserted, textRemoved, textNodesMerged, textNodeSplit도 각자의 local locker를 전달합니다. 이는 lock discipline을 코드에 명문화하는 작업입니다. "caller는 m_boundaryPointLock을 들고 있어야 한다"는 규칙을 명시되지 않은 관례가 아니라 각 helper의 signature 일부로 만듭니다. helper 본문의 동작에는 기능적 변화가 없습니다.

여러 필드에 걸친 update가 두 개의 별도 critical section으로 쪼개져, 단일 스레드 관찰자는 도달할 수 없는 중간 상태를 concurrent reader가 관찰할 수 있게 되는 패턴.

Live Range와 그 endpoint. document.createRange()로 얻는 DOM Range는 endpoint를 m_start, m_end라는 두 개의 RangeBoundaryPoint 멤버로 저장합니다. RangeBoundaryPointRef<Node> m_container, unsigned m_offset, RefPtr<Node> m_childBefore를 가지고 있으며, strong Ref를 들고 있기 때문에 boundary point가 해당 container node를 참조하는 동안 그 node는 계속 살아있게 됩니다.

Mutation notification. DOM tree가 변경되면 Document는 attach된 모든 live range에게 Range::nodeChildrenChanged, nodeChildrenWillBeRemoved, nodeWillBeRemoved, textInserted, textRemoved, textNodesMerged, textNodeSplit을 통해 알립니다. 각각의 함수는 static boundary* helper를 통해 두 boundary point를 모두 fix up합니다.

NODELETE. Range.cpp 안의 일부 helper에 붙는 annotation으로, 객체 destruction을 유발해서는 안 되는 code path를 표시합니다. 이 annotation이 붙은 helper는 정확히 offset만 조정하는 body를 가진 helper들이고, annotation이 없는 helper는 boundary의 container를 재할당합니다.

Non-thread-safe refcounting. WebCore Node의 reference count는 atomic이 아니기 때문에, 일반적으로 main thread만 이를 증가시키거나 감소시킬 수 있습니다.

GC-thread visiting. JSC의 garbage collector는 전용 marking thread에서 heap을 tracing합니다. DOM wrapper는 visitAdditionalChildrenInGCThread를 통해 이 과정에 참여하며, Range의 경우 JSRange::visitAdditionalChildrenInGCThreadRange::visitNodesInGCThread(visitor)를 호출합니다. Opaque root는 wrapper가 collector에게 "이 native subgraph의 wrapper들을 계속 살려두라"고 알리는 메커니즘입니다.

Lock / Locker. WTF의 mutex와 그에 대응하는 RAII 방식 scoped-acquisition helper입니다. Rangemutable Lock m_boundaryPointLock을 선언하고 있으며, Locker는 감싸고 있는 scope를 벗어날 때 lock을 해제합니다.

treeOrder. treeOrder(BoundaryPoint, BoundaryPoint)는 두 boundary point 중 어느 쪽이 문서 순서상 먼저인지를 나타내는 std::partial_ordering을 반환하며, is_lteq는 "먼저이거나 같음"을 검사합니다. DOM 스펙은 결과 range가 inverted 상태가 될 경우, setStart/setEnd가 range를 새로 설정된 endpoint로 collapse하도록 요구합니다.

근본 원인은 lock-granularity 결함입니다. m_boundaryPointLock은 이미 존재했고 개별 write를 이미 보호하고 있었지만, 하나의 논리적인 two-endpoint update가 두 개의 critical section으로 나뉘어 수행되었고 그 사이에 unlocked gap이 존재했습니다.

  Main thread (pre-patch setStart)      Marking thread
  ────────────────────────────────      ──────────────
  Locker { lock }
    m_start.set(new)   ← drops
      m_start's OLD container ref
  } release
                                        ── scheduled here ──
                                        reads m_start (new)
                                        reads m_end   (stale)
                                        => inverted pair: a state
                                           no single-threaded
                                           caller can observe
  treeOrder(m_start, m_end) -> inverted
  Locker { lock }
    m_end = m_start
  }

어느 reference가 어디서 해제되는지는 정확히 구분해서 봐야 합니다. patch 이전 setStart에서 첫 번째 critical section의 m_start.set(...)m_start 자신의 이전 container reference를 해제합니다. m_end는 gap 동안 자신의 Ref<Node>를 그대로 유지하고 있다가, 두 번째 block에서 덮어써질 때 비로소 자신의 이전 container reference를 해제합니다. 따라서 gap에서 노출되는 중간 상태는 (a) inverted된 start/end pair이며, (b) m_start이전 container 해제이지, dangling m_end가 아닙니다. patch 이후에는 m_start에 대한 write와 m_end의 조건부 collapse가 하나의 Locker 안에서 함께 일어나므로, 어떤 reader도 그 사이에 스케줄될 수 없습니다.

여기서 문제가 되는 reader는 JSRange::visitAdditionalChildrenInGCThreadwrapped().visitNodesInGCThread(visitor)를 호출하는 경로입니다. 즉 JSC의 marking thread가 main thread 밖에서 live Range의 boundary point를 읽는 상황입니다. GC 측 reader가 m_boundaryPointLock을 직접 잡는지 여부가 이 window의 실제 위협 수준을 결정하는데, 이번 patch는 Range::visitNodesInGCThread를 건드리지 않습니다. 따라서 진정으로 unsynchronized한 concurrent reader가 남아있는지는 이번 변경이 해결한 문제가 아니라 열린 질문으로 남습니다.

이 patch는 write 쪽 tear만 닫습니다. collapse 여부를 결정하는 predicate는 여전히 반대편 endpoint를 lock 바깥에서 읽습니다. patch 이후 setStart는 lock을 획득하기 전에 makeBoundaryPoint(m_end)를 평가하고, setEnd는 lock을 획득하기 전에 makeBoundaryPoint(m_start)를 평가합니다. 남은 형태는 "한 endpoint를 unlocked 상태로 읽은 뒤, 둘을 locked 상태로 atomic하게 write"하는 구조이지, 완전히 직렬화된 read-modify-write는 아닙니다. 이 잔여 read가 실제로 문제가 되는지는 다른 어떤 thread가 이 필드들을 쓸 수 있는지에 달려 있는데, 이 파일 안의 mutation-notification 경로는 모두 main thread에서 lock을 잡고 write를 수행합니다.

race가 실제로 이빨을 가지는 지점을 정확히 짚어볼 필요가 있습니다. 7개의 boundary helper 중 boundaryNodeChildrenWillBeRemoved, boundaryNodeWillBeRemoved, boundaryTextNodesMerged, boundaryTextNodesSplit 네 개는 NODELETE annotation이 붙어 있지 않습니다. boundaryNodeChildrenChanged, boundaryTextInserted, boundaryTextRemoved와 달리 이 네 개는 boundary의 container를 재할당하는 body를 가지고 있어 node reference를 해제할 수 있습니다. 이 구분이 곧 reference 해제, 즉 stale-pointer read가 일어날 수 있는 지점의 지도에 해당합니다.

발견 경로로 가장 가능성이 높은 것은 code review입니다. 이 변경은 이전 locking patch에 대한 review follow-up의 형태를 띠고 있습니다. 리뷰어가 setStart/setEnd가 하나의 논리적 update를 두 critical section에 걸쳐 수행한다는 점, 그리고 boundary* helper들이 lock precondition을 관례로만 유지하고 있었다는 점을 지적했을 가능성이 높습니다. 원래 locking 작업을 촉발한 race 자체는 JSC의 marking thread에서 도달 가능한 surface를 감사하면서 — 즉 visitAdditionalChildrenInGCThread 구현체를 나열하고 mutable main-thread state를 읽는지 확인하는 방식으로 — 발견되었을 수도 있고, marking thread에서 발생한 crash-report나 ThreadSanitizer signal에서 비롯되었을 수도 있습니다. 둘 중 어느 쪽인지는 여기서 특정되지 않습니다.

이 결함은 WebContent process 안에서 main(mutator) thread와 JSC의 marking thread 사이의 consistency guarantee를 약화시킵니다. 여기서 걸려 있는 가정은, GC thread에서 읽는 DOM state가 읽는 동안 immutable하거나, lock에 의해 하나의 transaction으로 직렬화되어 있어야 한다는 것입니다. 바로 m_boundaryPointLock이 보장하려던 가정입니다. 수정 전에는 unlocked gap에 스케줄된 marking thread가 하나는 갱신되고 하나는 stale한 start/end pair를 읽을 수 있었고, mutator가 첫 번째 critical section에서 해제한 node reference가 reader가 이미 bare pointer로 샘플링해둔 그 node일 수 있었습니다. unpinned node pointer만 들고 있는 reader가 실제로 존재한다면, 이 race를 안정적으로 이기는 attacker는 renderer 안에서 DOM node에 대한 cross-thread stale-pointer read를 확보하게 될 가능성이 있습니다. 다만 이 자체는 WebContent sandbox 안에 머무르며, 별도의 sandbox escape가 여전히 필요합니다.

Insight: 이번 재구성이 가능했던 이유는 predicate를 m_start의 write 이후 값이 아니라 들어오는 인자(BoundaryPoint(container.copyRef(), offset))를 기준으로 평가할 수 있기 때문입니다. 이 equivalence를 인지했기 때문에 작성자는 lock의 범위를 비교 구간까지 넓히는 대신 두 critical section을 하나로 합칠 수 있었습니다. 두 번째 변경 — Locker<Lock>&를 이름 없이 모든 helper에 관통시키는 것 — 은 lock precondition을 type 수준에서 저렴하게 인코딩하는 방법입니다. mutating helper들이 free function으로 존재하는 codebase에서는 이후 refactor가 unlocked path에서 쉽게 이 helper들을 호출할 수 있기 때문입니다. 다만 이 방식이 올바른 lock이 실제로 잡혀 있음을 증명하지는 않습니다. 그 증명은 WTF_GUARDED_BY_LOCK / assertIsHeld가 담당할 몫이지만, 적어도 call site에서 이 요구사항을 놓칠 수 없게 만듭니다.