[3] UAF in ManualSlotAssignment via WeakHashMap rehash during reentrant composed-tree teardown
A shadow-DOM cache that outlived the table it was stored inside.
High. 한 줄짜리 borrowed pointer가 render-tree teardown을 그대로 통과합니다. 문제는 이 teardown 과정 자체가 pointer가 가리키는 테이블을 rehash할 수 있다는 점입니다. 일반적인 스크립트만으로 모든 단계가 진행되며, sweep에 필요한 GC pressure까지도 스크립트로 유발할 수 있어 free 시점을 attacker가 직접 조율할 수 있습니다. 다만 read를 넘어선 확장이 가능한지는 teardown window 안에서 bucket array를 재점유할 수 있는가에 달려 있습니다.
Shadow DOM은 컴포넌트가 host의 자식 노드를 <slot> 요소로 project하도록 해주며, manual-assignment 모드에서는 attribute matching이 아니라 스크립트가 이 매핑을 명시적으로 결정합니다. WebKit은 각 slot의 resolved node list를 weak-keyed hash table 안에 담긴 per-slot record 형태로 캐싱해 두는데, 이렇게 하면 반복 조회 시마다 트리를 다시 순회할 필요가 없습니다. Assignment가 변경되면 composed tree가 무효화되고, 더 이상 slot에 배정되지 않은 노드들에 대해 render-tree teardown이 진행됩니다. 이 과정 전반에서 캐싱된 list는 원래 위치에 그대로 남아 있어야 합니다.
관전 포인트: mutation handler가 slot의 캐싱된 assignment를 가리키는 pointer를 통해 여전히 읽는 중인데, 그 사이 페이지가 이 pointer가 가리키는 hash-table storage를 해제할 수 있습니다. 결과적으로 스크립트가 타이밍을 조절할 수 있는, 해제된 renderer heap에 대한 read가 발생합니다.
ManualSlotAssignment::slotManualAssignmentDidChange는 assignedNodesForSlot을 호출해 effectiveCurrent를 계산했습니다. 이 함수는 m_slots WeakHashMap의 bucket array 안에 직접 저장된 Slot 값 내부 cachedAssignment Vector를 가리키는 raw pointer를 반환합니다. 이어서 RenderTreeUpdater::tearDownRenderersAfterSlotChange가 호출되는데, 이 함수의 composed-tree traversal은 sibling slot에 대해 HTMLSlotElement::assignedNodes를 호출할 수 있고, 이는 다시 ManualSlotAssignment::assignedNodesForSlot으로 재진입할 수 있습니다. 이때 재진입한 m_slots.ensure 호출이 WeakHashMap의 amortized cleanup을 트리거할 수 있습니다. 이 cleanup은 이전에 삽입되었다가 제거되고 GC로 수거된 slot 요소들이 남긴 null-keyed entry를 sweep하면서 테이블을 rehash하고, 그 결과 effectiveCurrent가 가리키던 bucket array가 해제됩니다. 이후 이 stale pointer는 scheduleSlotChangeEventIfNeeded에서 그대로 dereference되었습니다. 패치에서는 effectivePrevious와 동일한 방식으로, effectiveAssignedNodes를 통해 effectiveCurrent를 local Vector로 계산하도록 변경했습니다. 이렇게 하면 render-tree teardown 도중에 m_slots를 가리키는 pointer가 더 이상 유지되지 않습니다.
Source/WebCore/dom/SlotAssignment.cpp
LayoutTests/fast/shadow-dom/manual-slot-assign-renderer-teardown-crash.html
Patch Details
프로덕션 코드 변경은 ManualSlotAssignment::slotManualAssignmentDidChange 한 곳뿐입니다. assignedNodesForSlot(slot, shadowRoot) 호출이 auto effectiveCurrent = effectiveAssignedNodes(shadowRoot, current);로 교체되었습니다. SlotAssignment.h의 인터페이스 선언에 따르면 이 함수는 const Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>>*를 반환하는데, 이는 WeakHashMap<HTMLSlotElement, Slot, WeakPtrImplWithEventTargetData> m_slots의 bucket 배열 안에 값으로 저장된 Slot 내부의 cachedAssignment Vector를 가리키는 raw pointer입니다. 새로 도입된 effectiveCurrent는 로컬 Vector 복사본을 만들며, 이미 계산되고 있던 effectivePrevious와 동일한 방식을 따릅니다.
scheduleSlotChangeEventIfNeeded 람다도 이에 맞춰 수정되었습니다. null 체크 삼항식이던 (effectiveCurrent ? effectiveCurrent->size() : 0)은 effectiveCurrent.size()로 바뀌었고, effectiveCurrent->at(i)는 effectiveCurrent[i]로 바뀌었습니다. 주석에는 tearDownRenderersAfterSlotChange가 ComposedTreeIterator를 통해 assignedNodesForSlot으로 재진입할 수 있고, 이 과정에서 WeakHashMap rehash가 발생할 수 있다는 설명이 추가되었습니다. 부수적으로, teardown 도중 rehash를 강제로 유발하도록 WeakHashMap의 연산 카운터와 null 키를 가진 ghost entry를 의도적으로 조작하는 회귀 테스트와 그 expectation 파일도 함께 추가되었습니다.
컨테이너 내부 저장소를 가리키는 raw pointer를, 그 컨테이너를 재진입시키고 rehash까지 유발할 수 있는 호출 구간 너머까지 보유하는 패턴.
Background
Manual slot assignment.
attachShadow({ slotAssignment: 'manual' })로 생성된 shadow root는 slot= 속성을 통한 매칭을 사용하지 않습니다. 대신 스크립트가 HTMLSlotElement.assign(...nodes)를 명시적으로 호출해, 특정 <slot>이 렌더링할 host 자식 노드를 지정합니다. ManualSlotAssignment는 이 모드를 구현하는 SlotAssignment의 서브클래스입니다.
Slot and m_slots.
SlotAssignment.h에 따르면, 각 slot의 해석된 assignment는 struct Slot { Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>> cachedAssignment; uint64_t cachedVersion; }에 캐시되고, 이 Slot 값들은 WeakHashMap<HTMLSlotElement, Slot, WeakPtrImplWithEventTargetData> m_slots의 value 타입으로서 값으로 저장됩니다. 따라서 Slot이나 그 cachedAssignment를 가리키는 pointer는 곧 해시 테이블의 bucket 할당 영역을 가리키는 pointer가 됩니다.
assignedNodesForSlot.
SlotAssignment에 선언된 virtual 함수는 const Vector<WeakPtr<Node, WeakPtrImplWithEventTargetData>>*를 반환합니다. 이는 복사본이 아니라 빌려온 raw pointer입니다.
WeakHashMap amortized cleanup.
WeakHashMap의 key는 weak reference이며, key 객체가 수거되면 null이 됩니다. 매번 수거될 때마다 반응하는 대신, 연산 횟수를 세다가 주기적으로 null 키 entry를 정리(sweep)하고 rehash를 수행합니다. rehash는 새 bucket 배열을 할당하고 기존 배열을 해제하는 동작입니다. ensure()처럼 삽입 형태의 연산이 이 cleanup이 발생할 수 있는 지점입니다. 이런 설계를 사용하는 이유는, weak table이 entry마다 collector callback을 둘 필요 없이, 임계값을 넘는 특정 연산 시점에 비용을 일괄 지불하도록 하기 위해서입니다.
WeakPtr equality.
두 WeakPtr를 비교하면 각각 WeakPtrImpl control block을 거쳐 raw 객체 pointer를 얻어옵니다. 따라서 이 비교는 WeakPtr 슬롯 자체를 넘어선 메모리까지 읽게 됩니다.
Composed tree and render-tree teardown.
composed tree는 host 자식 노드들이 shadow slot에 투영된 평탄화된 view이며, ComposedTreeIterator가 이를 순회합니다. slot의 assignment가 변경되면 RenderTreeUpdater::tearDownRenderersAfterSlotChange가 영향받은 composed 서브트리를 순회하며, 더 이상 slot에 배정되지 않은 노드의 renderer를 파괴합니다. 이 순회 과정에서 slot 엘리먼트의 assigned node를 참조하게 됩니다.
slotchange.
slot은 assigned-node 목록이 실제로 변경될 때 slotchange를 발생시킵니다. 그래서 slotManualAssignmentDidChange는 enqueueSlotChangeEvent를 호출하기 전에 이전 effective assignment와 현재 effective assignment를 비교합니다.
Analysis
이 취약점은 object-lifetime UAF가 아니라 컨테이너 무효화로 인한 UAF에 해당합니다. HTMLSlotElement와 ShadowRoot는 모두 여전히 살아 있는 상태이며, 해제되는 대상은 Slot 값을 담고 있던 WeakHashMap의 bucket 배열입니다.
slotManualAssignmentDidChange re-entrant path
───────────────────────────── ───────────────
++m_slottableVersion
effectiveCurrent = &bucket[k].cachedAssignment ──┐
tearDownRenderersAfterSlotChange() ──────────────┤
ComposedTreeIterator visits otherSlot │
HTMLSlotElement::assignedNodes() │
assignedNodesForSlot() -> m_slots.ensure() │
amortized cleanup crosses threshold │
sweep null keys, rehash │
free(old bucket array) ──────────────┤
scheduleSlotChangeEventIfNeeded() │
effectiveCurrent->size() ◄────────────────┘ UAF read
effectivePrevious[i] != effectiveCurrent->at(i)
위 시퀀스에서 함수는 현재 effective assignment를 raw pointer 형태로 스냅샷한 뒤, render-tree teardown을 유발합니다. 커밋 메시지에 따르면 이 teardown의 composed-tree 순회는 형제 slot 엘리먼트까지 도달하며, HTMLSlotElement::assignedNodes를 호출하게 됩니다. 이 호출은 ManualSlotAssignment::assignedNodesForSlot으로 재진입을 유발합니다. 이 재진입 경로는 삽입 형태의 연산인 m_slots.ensure(...)를 수행하는데, 이때 연산 카운터가 amortized-cleanup 임계값을 넘으면 테이블은 null 키 entry를 정리하고 rehash를 수행합니다. 이 과정에서 새 bucket 배열이 할당되고 기존 배열은 해제됩니다.
이 시점에서 stale 상태가 된 effectiveCurrent는 이미 해제된 메모리를 가리키게 됩니다. effectiveCurrent->size()는 해제된 할당 영역에서 Vector header를 읽어오고, effectiveCurrent->at(i)는 역시 해제된 메모리에서 읽어온 buffer pointer를 통해 원소를 읽습니다. WeakPtr 비교는 WeakPtrImpl을 거쳐 해석되므로, effectivePrevious[i] != effectiveCurrent->at(i) 비교문은 해제된 영역에서 가져온 pointer를 역참조하게 됩니다. 이번 fix는 effectiveCurrent를 effectivePrevious와 구조적으로 동일한, 소유권을 가진 로컬 Vector로 바꿈으로써 이 invariant를 복원합니다. 그 결과 teardown 구간 내내 m_slots를 가리키는 pointer가 존재하지 않게 됩니다.
이번 회귀 테스트는 사실상 최소화된 PoC이며, 각 단계는 모두 평범한 shadow-DOM 스크립트로 구성되어 있습니다.
host.attachShadow({ mode: 'open', slotAssignment: 'manual' })는 비어 있는m_slots를 가진ManualSlotAssignment를 설치합니다.targetSlot.assign(childA)와otherSlot.assign(childB)는m_slots에 살아 있는 entry 두 개를 만들며, 각각 채워진cachedAssignment를 가진Slot을 보유하게 됩니다.- ghost 루프 —
shadowRoot.appendChild(document.createElement('slot')).remove()— 는 N개의 버려질 slot 엘리먼트를 삽입하고 곧바로 분리합니다. 각 삽입은m_slots에 entry를 하나 남기는데,GCController.collect()가 실행되면 이 entry들의HTMLSlotElementweak key가 null이 됩니다. 그 결과 테이블에는 sweep 대상 garbage가 쌓이게 되고, 이후 cleanup pass가 no-op에 그치지 않고 실제로 rehash를 수행하게 됩니다. document.body.offsetHeight는 layout을 강제로 발생시켜 각 host가 renderer를 갖도록 만듭니다. 이 덕분에tearDownRenderersAfterSlotChange가 실제로 composed-tree 순회를 수행하게 됩니다.- pump 루프는
assignedNodesForSlot을 반복 호출하며 연산 카운터를 임계값 바로 아래까지 끌어올립니다. 정확한 임계 도달 지점은 구현에 따라 달라지기 때문에, 테스트는 임계값 부근의 pump 값 구간을 brute-force로 시도합니다(주석에는 대략2 * table size로 기록되어 있습니다). targetSlot.assign(newSpan)은slotManualAssignmentDidChange에 진입하여effectiveCurrent를 raw pointer로 취득하고 renderer teardown을 유발합니다. 이때 재진입한ensure호출이 카운터를 임계값 너머로 밀어 올려 sweep, rehash, free가 연쇄적으로 발생합니다.- 제어 흐름이 복귀하면
scheduleSlotChangeEventIfNeeded가 이미 해제된 할당 영역을 읽게 됩니다.
crash를 넘어선 확장 가능성을 고려하면, 공격자는 rehash와 read 사이에서 해제된 bucket 배열을 재점유해야 합니다. free와 read는 teardown 순회의 나머지 부분으로만 분리되어 있으므로, 재점유는 teardown 자체가 수행하는 할당에서 나오거나, 사전에 준비된 동일 size-class free-list 히트를 통해서만 가능할 것으로 보입니다. 만약 공격자가 원하는 바이트로 재점유에 성공한다면, cachedAssignment offset에 위치한 조작된 Vector header를 통해 controlled buffer pointer와 length를 얻을 가능성이 있습니다. 이어지는 WeakPtr 비교는 이 controlled pointer를 역참조해 WeakPtrImpl 객체 pointer를 가져오게 되는데, 이 단계는 임의 주소를 탐침하는 용도로 이어질 가능성이 있습니다. 다만 스크립트에서 관찰 가능한 결과는 slot.enqueueSlotChangeEvent()가 발생하는지 여부뿐이므로, 현실적으로 가능한 최선의 결과는 직접적인 정보 노출 채널이라기보다 주소 탐침 내지 비교 oracle 수준일 것으로 보입니다. 바이트를 추출하려면 이 전체 시퀀스를 비트 단위로 반복해야 합니다. 이 경로에서 write primitive는 드러나지 않습니다 — scheduleSlotChangeEventIfNeeded는 읽고 비교하는 동작만 수행합니다.
이 취약점은 WebContent process 내부의 메모리 안전성을 약화시킵니다. 보안 모델은 DOM 내부 캐시가 하나의 DOM mutation이 진행되는 동안 유효하게 유지되며, mutation으로 유발된 render-tree 업데이트가 그 mutation을 수행 중인 코드가 여전히 참조 중인 상태를 무효화하지 않는다는 전제를 두고 있습니다. fix 이전에는 manual slot reassignment가 amortized-cleanup 임계값을 넘어설 때마다 이 전제가 깨졌습니다. 이 전체 시퀀스는 신뢰되지 않은 스크립트만으로 유발되므로, 공격자는 free의 타이밍과 그 주변 heap 상태를 모두 제어할 수 있습니다. shadow DOM slot assignment와 render-tree teardown은 모두 renderer 안에서 실행되므로, 이 취약점 단독으로는 sandbox 경계를 넘지 못하며 별도의 escape가 필요합니다.
Insight
가장 눈여겨볼 지점은 동일한 virtual 시그니처 뒤에 있는 두 SlotAssignment 서브클래스 사이의 비대칭성입니다. assignedNodesForSlot은 두 구현 모두에서 빌려온 const Vector<...>*를 반환하지만, NamedSlotAssignment는 slot을 HashMap<AtomString, std::unique_ptr<Slot>>으로 저장합니다. unique_ptr 간접 참조 덕분에 Slot과 그 Vector는 안정된 heap 할당 영역에 위치하게 되고, rehash가 일어나도 이동하지 않습니다. 반면 ManualSlotAssignment는 Slot을 WeakHashMap의 value 타입으로 인라인 저장하므로, 동일한 borrowed-pointer 패턴이 pointer-unstable해집니다. interior pointer를 노출하는 인터페이스는 lifetime 계약을 각 구현체에 떠넘기게 되고, 이번 경우에는 한 구현체가 다른 구현체가 보장하던 조건을 조용히 위반한 셈입니다. 부차적으로 두 가지가 더 눈에 띕니다. 하나는 WeakHashMap의 amortized cleanup이 겉보기에 무해한 ensure() 호출을 테이블 재할당 연산으로 만든다는 점입니다. 이는 "명시적 제거만이 무효화를 유발한다"는 일반적인 사고방식이 여기서는 성립하지 않는다는 뜻입니다. 다른 하나는 exploit 조건이 테이블 안에 garbage를 요구한다는 점입니다. ghost-slot 루프는 오로지 sweep이 수거할 대상을 만들어주기 위해 존재하며, 이는 이 버그가 단순한 re-entrancy뿐 아니라 weak key에 대한 GC 압력도 함께 필요로 한다는 의미입니다.
Audit directions
-
컨테이너 내부 저장소를 가리키는 raw pointer 또는 reference를 반환하는 accessor이며, 그 반환값이 해당 컨테이너에 삽입되거나 재할당을 유발할 수 있는 호출 구간 너머까지 보유되는 패턴입니다. 여기서 지켜야 할 invariant는 hash table이나 vector의 interior pointer는 그 테이블을 재할당할 수 있는 다음 연산 전까지만 유효하다는 것인데, 이 무효화를 유발하는 연산이 호출 지점에서 여러 프레임 아래에 있고 겉으로는 read-only처럼 보이는 경우가 많아 지키기 어렵습니다. 좁게 보면:
Source/WebCore/dom과Source/WebCore/html에서 반환 타입이const Vector<...>*/T*이면서 그 출처가HashMap/WeakHashMap의 value인 메서드를 검색하고, 각 호출자에서 style이나 render-tree machinery로 들어가는 중간 호출이 있는지 확인해야 합니다.SlotAssignment::assignedNodesForSlot이 이 패턴의 전형이며,ShadowRoot/HTMLSlotElement의 accessor들이 바로 인접한 영역입니다. 넓게 보면: 크기가 변할 수 있는 저장소를 가리키는 빌려온 handle이라면 어디서나 동일한 부류의 문제가 나타납니다 — 캐시된Vector::data()pointer,add()호출 구간 너머로 유지되는HashMapiterator, mutation 이전에 캡처된StringImpl문자 buffer 등이 그 예입니다. WebCore가 layout, style resolution, event dispatch로 들어가는 호출 이전에 이런 handle을 캐시해두는 지점을 모두 점검할 필요가 있습니다. 가장 넓게 보면: 이는 컨테이너 저장소가 불안정한 언어라면 어디서나 나타나는 고전적인 iterator/reference 무효화 부류에 해당합니다 — C++의std::unordered_maprehash, Go map의 growth, Java의ConcurrentModificationException, 그리고 Rust의 borrow checker가 애초에 막고자 하는 바로 그 부류입니다. 매칭 단서(좁은 범위): 컨테이너 조회로부터 대입된 로컬 raw pointer가 있고, 그 대입 지점과 마지막 역참조 지점 사이에 사소하지 않은 호출이 하나라도 존재하는 경우입니다. 매칭 단서(넓은 범위): 출처가 크기 가변 buffer인 빌려온 handle이, 감사자가 allocation-free임을 증명할 수 없는 호출 경계를 넘어서는 경우입니다. 매칭 단서(가장 넓은 범위): "borrow와 use 사이에, owner를 확장시키거나 압축시키는 코드 경로에 도달할 수 있는가?"를 자문했을 때, 그 답을 얻기 위해 호출 스택을 세 단계 아래까지 파고들어야 한다면 일단 hit으로 간주하고 반증되기 전까지는 그렇게 취급해야 합니다. -
숨겨진 self-maintenance를 가진 컨테이너입니다. 명목상 읽기에 가깝거나 단순 삽입으로 보이는 연산이 실제로는 sweep-and-rehash를 유발하고, 그 결과 외부에 남아 있던 reference가 무효화됩니다. 위험한 지점은 invalidation이 일어나는 시점이 호출부에서는 전혀 보이지 않는다는 데 있습니다. 게다가 이 시점은 내부 counter와 그동안 쌓인 garbage의 양에 따라 확률적으로 결정됩니다. 그래서 load가 낮을 때는 버그가 잠복해 있다가, attacker가 의도적으로 pressure를 가하는 순간에만 발현됩니다. 좁게 보면:
Source/WebCore안에서WeakHashMap<과WeakHashSet<선언 중 value type이 non-pointer struct로 inline 저장되는 경우를 검색해야 합니다 (ManualSlotAssignment::m_slots가Slot을 값으로 들고 있는 것이 그 예입니다). 이런 테이블에 대한 모든.ensure(/.add(/.get(호출부를 점검하면서, value에 대한 pointer나 reference를 계속 들고 있는 caller가 있는지 확인해야 합니다. 넓게 보면: 같은 형태가 tree 안의 다른 lazily-compacting 구조에도 적용됩니다. lookup 시점에 evict하는 cache, GC가 sweep하는 side table, refcount를 sweep하는 registry 등이 해당하며, amortized 또는 deferred라고 문서화되어 있으면서도 실제로는 reallocate하는 연산을 찾아야 합니다. 가장 넓게 보면: cleanup을 미루는 컨테이너는, caller가 저렴하다고 여기는 연산 도중에 그 cleanup을 수행할 수 있고, 그 순간 남아 있던 모든 reference가 무효화됩니다. 이 원리는 Java의WeakHashMap.expungeStaleEntries, Python의WeakValueDictionarycallback, Lua/JSWeakMap구현의 ephemeron table, Go의 incremental map growth에도 동일하게 성립합니다. Match tell (narrow): inline-value로 구성된 weak table에서 value가 address로 넘겨지는 경우입니다. Match tell (wider): 삽입과 cleanup이 같은 진입점을 공유하는 컨테이너 API입니다. Match tell (widest): "이 컨테이너가 죽은 entry를 대신해서 작업을 수행하는 경우가 있는가, 있다면 언제인가"라는 질문을 항상 던져야 합니다. 답이 "다음 연산이 뭐가 됐든 그때"라면, 그 컨테이너로부터 빌려온 모든 reference는 의심 대상입니다. -
상태를 스냅샷으로 저장한 뒤 style이나 render-tree update를 호출하고, 그 후에 호출 이전의 스냅샷을 다시 사용하는 DOM mutation handler입니다. 여기서 문제는 update 경로가 그 스냅샷을 만들어낸 DOM 구조 자체로 다시 진입할 수 있다는 점입니다. 지켜야 할 invariant는 render-tree teardown과 style resolution이 leaf call이 아니라 DOM 데이터 구조로의 re-entrancy boundary라는 것입니다. 좁게 보면:
Source/WebCore/rendering/updating/RenderTreeUpdater.cpp안에 있는RenderTreeUpdater::tearDownRenderersAfterSlotChange를 비롯한 teardown/update 진입점들의 caller를 추적해야 합니다. 각 caller마다, 호출 이전에 계산된 local 변수가 호출 이후에도 읽히는지 확인해야 합니다.slotManualAssignmentDidChange가 이번에 수정된 사례이며, 그 형제 handler인didRemoveManuallyAssignedNode,slotFallbackDidChange,willRemoveAssignedNode,didRemoveAllChildrenOfShadowHost가 바로 다음 점검 대상입니다. 넓게 보면: 같은 클래스의 문제는 상태를 caching한 뒤ComposedTreeIterator, forced layout,Style::TreeResolver, event dispatch, script callback에 도달할 수 있는 무언가를 호출하는 모든 WebCore 메서드에 적용됩니다. mutation 도중에invalidateStyleAndRenderersForSubtree를 호출하거나 forced layout을 일으키는 메서드를 찾아, 그 호출 이후의 읽기 동작을 점검해야 합니다. 가장 넓게 보면: 이는 callback 또는 reentrancy에 의해 무효화되는 cached state라는 일반적인 클래스에 해당하며, mutation notification이 mutation 대상 구조로 동기적으로 다시 진입할 수 있는 모든 시스템에 적용됩니다. DOM observer의 전달, measure callback을 발생시키는 UI framework의 layout pass, 그리고 자신이 쓰고 있는 테이블로 다시 진입하는 database trigger가 여기에 해당합니다. Match tell (narrow): teardown/update 호출 이전에 정의되고 그 이후에도 마지막으로 사용되는 local pointer, index, 또는 iterator입니다. Match tell (wider): layout/style/script로의 호출 양쪽에서 동일한 object graph가 등장하는 함수입니다. Match tell (widest): "다시 나를 호출할 수 있는 subsystem에 제어권을 넘긴 이후에는, 그 이전에 계산해둔 shared mutable state에 대한 어떤 정보도 더 이상 신뢰할 수 없다"는 원칙입니다.