← All reports

[1] Use-after-free in Node::m_shadowIncludingRoot via destructor cascade

HighWebCore DOMUAF

Closing a page freed the <html> element out from under its own shadow trees.

9414a97

High. 미디어 요소를 포함한 문서를 닫는 것과 같은 일반적인 페이지 teardown 과정만으로도, 살아있는 객체가 해제된 메모리를 가리키는 root pointer를 계속 들고 있는 상태가 만들어집니다. 이 상태에 도달하기 위해 특별한 script가 필요하지 않습니다. Critical이 아니라 High로 분류되는 이유는, 이 stale read를 controlled primitive로 발전시키려면 해제된 Element allocation을 공격자가 원하는 데이터로 재확보해야 한다는 조건이 추가로 필요하기 때문입니다.

모든 DOM node는 자신이 속한 트리의 root에 대한 pointer를 캐시해 둡니다. 이렇게 하면 연결이 끊긴 node에게 "너의 root는 무엇이냐"라고 물을 때, parent chain을 거슬러 올라가는 대신 단 한 번의 load로 답할 수 있습니다. 이 캐시가 바로 m_shadowIncludingRoot이며, raw Node* 타입입니다. Node::rootNode()는 node가 더 이상 tree scope 안에 있지 않을 때 이 값을 무조건 dereference합니다. 여기서 "tree scope 안에 있다"는 것은 DocumentShadowRoot로부터 도달 가능하다는 뜻입니다. 이 전체 구조가 성립하려면, node의 연결을 끊는 모든 code path가 다른 누군가 다시 그 값을 읽기 전에 캐시를 재계산해야 한다는 전제가 지켜져야 합니다.

관전 포인트: 미디어 요소를 로드한 뒤 페이지를 이동하면, shadow-tree node들이 해제된 root element를 계속 가리키게 되고, 이 pointer는 이후 renderer 내부의 event-loop task에서 dereference됩니다.

Document::removedLastRefremoveDetachedChildrenInContainer를 거쳐 document를 정리할 때, <html> element가 document에서 제거됩니다. 이 시점에서 <html>은 아직 tree scope 안에 있으므로 notifyChildNodeRemoved가 호출되고, 이 함수는 shadow root를 포함한 전체 subtree를 순회하며 모든 하위 node의 m_shadowIncludingRoot<html>로 설정합니다. 이 시점까지는 정상입니다.

이후 loop의 RefPtr이 마지막 참조를 해제하면서 <html>이 해제됩니다 (child는 parent를 ref-count하지 않으며, m_parentNodeCheckedPtr입니다). 이 시점에 destructor cascade가 시작됩니다. ~ContainerNode(<html>)<body>를 처리하고, 다시 ~ContainerNode(<body>)<video> element를 처리하는 식으로 이어집니다. 각 단계에서 direct child에 대해 resetShadowIncludingRoot()가 호출되어 해당 node의 캐시는 고쳐집니다. 문제는 앞선 notifyChildNodeRemoved 순회 과정에서 IsConnectedIsInShadowTree flag가 이미 clear된 상태라는 점입니다. 그 결과 isInTreeScope()가 false를 반환하게 되고, 이번 단계에서는 notifyChildNodeRemoved가 건너뛰어집니다. 즉 shadow root와 그 하위 node들은 결코 업데이트되지 않으며, 이 node들의 m_shadowIncludingRoot는 여전히 이미 해제된 <html>을 가리킵니다.

HTMLMediaElement처럼 ActiveDOMObject로서 살아남는, JS wrapper가 아닌 다른 메커니즘으로 생존하는 node는 dangling m_shadowIncludingRoot pointer를 가진 shadow DOM을 그대로 유지합니다. 이후 이 node들이 사용될 때 — 커밋에서는 event loop task를 통한 VTT cue display tree 업데이트를 예로 들고 있습니다 — stale pointer가 dereference되면서 use-after-free가 발생합니다.

새로운 테스트는 추가되지 않았습니다. media/track/webvtt-parser-does-not-leak.html과 같은 기존 미디어 테스트가 이 fix 없이는 debug assertion에 걸릴 것이라는 설명이며, 이 버그는 C++ 코드에 의해 node가 계속 살아있어야 재현되는 특성을 가집니다.

Source/WebCore/dom/ContainerNodeAlgorithms.cpp

void removeDetachedChildrenInContainer(ContainerNode& container)
{
...
node->setParentNode(nullptr);
node->resetShadowIncludingRoot();
...
node->setTreeScopeRecursively(Ref<Document> { container.document() });
if (node->isInTreeScope())
notifyChildNodeRemoved(container, *node);
+ else if (node->refCount() > 1)
+ node->updateShadowIncludingRootForSubtree();
ASSERT_WITH_SECURITY_IMPLICATION(!node->isInTreeScope());
}
}

Source/WebCore/dom/Node.cpp

+void Node::updateShadowIncludingRootForSubtree()
+{
+ SUPPRESS_UNCOUNTED_LOCAL for (auto* current = this; current; current = NodeTraversal::next(*current, this)) {
+ current->updateShadowIncludingRoot();
+ SUPPRESS_UNCOUNTED_LOCAL if (auto* shadowRoot = current->shadowRoot())
+ shadowRoot->updateShadowIncludingRootForSubtree();
+ }
+}

Source/WebCore/dom/Node.h

+ void updateShadowIncludingRootForSubtree();

production code 변경 두 곳과 header 선언 한 곳이 추가되었습니다. removeDetachedChildrenInContainer()에서는 기존의 if (node->isInTreeScope()) notifyChildNodeRemoved(container, *node); 분기에 else 브랜치가 새로 붙습니다. 분리된 child가 tree scope 안에 있지 않아 전체 removal walk가 건너뛰어지는 경우인데, 동시에 refCount() > 1이라서 loop 자신의 RefPtr 외에 다른 무언가가 여전히 참조를 들고 있는 상황이라면, 새로 추가된 node->updateShadowIncludingRootForSubtree()가 실행됩니다.

이 새 메서드는 Node.cpp에 정의되어 있습니다. NodeTraversal::next(*current, this)로 자기 자신의 subtree를 순회하면서 각 node에 대해 기존에 있던 updateShadowIncludingRoot()를 호출합니다. 핵심은 current->shadowRoot()를 통해 shadowRoot->updateShadowIncludingRootForSubtree()로 재귀 호출한다는 점인데, 이로써 shadow tree까지 함께 커버됩니다. Node.h에는 movingSteps 옆에 선언이 추가되었습니다. SUPPRESS_UNCOUNTED_LOCAL annotation은 raw Node* 순회용 로컬 변수에 대한 정적 분석 억제 표시일 뿐, fix의 의미론과는 무관합니다. 새로운 layout test는 추가되지 않았습니다.

앞선 teardown 단계에서 이미 clear된 state flag를 조건으로 캐시 유지보수 walk가 게이트되어 있어, 복구 pass가 원래 고쳐야 할 구조 일부를 조용히 건너뛰는 패턴입니다.

이 코드가 있는 위치. ContainerNodeAlgorithms.cpp는 모든 DOM mutation과 document teardown 아래에 깔려 있는 저수준 node 제거 로직을 담고 있습니다. removeDetachedChildrenInContainer()는 container가 파괴될 때 사용되는 빠른 teardown 경로로, 각 child의 연결을 끊고(setNextSibling(nullptr), setParentNode(nullptr), resetShadowIncludingRoot()), tree scope를 document로 재설정한 뒤, 조건에 따라 removal 알림을 실행합니다.

캐시된 root pointer. m_shadowIncludingRoot는 모든 Node가 가지는 raw Node*로, SameSizeAsNode에서 void* shadowIncludingRoot로 확인되며, node가 속한 shadow-including 트리의 root를 기록합니다. NodeInlines.hNode::rootNode()는 node가 tree scope 안에 있을 때는 treeScope().rootNode()를 읽고, 그렇지 않으면 *m_shadowIncludingRoot를 dereference합니다. 이 캐시가 존재하는 이유는, 연결이 끊긴 node에 대해 rootNode()가 parent chain을 거슬러 올라가는 대신 O(1)로 동작하도록 하기 위함입니다.

Tree scope와 isInTreeScope(). node가 "tree scope 안에 있다"는 것은 Document 또는 ShadowRoot로부터 도달 가능하다는 뜻이며, 이 predicate는 NodeIsConnected, IsInShadowTree state flag를 근거로 판단됩니다.

두 개의 유지보수 경로. notifyChildNodeRemoved()는 DOM removal 알림의 진입점입니다. notifyNodeRemovedFromDocument() 또는 notifyNodeRemovedFromTree()로 분기하며, 이 각각은 NodeTraversal::next로 제거된 subtree를 순회하면서 currentNode->shadowRoot()로 재귀합니다. 따라서 shadow tree를 커버하는 경로는 이쪽입니다. 다른 하나의 경로는 resetShadowIncludingRoot()로, removeDetachedChildrenInContainer() 안에서 분리된 단일 child에만 적용됩니다.

Parent pointer의 강도와 destructor cascade. m_parentNodeCheckedPtr입니다 (class Node : public ... CanMakeCheckedPtr<Node>). 즉 child는 parent를 살려두지 않으며, child가 여전히 존재하더라도 소유권을 가진 마지막 Ref/RefPtr이 사라지는 즉시 parent가 해제됩니다. 게다가 ~ContainerNode가 자기 자신에 대해 removeDetachedChildrenInContainer()를 호출하므로, 하나의 container를 해제하면 그 child들이 재귀적으로 깊이 우선(depth-first) 순서로 해제되면서 매 단계마다 같은 함수를 다시 진입하게 됩니다.

트리 바깥에서의 lifetime. ActiveDOMObjectHTMLMediaElement를 비롯한 특정 객체들이 DOM tree 소속 여부나 JS wrapper와 무관하게, script 실행 context에 의해 계속 살아있도록 만드는 lifetime 메커니즘입니다. 미디어 요소와 텍스트 트랙 렌더링은 자신의 컨트롤과 cue display box를 ShadowRootMode::UserAgent 타입의 ShadowRoot 안에 구성합니다. 이 shadow root는 페이지 script에서는 보이지 않지만, shadow-including tree의 정상적인 일부입니다.

observabilityOfRemovedNode(). 같은 파일에 이미 존재하는 helper로, node.refCount() > 1을 "제거된 node가 외부 RefPtr을 통해 여전히 관찰 가능할 수 있다"는 신호로 사용하고 있습니다.

근본 원인은 하나의 predicate가 두 가지 다른 질문에 답하도록 쓰이다가, teardown 도중에 스스로와 모순되는 데 있습니다. isInTreeScope()는 원래 "이 node가 트리 안에 살아있는가"를 의미하는데, 코드는 암묵적으로 이 값을 "이 node의 캐시를 아직 고쳐야 하는가"라는 의미로도 함께 사용하고 있습니다. teardown의 첫 단계에서 이 flag를 뒷받침하는 값들이 clear되고, 두 번째 단계는 그 값을 읽고서 "할 일이 없다"고 결론짓습니다.

  Phase 1: <html> detached, still in tree scope
  ────────────────────────────────────────────────────────
  notifyChildNodeRemoved(<html>)
    walks whole subtree INCLUDING shadow roots
      m_shadowIncludingRoot = <html>   (correct now)
    clears IsConnected / IsInShadowTree on every node

  RefPtr drops last ref to <html>  ──►  free(<html>)

  Phase 2: destructor cascade, flags already cleared
  ────────────────────────────────────────────────────────
  ~ContainerNode(<html>) ─► removeDetachedChildrenInContainer(<html>)
    <body>: resetShadowIncludingRoot()      ✓ repaired
            isInTreeScope() == false  ──►  walk SKIPPED
              <video>'s UA shadow root      ✗ still ──► freed <html>

버그 클래스로 보면, 캐시된 raw pointer가 stale해진 상태에서 발생하는 use-after-free입니다. notifyChildNodeRemoved()shadowRoot()로 재귀하는 유일한 경로였는데, flag가 이미 내려간 뒤에는 이 경로에 도달할 수 없게 됩니다. cascade 각 단계에서 실행되는 resetShadowIncludingRoot()는 오직 child 하나만 고칠 뿐입니다. shadow root 아래에 매달린 모든 것은 결국 한 단계 위에서 이미 해제된 storage를 계속 가리키게 됩니다.

일시적인 불일치를 exploit 가능한 상태로 바꾸는 요소는, DOM lifetime에 묶이지 않는 subtree의 존재입니다. 커밋에서 언급하는 HTMLMediaElementActiveDOMObject로서 teardown 이후에도 살아남으며, 자신의 user-agent shadow tree — 컨트롤, cue display box — 를 함께 끌고 다닙니다. 이후 event-loop task에서의 사용이 이 상황을 촉발하는데, 커밋은 이를 VTT cue display-tree 업데이트로 설명하며, 이 과정에서 해제된 pointer가 dereference됩니다. media/track/webvtt-parser-does-not-leak.html이 fix 없이는 debug assertion에 걸릴 것이라는 커밋 메시지의 설명은 그대로 인용합니다. diff에 테스트 변경이 없다는 점도 이 설명과 배치되지 않습니다.

fix는 정확히 건너뛰어지던 그 경우에 명시적인 shadow-including subtree walk를 실행함으로써 invariant를 복구합니다. 이때 refCount() > 1을 조건으로 걸어, 외부 소유자가 실제로 그 node를 나중에 관찰할 수 있는 경우에만 비용을 지불하도록 했습니다. 이는 몇십 줄 위쪽의 observabilityOfRemovedNode()에 이미 인코딩되어 있는 것과 같은 observability 휴리스틱입니다.

이 조건이 계약(contract)에 미치는 영향을 짚어볼 필요가 있습니다. refCount() == 1인 node는 여전히 dangling 캐시를 갖게 되지만, 곧바로 죽어버리기 때문에 아무도 이를 관찰할 수 없습니다. 이제 invariant는 "캐시는 항상 올바르다"가 아니라 "누군가 여전히 볼 수 있는 node에 한해서만 캐시가 올바르다"로 약화된 셈입니다. 이는 더 취약한 형태이며, 향후 non-refcount 메커니즘으로 node의 lifetime을 연장하는 변경이 들어온다면 이 가정이 조용히 깨질 수 있습니다.

이 vulnerability는 WebContent process 내부의 메모리 안전성을 약화시킵니다. 여기서 걸려 있는 security-model 가정은, node가 자신이 분리된 트리보다 더 오래 살아남기 전에 m_shadowIncludingRoot가 항상 live node로 복구되어 있어야 한다는 것입니다. rootNode()는 트리 밖에 있는 node에 대해 이 값을 무조건 dereference하기 때문입니다. fix 이전에는, user-agent shadow tree를 가진 미디어 요소를 포함한 문서에 대한 평범한 페이지 teardown만으로도 dangling root pointer가 남았고, 이 node들은 계속 살아있으면서 event-loop task에서 계속 사용되었습니다. 공격자가 이런 teardown 상황을 만든 뒤 stale pointer의 사용을 유발할 수 있다면, 해제된 Element에 대한 use-after-free를 얻을 수 있습니다. 만약 해제된 allocation이 공격자가 원하는 데이터로 재확보된다면, 이는 renderer 내부에서 type-confused object read나 그 이상으로 확장될 가능성이 있습니다.

REGRESSION prefix와, loop 끝에 이미 있던 ASSERT_WITH_SECURITY_IMPLICATION(!node->isInTreeScope())는 이 버그가 targeted fuzzing 캠페인이 아니라 debug bot에서의 assertion failure 또는 ASan 리포트로 발견되었을 가능성을 가리킵니다. 즉 기존 테스트 실행 중에 잡혔고 수작업으로 근본 원인이 규명되었을 가능성이 높으며, REGRESSION prefix로 미루어 볼 때 캐싱 방식에 대한 이전 변경분에 대한 variant analysis 성격도 있는 것으로 보입니다.

Insight: m_parentNodeCheckedPtr라는 사실 자체가, parent의 storage가 자기 child들 아래에서 cascade 도중에 사라질 수 있게 만드는 근본 원인입니다. Node에 캐시된 다른 상위 방향 pointer가 있다면, 이 위험 프로필을 별도의 대가 없이 그대로 물려받게 됩니다. destruction 순서 자체가, non-owning 방향의 상위 edge는 어느 시점에서든 일시적으로 객체가 아니라 그냥 주소에 불과할 수 있는 window를 보장하기 때문입니다.