[1] Use-after-free in Node::m_shadowIncludingRoot via destructor cascade
Closing a page freed the <html> element out from under its own shadow trees.
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 안에 있다"는 것은 Document나 ShadowRoot로부터 도달 가능하다는 뜻입니다. 이 전체 구조가 성립하려면, node의 연결을 끊는 모든 code path가 다른 누군가 다시 그 값을 읽기 전에 캐시를 재계산해야 한다는 전제가 지켜져야 합니다.
관전 포인트: 미디어 요소를 로드한 뒤 페이지를 이동하면, shadow-tree node들이 해제된 root element를 계속 가리키게 되고, 이 pointer는 이후 renderer 내부의 event-loop task에서 dereference됩니다.
Document::removedLastRef가removeDetachedChildrenInContainer를 거쳐 document를 정리할 때,<html>element가 document에서 제거됩니다. 이 시점에서<html>은 아직 tree scope 안에 있으므로notifyChildNodeRemoved가 호출되고, 이 함수는 shadow root를 포함한 전체 subtree를 순회하며 모든 하위 node의m_shadowIncludingRoot를<html>로 설정합니다. 이 시점까지는 정상입니다.이후 loop의
RefPtr이 마지막 참조를 해제하면서<html>이 해제됩니다 (child는 parent를 ref-count하지 않으며,m_parentNode는CheckedPtr입니다). 이 시점에 destructor cascade가 시작됩니다.~ContainerNode(<html>)이<body>를 처리하고, 다시~ContainerNode(<body>)가<video>element를 처리하는 식으로 이어집니다. 각 단계에서 direct child에 대해resetShadowIncludingRoot()가 호출되어 해당 node의 캐시는 고쳐집니다. 문제는 앞선notifyChildNodeRemoved순회 과정에서IsConnected와IsInShadowTreeflag가 이미 clear된 상태라는 점입니다. 그 결과isInTreeScope()가 false를 반환하게 되고, 이번 단계에서는notifyChildNodeRemoved가 건너뛰어집니다. 즉 shadow root와 그 하위 node들은 결코 업데이트되지 않으며, 이 node들의m_shadowIncludingRoot는 여전히 이미 해제된<html>을 가리킵니다.
HTMLMediaElement처럼ActiveDOMObject로서 살아남는, JS wrapper가 아닌 다른 메커니즘으로 생존하는 node는 danglingm_shadowIncludingRootpointer를 가진 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
Source/WebCore/dom/Node.cpp
Source/WebCore/dom/Node.h
Patch Details
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가 원래 고쳐야 할 구조 일부를 조용히 건너뛰는 패턴입니다.
Background
이 코드가 있는 위치.
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.h의 Node::rootNode()는 node가 tree scope 안에 있을 때는 treeScope().rootNode()를 읽고, 그렇지 않으면 *m_shadowIncludingRoot를 dereference합니다. 이 캐시가 존재하는 이유는, 연결이 끊긴 node에 대해 rootNode()가 parent chain을 거슬러 올라가는 대신 O(1)로 동작하도록 하기 위함입니다.
Tree scope와 isInTreeScope().
node가 "tree scope 안에 있다"는 것은 Document 또는 ShadowRoot로부터 도달 가능하다는 뜻이며, 이 predicate는 Node의 IsConnected, IsInShadowTree state flag를 근거로 판단됩니다.
두 개의 유지보수 경로.
notifyChildNodeRemoved()는 DOM removal 알림의 진입점입니다. notifyNodeRemovedFromDocument() 또는 notifyNodeRemovedFromTree()로 분기하며, 이 각각은 NodeTraversal::next로 제거된 subtree를 순회하면서 currentNode->shadowRoot()로 재귀합니다. 따라서 shadow tree를 커버하는 경로는 이쪽입니다. 다른 하나의 경로는 resetShadowIncludingRoot()로, removeDetachedChildrenInContainer() 안에서 분리된 단일 child에만 적용됩니다.
Parent pointer의 강도와 destructor cascade.
m_parentNode는 CheckedPtr입니다 (class Node : public ... CanMakeCheckedPtr<Node>). 즉 child는 parent를 살려두지 않으며, child가 여전히 존재하더라도 소유권을 가진 마지막 Ref/RefPtr이 사라지는 즉시 parent가 해제됩니다. 게다가 ~ContainerNode가 자기 자신에 대해 removeDetachedChildrenInContainer()를 호출하므로, 하나의 container를 해제하면 그 child들이 재귀적으로 깊이 우선(depth-first) 순서로 해제되면서 매 단계마다 같은 함수를 다시 진입하게 됩니다.
트리 바깥에서의 lifetime.
ActiveDOMObject는 HTMLMediaElement를 비롯한 특정 객체들이 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을 통해 여전히 관찰 가능할 수 있다"는 신호로 사용하고 있습니다.
Analysis
근본 원인은 하나의 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의 존재입니다. 커밋에서 언급하는 HTMLMediaElement는 ActiveDOMObject로서 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_parentNode가 CheckedPtr라는 사실 자체가, parent의 storage가 자기 child들 아래에서 cascade 도중에 사라질 수 있게 만드는 근본 원인입니다. Node에 캐시된 다른 상위 방향 pointer가 있다면, 이 위험 프로필을 별도의 대가 없이 그대로 물려받게 됩니다. destruction 순서 자체가, non-owning 방향의 상위 edge는 어느 시점에서든 일시적으로 객체가 아니라 그냥 주소에 불과할 수 있는 window를 보장하기 때문입니다.
Audit directions
-
상위 state를 정규화하지 않고 캐시해 두었는데, 그 invalidation이 앞선 teardown 단계에서 이미 변경해버린 liveness predicate에 게이트되어 있는 패턴. 여기서 걸려 있는 invariant는 캐시 복구 pass를 가드하는 조건이, 복구 대상이 되는 동작 자체가 이미 바꿔버린 값이어서는 안 된다는 것입니다. 좁게 보면:
Source/WebCore/dom/에서m_shadowIncludingRoot,m_treeScope와 함께 유지보수되는 다른 멤버들을 검색하여,resetShadowIncludingRoot,updateShadowIncludingRoot,setTreeScopeRecursively의 모든 write 지점에서isInTreeScope()가 이미 false인 상태에서도 복구 로직에 도달 가능한지 확인할 필요가 있습니다. 넓게 보면: 캐시된 상위 방향 pointer나 memoized된 root/owner가 connectivity flag를 키로 하는 조건문 안에서만 갱신되는 곳이라면 어디든 같은 형태가 나타날 수 있습니다 —TreeScope::rootNode사용처,Node::rootNode, focus/selection anchor, 그리고RenderObject쪽에서 캐시하는 container pointer 등이 해당됩니다. 가장 넓게 보면: 이는 "invalidation 조건이, invalidation을 수행하는 동작 자체가 변경한 state를 소비한다"는 일반적인 클래스이며, cascade delete 전에 clear되는 ORM dirty-tracking flag, effect 자신이 reset하는 값을 key로 memoization하는 React 코드, dependent를 방문하기 전에 staleness bit를 clear하는 incremental build 시스템 등으로 그대로 전이됩니다. 코드 리뷰에서 좁은 의미의 단서는if (isInTreeScope())/if (isConnected())분기 안에else없이 들어있는 캐시 수정 호출이고, 넓은 의미의 단서는 같은 call graph 앞쪽에서 기록된 flag를 조건으로 읽는 모든 복구 pass입니다. 가장 넓은 의미의 단서는 "phase 1에서 dirty bit를 clear한다면, phase 2는 여전히 자신이 할 일이 있다는 것을 알 수 있는가?"라는 질문 그 자체입니다. -
하나의 code path에서는 shadow tree를 포함해 순회하지만, 같은 구조를 다루는 sibling path에서는 그렇지 않은 recursive structural walk. 여기서 지켜야 할 invariant는 동일한 per-node state를 유지하는 모든 traversal은 서로 동일한 shadow-tree coverage를 가져야 한다는 것입니다. 좁게 보면,
ContainerNodeAlgorithms.cpp에서currentNode->shadowRoot()를 통해 재귀하는 함수들을 나열할 수 있습니다.notifyNodeInsertedIntoDocument,notifyNodeInsertedIntoTree,notifyNodeRemovedFromDocument,notifyNodeRemovedFromTree, 그리고 새로 추가된Node::updateShadowIncludingRootForSubtree가 여기 해당합니다. 이 함수들의 coverage를, 같은 노드 집합을 순회하면서도 shadow root로는 내려가지 않는 다른 subtree walk와 비교해볼 필요가 있습니다. 예컨대setTreeScopeRecursively나Document/ContainerNode의NodeTraversal::next루프가 그런 경우입니다. 조금 넓게 보면, 같은 종류의 비대칭이 composed-tree iterator와 node-tree iterator 사이에서도 일반적으로 나타납니다.ComposedTreeAncestorIterator와ElementTraversal의 관계, slot-assignment 갱신과 light-DOM child 순회의 관계가 그 예입니다. 가장 넓게 보면, "하나의 구조에 대해 reachability가 서로 다른 두 view가 존재하는" 클래스 전반으로 확장됩니다. Detached layer가 있는 scene graph, portal을 넘나드는 virtual-DOM reconciliation, symlink와 mount point를 다르게 따라가는 filesystem walker가 모두 이 범주에 속합니다. 코드 리뷰 시에는, node state를 유지하면서도shadowRoot()재귀가 동반되지 않는NodeTraversal::next루프가 있는지가 핵심적인 tell입니다. -
강한 ownership의 비대칭 — child가 parent에 대한 non-owning pointer를 들고 있는데, parent의 destructor가 재귀적으로 child를 파괴하면서 parent의 storage는 이미 죽어 있지만 그 descendant는 여전히 코드를 실행하는 상황. Invariant는 owner의 destructor가 시작된 이후에는 어떤 캐시된 upward pointer도 읽혀서는 안 된다는 것입니다. 좁게 보면,
Node.h에서m_parentNode는CanMakeCheckedPtr<Node>를 통한CheckedPtr로 선언되어 있습니다.Node/ContainerNode/ShadowRoot의 다른 멤버들 중 위쪽이나 옆쪽을 가리키는 것들, 즉m_treeScope,m_shadowIncludingRoot,ShadowRoot의 host pointer 등이~ContainerNodecascade 도중 읽힐 수 있는지 점검할 필요가 있습니다.deletionHasBegun()assertion이 이 경계를 표시합니다. 조금 넓게 보면, parent가 child를 소유하는 계층 구조 전반에서 child가 raw/checked back-reference를 들고 있고 parent의 teardown이 child 코드로 다시 진입하는 모든 경우가 해당됩니다. Render tree 파괴 도중의RenderObjectparent chain,Frame/FrameTree의 teardown, cache invalidation 도중의AXObjectparent cache 등이 그 예입니다. 가장 넓게 보면, "owner가 child를 파괴하는 계층 구조에서는 destruction 순서상 위쪽을 가리키는 모든 non-owning pointer가 일시적으로 dangling 상태가 되며, destructor가 호출하는 어떤 코드도 그 pointer를 따라가서는 안 된다"는 원칙으로 일반화됩니다.Drop도중 upgrade되는 Rust의Weak<Parent>, Qt의 QObject parent-child deletion, scope를 dispose하는 DI container에도 동일하게 적용됩니다. 코드 리뷰 시에는, destructor에서 도달 가능한 함수 안에서 upward pointer를 읽는 코드가 있는지가 핵심적인 tell입니다.