[5] LazyLoadVideoObserver use-after-free after document teardown
A document at refcount zero is still alive — and still cloneable.
Free와 use 사이의 window에 스크립트가 개입할 여지가 없기 때문에 Medium으로 평가됩니다. Free와 use가 한 프레임 안에서 연속된 두 statement로 이루어져 있어 attacker가 그 사이에 spray를 끼워 넣을 수 없고, 객체 자체도 TZone으로 type-partition되어 있습니다. 다만 trigger 자체는 일반적인 웹 콘텐츠만으로도 항상 동일하게 재현되며, 이 점이 Low 등급으로 내려가지 않는 이유입니다.
C++에서는 이름 없는 temporary가 세미콜론 시점에 파괴됩니다. 그 결과 temporary smart pointer를 거쳐 빌려온 reference는 컴파일러가 별도로 지시받지 않는 한 경고 없이 넘어가는 lifetime 위험 요소가 됩니다. WebCore에서는 여기에 한 겹의 위험이 더해집니다. Document는 refcount 기반이지만, node는 별도의 카운팅 메커니즘으로 이를 살아있게 유지합니다. 그래서 detached document의 마지막 strong reference가 해제되면 객체를 삭제하는 대신 teardown 경로가 실행되고, 이후 document는 refcount 0인 상태로 계속 존재하면서 나중에 다시 참조될 수 있습니다. Lazy <video> 로딩은 Document가 소유한 helper에 의존하는데, 여기서 필요한 invariant는 그 owner를 지키는 strong reference가 owner가 넘겨주는 모든 raw reference보다 오래 살아있어야 한다는 것입니다.
관전 포인트: 페이지가 detached document 안에서 <video>를 생성하고 refcount를 0으로 떨어뜨린 뒤, 이를 보호하던 유일한 reference가 해제된 상태에서 cloneNode()가 Document 소유 helper를 읽고 쓰게 만들 수 있습니다.
Document가 LazyLoadVideoObserver를 소유하고 있는 상황에서, Document의 조기 소멸이 LazyLoadVideoObserver::observe() 내 LazyLoadVideoObserver의 use-after-free로 이어질 수 있던 케이스가 수정되었습니다. lazyLoadVideoObserver() getter에는 LIFETIME_BOUND가 추가되었는데, 이 attribute가 있었다면 애초에 안전하지 않은 코드가 걸러졌을 것입니다. Node::cloneNode는 Document를 보호하도록 수정되었고, Document::m_lazyLoadObserver가 nullable이라는 점을 감안해 observer도 추가 안전장치로 CheckedPtr에 담기게 되었습니다. 이번 코드 변경과 테스트는 Kristian Monsen의 초기 작업을 거의 그대로 기반으로 합니다.
Source/WebCore/html/LazyLoadVideoObserver.cpp
Source/WebCore/html/LazyLoadVideoObserver.h
Source/WebCore/dom/Document.h
Source/WebCore/dom/Node.cpp
LayoutTests/fast/dom/lazy-video-clone-after-document-teardown-crash.html
Patch Details
LazyLoadVideoObserver::observe()가 다시 작성되었습니다. 기존의 auto& observer = protect(element.document())->lazyLoadVideoObserver();는 Document 소유 storage에 raw reference를 바인딩하면서도, 이를 보호하는 Ref<Document>는 세미콜론에서 죽는 temporary에 불과했습니다. 수정된 함수는 Ref document = element.document();를 함수 본문 전체 동안 살아있는 named local로 끌어올리고, observer 자체도 protect(document->lazyLoadVideoObserver())로 감싼 뒤(인자가 lvalue reference이고 클래스가 이제 CanMakeCheckedPtr이므로 CheckedRef가 됩니다) ->intersectionObserver(document)를 호출합니다.
나머지 세 변경은 fix라기보다 hardening에 가깝습니다. Document.h에서는 getter에 LIFETIME_BOUND를 붙여, 기존 observe()가 사용했던 바로 그 패턴을 컴파일러가 잡아낼 수 있도록 했습니다. Node::cloneNode는 cloneNodeInternal(document(), ...)를 cloneNodeInternal(protect(document()), ...)로 바꿔, clone 작업 전체 동안 refcount를 고정시킵니다. 그리고 LazyLoadVideoObserver.h는 클래스를 final이자 CanMakeCheckedPtr로 만들고 WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR를 추가했으며, RefPtr<IntersectionObserver> m_observer를 const RefPtr<IntersectionObserver> m_observer로 바꿔 단순 대입 대신 lazyInitialize(...)를 통해 한 번만 초기화되도록 했습니다.
Temporary owning smart pointer를 거쳐 도달한 sub-object에 long-lived reference를 바인딩하는 패턴으로, statement가 끝나는 시점에 owner가 파괴되는데도 reference는 여전히 사용 중인 상태로 남습니다.
Background
Smart pointer와 protect(...). Ref<T>/RefPtr<T>는 WebKit의 strong reference-counting smart pointer로, 마지막 하나가 해제될 때 객체가 파괴됩니다. protect(x)는 x를 적절한 smart pointer로 감쌉니다. reference-counted 타입에는 Ref/RefPtr이, CanMakeCheckedPtr 타입에는 CheckedRef/CheckedPtr이 쓰입니다. RefPtr<T>에 값을 대입하면 새 값을 저장하기 전에 기존에 들고 있던 값이 먼저 해제됩니다.
Temporary의 lifetime. expression 내부에서 생성된 이름 없는 temporary는, named reference에 직접 바인딩되지 않는 한 full-expression이 끝나는 시점, 즉 세미콜론에서 파괴됩니다.
LIFETIME_BOUND. 반환된 reference의 유효성이 그 메서드가 호출된 대상 객체의 lifetime에 묶여 있음을 나타내는 Clang attribute입니다. 이런 반환값이 temporary receiver보다 오래 살아남으면 컴파일러가 경고를 발생시킵니다. 이 attribute 하나로 특정 버그 클래스 전체를 거의 비용 없이, 컴파일러 수준에서 막을 수 있습니다.
CanMakeCheckedPtr / CheckedPtr / CheckedRef. 객체별로 outstanding pointer 개수를 세는 non-owning pointer/reference 타입입니다. checked pointer가 남아있는 상태에서 객체가 파괴되면 조용히 dangling pointer가 남는 대신 release assertion이 발생합니다. WTF_MAKE_TZONE_ALLOCATED는 별도로 인스턴스를 type-partition된 heap에서 할당하기 때문에, 서로 무관한 타입의 객체가 통상적으로 같은 slot에 놓이지 않습니다. lazyInitialize(member, value)는 const로 선언된 member를 최초 사용 시점에 정확히 한 번만 대입하는 관용구입니다.
WebCore에서의 Document lifetime. Document는 refcount 기반이지만, node들도 별도의 node-referencing count로 이를 살아있게 유지합니다. node가 여전히 document를 참조하는 상태에서 마지막 strong reference가 해제되면, 객체를 삭제하는 대신 last-ref teardown 경로가 실행되며 Document 소유 상태가 정리됩니다. 이후 document는 refcount 0으로 계속 존재하며 나중에 다시 참조될 수 있습니다.
Lazy-video 경로. IntersectionObserver는 Intersection Observer API에 대한 WebCore의 구현체입니다. Lazy video 로딩은 Document당 하나의 내부 IntersectionObserver를 사용하며, 이는 LazyLoadVideoObserver::intersectionObserver()가 처음 사용될 때 생성합니다. LazyLoadVideoObserver 자체는 Document가 소유한 helper로, HTMLVideoElement::create()와 그 destructor에서 구동됩니다. Node::cloneNode / cloneNodeInternal은 DOM clone 경로로, element를 clone하면 같은 document 안에 새 element가 만들어집니다. Document.parseHTMLUnsafe()는 문자열을 파싱해 어떤 frame에도 붙어있지 않은 새 document를 생성합니다.
Analysis
Root cause는 temporary를 거쳐 빌려온 sub-object에 대한 dangling reference입니다. protect(element.document())는 temporary Ref<Document>를 만들어냅니다. 이때 호출되는 getter는 그 document가 소유한 storage를 가리키는 LazyLoadVideoObserver&를 반환하는데, 이는 새로 추가된 LIFETIME_BOUND annotation이 정확히 선언하는 상황과 같습니다. 이 temporary는 세미콜론에서 파괴되므로, named reference인 observer는 자신의 owner를 지키던 유일한 strong reference보다 오래 살아남게 되고, 다음 statement는 바로 그 storage를 건드리게 됩니다.
refcount state
-------- --------------------------------------------------
0 detached Document, alive only via node references
1 protect(element.document()) <- temporary Ref
1 lazyLoadVideoObserver() -> LazyLoadVideoObserver&
0 ';' temporary released -> last-ref teardown re-runs
0 observer.intersectionObserver(...) <- FAILURE WINDOW
평상시에는 document가 다른 strong reference들도 가지고 있기 때문에 temporary를 해제해도 무해하며, 버그는 잠복 상태로만 남습니다. 위 타임라인은 테스트가 강제로 만들어내는 상태로, temporary가 유일한 strong reference를 쥐고 있는 경우에 해당합니다. refcount 0에서 다시 획득했다가 다시 해제하는 과정은 last-ref teardown 경로를 재실행시킵니다. "freed memory" 프레이밍이 성립하려면 이 teardown이 Document 소유의 LazyLoadVideoObserver를 파괴하거나 clear해야 합니다. 제공된 context에는 Document::removedLastRef()나 해당 member 선언이 포함되어 있지 않아, 이 단계는 fix의 형태로부터 추론한 것입니다. 함수 전체를 아우르는 Ref<Document>와 observer를 감싸는 CheckedRef를 함께 추가한다는 것은, observer가 owner와 함께 죽을 수 있는 경우에 작성하는 코드이며, 테스트 이름 또한 이를 뒷받침합니다. 이 가정이 성립한다면, 이어지는 호출은 해제된 메모리에서 m_observer를 읽어오게 되고, null 분기에서는 패치 이전의 m_observer = observer.returnValue().ptr();가 실행되는데, 이 RefPtr 대입은 해제된 slot에서 읽어온 값을 dereference함과 동시에 새 pointer를 그 slot에 다시 저장하는 동작입니다. 만약 teardown이 observer를 해제하지 않고 stale 상태로만 남겨둔다면, 같은 statement들은 freed memory가 아니라 logically-dead 상태에 대한 use에 해당합니다. Node::cloneNode도 한 단계 위에서 같은 형태의 문제를 안고 있었습니다. raw Document&를 그대로 넘겼기 때문에, clone 과정 더 깊은 곳에서 만들어지는 어떤 transient Ref<Document>든 동일한 0→1→0 round trip을 만들어낼 수 있었습니다.
이 경로는 일반적인 웹 콘텐츠에서 도달 가능하며, 추가된 layout test가 동작하는 trigger입니다.
Document.parseHTMLUnsafe("")가 frame 없는 document를 생성합니다.createElementNS(..., "video")가 그 안에HTMLVideoElement를 할당하고, 스크립트에는 element만 반환하므로 document의 JS wrapper는 도달 불가능한 상태가 됩니다.await setTimeout(0)으로 현재 task가 끝나면서 C++ stack에서 document를 보호하는 대상이 사라지고,GCController.collect()가 wrapper를 수거하면서 마지막 strong reference가 해제됩니다. 다만 video node가 여전히 document를 참조하고 있어 document는 refcount 0으로 살아남습니다.video.cloneNode(false)가Node::cloneNode에 진입해HTMLVideoElement::create()→LazyLoadVideoObserver::observe()로 이어집니다.protect(element.document())가 유일한 strong reference를 다시 획득하고, getter가 observer reference를 돌려준 뒤 temporary는 세미콜론에서 해제됩니다.- 다음 statement가 바로 그 storage를 조작합니다.
crash를 넘어선 확장이 가능하려면 여러 조건이 동시에 성립해야 합니다. 해제된 slot이 attacker가 영향을 줄 수 있는 바이트로 재사용되어야 하는데, 이는 TZone type-partitioning에 의해 제약을 받습니다. 또한 재사용이 free→use window 안에서 일어나야 하는데, 이 구간에는 JavaScript가 전혀 실행되지 않습니다. free와 use가 같은 프레임 안에서 연속된 statement이기 때문에 attacker가 그 사이에 spray를 끼워 넣을 수 없고, teardown 자체나 IntersectionObserver::create()가 수행하는 할당이 해제된 slot에 우연히 들어맞기를 기대하는 수밖에 없습니다. 마지막으로 m_observer offset에 재사용된 내용이 attacker가 제어하는 pointer여야 합니다. 이 세 조건이 모두 성립한다면, RefPtr 대입이 stale 값을 해제하는 과정에서 controlled-pointer refcount decrement로 이어질 가능성이 있고, non-null 경로에서는 intersectionObserver->observe(element)가 controlled-pointer dereference와 virtual call로 이어질 가능성도 있습니다. 이 조건들이 성립하지 않는다면, 관찰되는 결과는 항상 동일하게 재현되는 renderer crash입니다.
이 취약점은 WebContent process 내부의 메모리 안전성을 약화시킵니다. 여기서 걸려 있는 전제는 Document 소유 helper가 WebCore 코드가 그것을 참조하는 동안에는 계속 유효해야 한다는 것입니다. 패치 이전에는 페이지가 순수하게 스크립트만으로 detached document를 post-teardown, refcount-0 상태로 몰아넣은 뒤, 이를 보호하던 reference가 해제된 이후에도 WebCore가 그 helper를 읽고 쓰게 만들 수 있었습니다. 여기서 다루는 내용은 모두 renderer 측 문제이며, renderer 침해를 넘어서는 무언가를 위해서는 별도의 escape가 여전히 필요합니다.
Takeaway: lazily-created owned member를 가리키는 T&를 반환하는 getter에 LIFETIME_BOUND를 붙이는 것은 이 버그 클래스 전체를 컴파일러 수준에서 막는 방법이며, WebCore에는 아직 이 attribute가 붙지 않은 Document/TreeScope getter가 다수 존재합니다. 이런 getter들에 annotation을 추가하는 작업은 기계적이면서도 효율이 높은 hardening 작업입니다.
Audit directions
-
Temporary owning smart pointer를 거쳐 도달한 sub-object에 raw reference나 pointer가 바인딩되는 패턴으로, owner가 세미콜론에서 파괴되는 동안 그 borrow는 여전히 살아있는 경우입니다. 좁게 보면: WebCore 전체에서
auto& x = protect(...)->foo();/auto* x = protect(...)->foo();/Ref { ... }->bar()형태의 선언 중 결과가 즉시 소모되지 않고 저장되는 경우를 검색합니다. 조금 넓게 보면: temporary에서 빌려오는 동일한 형태가 다른 곳에도 나타날 수 있습니다. temporaryString이나Vector로부터 만들어진StringView/std::span, 값으로 반환된 container에 대한 iterator, temporaryUniqueRef/std::unique_ptr에 대한.get()등이며, 인자 목록에 값으로 반환하는 호출을 포함하면서T&나 view 타입을 반환하는 함수를 찾아보면 됩니다. 가장 넓게 보면: manual borrow lifetime을 가지고 borrow checker가 없는 모든 언어에서 나타나는, temporary에 대한 dangling reference라는 일반적인 버그 클래스 전체가 해당됩니다. 코드 리뷰에서의 단서는, expression 왼쪽에서&/*바인딩이 이루어지는데 그 expression 안에 이름 없는protect(,Ref {,RefPtr {가 포함되어 있는 경우입니다. 즉 full-expression보다 오래 살아남는 이름이, 그렇지 못한 무언가가 소유한 storage로부터 초기화되는 패턴입니다. -
Auxiliary liveness count로 refcount 0을 정상적으로 넘기는 객체들. 마지막 strong reference가 해제되는 시점에 삭제가 아니라 teardown side effect가 실행되는 구조이며, 이 경우 일시적인 strong reference조차 0→1→0의 파괴적 round trip이 됩니다. 좁게 보면, potentially-detached document 주변에서 짧게 생존하는
Ref<Document>/protect(document())값을 생성하는 WebCore 호출 지점을 점검할 필요가 있습니다. 우선 element 생성자/소멸자에서 도달하는 다른 Document 소유 lazy observer들, 즉LazyLoadImageObserver,LazyLoadModelObserver,ContentVisibilityDocumentState부터 확인해야 합니다. 또한 bareDocument&를 ref를 취하는 helper로 넘기는Node/ContainerNode메서드들도 함께 살펴봐야 합니다. 조금 더 넓게 보면,removedLastRef()를 override하는 코드와RefCounted에 별도의 referencing counter를 짝지은 클래스들을 grep으로 찾아, caller의 protection이 named local이 아니라 temporary인 경우가 있는지 확인해야 합니다. 가장 넓은 범위에서 보면, release-to-zero 시점에 어떤 동작이 실행되는 구조라면 모든 temporary strong reference는 그 동작을 호출하는 셈이 됩니다. 이 형태는 C++ intrusive refcount의 custom deleter 및 resurrection, Objective-C-deallocresurrection,Dropside effect를 가진Arc에서도 동일하게 나타납니다. -
Nullable owned member에 대해 raw
T&를 반환하는 lazy getter와, 재할당 시 이전에 반환된 raw pointer를 무효화시키는 mutable member의 조합. 좁게 보면,Source/WebCore/dom/Document.h와TreeScope.h에서 lazily-created owned member에 대해T&를 반환하는 accessor들을 검토하고, 각각이LIFETIME_BOUND를 지니고 있는지 확인해야 합니다. 이번 패치에서는lazyLoadVideoObserver()에만 이 annotation이 붙어 있었습니다. 조금 더 넓게 보면, WebCore 전반에서 accessor가 raw pointer로 내어주는 member가 같은 클래스 안 다른 곳에서 나중에 재할당되는 패턴을 찾아야 합니다. 여기서 나타나는const RefPtr<IntersectionObserver> m_observer+lazyInitialize조합이 바로 의도된 형태입니다. 가장 넓은 범위에서 보면, "owner의 mutation domain을 벗어나는 borrow"라는 일반적인 클래스에 해당합니다.std::map::operator[]가 rehash를 거치며 반환한 reference나, Java collection view가 structural modification을 거치며 남기는 참조도 같은 문제입니다. 코드 리뷰 시 눈여겨봐야 할 신호는, body가if (!m_foo) m_foo = makeUnique<T>()형태인 non-constT& foo()이거나,m_x가 클래스 안 여러 곳에서 할당되는 상황에서m_x.get()을 반환하는 accessor입니다.
Third-party cookies not blocked for DedicatedWorker WebSocket connections에 대한 번역을 진행하겠습니다.
Medium severity이며, memory-safety 문제가 아니라 privacy 문제에 해당합니다. 임의 페이지에 심어진 tracker가 자신의 beacon을 worker로 옮기기만 하면 cross-site cookie를 다시 받아올 수 있습니다. Memory corruption도 없고 process 경계를 넘는 것도 아니지만, 아무 전제조건 없이 일반적인 web content에서 도달 가능하다는 점이 문제입니다.
Intelligent Tracking Prevention은 현재 페이지의 first party가 아닌 도메인으로 향하는 요청에서 cookie를 차단합니다. 이렇게 하면 third-party resource는 first-party context에서 설정된 식별자를 돌려받지 못하게 됩니다. 이 판단은 요청이 발생하는 지점과 가까운 network process에서 이루어지며, 요청의 originating context를 입력값으로 사용할 수 있다는 전제에 의존합니다. DedicatedWorker는 자신만의 thread와 global scope에서 script를 실행하면서도, 소유 document를 대신해 네트워크 요청을 발생시킬 수 있습니다. WebSocket channel은 document에 종속되어 반드시 main thread에서 동작해야 하므로, worker의 WebSocket은 worker thread의 Bridge와 main thread의 Peer가 짝을 이루는 구조로 구현되어 있습니다. 이때 기대되는 동작은 cross-site server에 도달할 수 있는 모든 subresource 경로에 동일한 차단 정책이 적용되는 것입니다.
관전 포인트: 피해자 페이지에 third party로 심어진 tracker가 자신의 beacon을 worker가 호스팅하는 WebSocket으로 옮기면, handshake 과정에서 자신의 cookie를 그대로 돌려받을 수 있습니다. 결과적으로 사용자의 차단 정책이 제거했던 cross-site identity linkage가 복원됩니다.
DedicatedWorker fetch 요청은 이미 NetworkResourceLoadParameters를 통해 isInitiatedByDedicatedWorker를 전달하고 있었지만, DedicatedWorker의 WebSocket 연결은 ITP third-party cookie 차단의 대상이 되지 않고 있었습니다. 이 patch는 새로운 isInitiatedByDedicatedWorker boolean을 worker thread에서부터 WorkerThreadableWebSocketChannel::Bridge::initialize()까지 전달합니다. 이 값은 is<DedicatedWorkerGlobalScope>(scope)를 통해 계산되며, 이후 WebSocketTaskCocoa 생성자까지 이어집니다. 그리고 기존의 shouldBlockCookies() 호출을 thirdPartyCookieBlockingDecisionForRequest(..., isInitiatedByDedicatedWorker)로 대체합니다.
Source/WebCore/Modules/websockets/WorkerThreadableWebSocketChannel.cpp
Source/WebCore/Modules/websockets/WorkerThreadableWebSocketChannel.cpp
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.messages.in
LayoutTests/http/tests/websocket/tests/hybi/resources/websocket-blocked-sending-cookie-as-third-party-worker.js
Patch Details
이번 변경은 WebCore 안에 타입이 지정된 provenance flag를 새로 도입하고, 이를 WebSocket parameter chain과 IPC 경계를 거쳐 network process까지 전달합니다.
WebCore 쪽에서는 Source/WebCore/Modules/websockets/IsInitiatedByDedicatedWorker.h에 새로운 enum class IsInitiatedByDedicatedWorker : bool { No, Yes }가 추가되었습니다. ThreadableWebSocketChannel::create(Document&, WebSocketChannelClient&, SocketProvider&)는 기본값이 IsInitiatedByDedicatedWorker::No인 네 번째 parameter를 갖게 되었고, SocketProvider::createWebSocketChannel과 EmptySocketProvider::createWebSocketChannel에도 동일한 parameter가 추가되었습니다. Bridge::initialize(WorkerGlobalScope& scope)는 is<DedicatedWorkerGlobalScope>(scope)를 통해 이 값을 계산하고, postTaskToLoader lambda에 캡처한 뒤 mainThreadInitialize() → Peer::create() → Peer::Peer()를 거쳐 main thread의 ThreadableWebSocketChannel::create(downcast<Document>(context), ...)까지 전달합니다.
WebKit 쪽에서는 이 flag가 CreateSocketChannel IPC message에 추가되어 NetworkConnectionToWebProcess::createSocketChannel → NetworkSocketChannel::create/생성자 → NetworkSession::createWebSocketTask까지 전달됩니다. Commit message에 따르면 Cocoa의 WebSocketTask 생성자는 기존의 shouldBlockCookies() 호출을 thirdPartyCookieBlockingDecisionForRequest(..., isInitiatedByDedicatedWorker)로 대체합니다. WebSocketTaskCocoa.mm의 해당 hunk는 제공된 diff의 잘린 부분에 위치하기 때문에, 이 구체적인 변경 내용은 commit message 그대로 인용합니다. 부수적으로는 worker 내부에서 cross-origin WebSocket handshake를 실행하는 새로운 layout test 파일 세 개가 추가되었고, LayoutTests/ipc/create-socket-channel-invalid-url-crash.html도 수정되어 수작업으로 구성한 IPC message가 직렬화된 argument list와 계속 일치하도록 isInitiatedByDedicatedWorker: 0이 추가되었습니다.
Originating context가 이미 지워진 계층에서 policy 판단이 이루어지다 보니, 한 요청 경로에 적용된 privacy control이 같은 sink로 향하는 다른 경로에서는 조용히 누락되는 패턴입니다.
Background
ITP third-party cookie blocking. WebKit의 Intelligent Tracking Prevention은 현재 페이지의 first party가 아닌 도메인으로 향하는 요청에서 cookie를 차단할 수 있습니다. 이를 통해 third-party resource는 first-party context에서 설정된 식별자를 돌려받지 못하게 됩니다. 이 판단은 요청이 발생하는 지점과 가까운 network process에서 이루어집니다.
WebSocket handshake. WebSocket 연결은 Upgrade: websocket header가 붙은 일반적인 HTTP 요청으로 시작됩니다. 다른 HTTP 요청과 마찬가지로 cookie policy의 적용을 받으며, 목적지 origin에 대한 cookie가 해당 규칙에 따라 함께 전송됩니다.
DedicatedWorker / DedicatedWorkerGlobalScope. DedicatedWorker는 페이지의 Window와는 구분되는 자신만의 global scope object를 갖고, 자신만의 thread에서 script를 실행합니다. 또한 소유 document를 대신해 fetch나 WebSocket 같은 네트워크 요청을 발생시킬 수 있습니다.
Bridge/peer split. 실제 WebSocket channel은 document에 종속되어 반드시 main thread에서 동작해야 하므로, worker의 WebSocket은 worker thread의 Bridge와 main thread의 Peer가 짝을 이루는 구조로 구현됩니다. Bridge::initialize()는 postTaskToLoader를 통해 loader thread에 task를 전달하고 BinarySemaphore로 대기하며, main thread는 Peer를 생성합니다. 이 Peer가 소유 Document를 대상으로 실제 ThreadableWebSocketChannel을 생성합니다.
SocketProvider. WebSocket channel 구현체를 제공하는 WebCore 수준의 abstraction입니다. WebSocketProvider(modern WebKit), LegacySocketProvider(WebKitLegacy), EmptySocketProvider(no-op)가 모두 이를 구현합니다.
Cookie domain scoping. Cookie는 registrable domain 기준으로 구분되므로, localhost와 127.0.0.1은 둘 다 loopback interface로 연결되더라도 서로 다른 cookie store에 해당합니다. 따라서 어떤 policy 하에서도 한쪽 페이지가 document.cookie를 통해 다른 쪽의 cookie를 읽을 수는 없습니다.
NetworkConnectionToWebProcess.messages.in. Web process와 network process 간 연결을 위한 선언적 IPC message 정의 파일입니다. 각 message의 parameter list가 직렬화되는 wire format을 결정하므로, 이를 변경하면 모든 sender와 수작업으로 구성된 test message도 함께 맞춰야 합니다.
Analysis
Root cause는 요청의 originating context가 이미 지워진 계층에서 차단 여부가 판단되었다는 데 있습니다. WebSocket parameter chain에는 애초에 이 정보를 담을 field가 존재하지 않았습니다. SocketProvider::createWebSocketChannel(Document&, WebSocketChannelClient&)는 Document만을 받았고, Peer는 소유 document인 downcast<Document>(context)로부터 main-thread channel을 생성하면서 요청이 worker global scope 내부에서 발생했다는 기록을 전혀 남기지 않았습니다. 이 요청이 IPC를 거쳐 createSocketChannel에 도달할 무렵에는, worker provenance가 message signature 자체에 구조적으로 표현될 수 없는 상태였습니다.
Before: After:
DedicatedWorkerGlobalScope DedicatedWorkerGlobalScope
| Bridge::initialize() | is<DedicatedWorkerGlobalScope> -> Yes
v postTaskToLoader v postTaskToLoader (flag captured)
Peer: downcast<Document>(context) Peer: downcast<Document>(context) + flag
v CreateSocketChannel IPC v CreateSocketChannel IPC (+ flag)
NetworkProcess: shouldBlockCookies() NetworkProcess:
^ worker provenance erased thirdPartyCookieBlockingDecision(.., flag)
왼쪽 열에서 볼 수 있듯이, provenance가 사라지는 지점은 바로 Peer hop입니다. 어떤 종류의 global scope가 요청을 발생시켰는지 downstream에서 물어볼 수 있기도 전에, context가 소유 Document로 축소되어 버립니다. 그 결과 handshake의 Upgrade 요청은, worker를 인지하는 ITP 정책이라면 제거했을 cross-origin 목적지의 cookie를 그대로 실어 보낼 수 있었습니다. 이번 fix는 이 구분이 저렴하게 파악 가능한 유일한 지점, 즉 worker thread에서 이를 명시적으로 만들고, downstream에서 재구성하는 대신 typed enum 형태로 전달하는 방식을 택하고 있습니다.
이 취약점에 도달하는 데는 특별한 조건이 필요하지 않습니다. 임의 페이지가 Worker를 생성할 수 있고, DedicatedWorkerGlobalScope 안의 script는 new WebSocket("ws://cross-origin-host/...")를 호출할 수 있습니다. Regression test는 구체적인 시퀀스를 그대로 재현합니다. 페이지는 최초 origin에서 setAsFirstPartyHTTPLoopback을 설정한 뒤, http://localhost:8000/...#setCookieAsFirstParty로 이동해 localhost에 대해 setAsFirstPartyHTTP와 setAsFirstPartyJS를 first-party cookie로 설정합니다. 이어서 http://127.0.0.1:8000/...#didSetCookieAsFirstParty로 이동함으로써 localhost를 third-party 위치에 놓은 다음, worker를 생성해 ws://localhost:8880/...를 엽니다.
마지막 단계의 shouldBeUndefined(document.cookie) 라인 자체는 별다른 의미를 갖지 않습니다. Expected output에는 expression name이 비어 있는 PASS is undefined.가 기록되어 있는데, 이는 helper가 expression string이 아니라 값을 받았음을 보여줍니다. 게다가 localhost의 cookie는 ITP 여부와 무관하게 127.0.0.1:8000의 document.cookie에는 애초에 나타나지 않습니다. 서로 다른 cookie domain이기 때문입니다. 실질적으로 의미를 갖는 assertion은 worker 단계입니다. Worker script 자체의 message 문자열을 보면, test endpoint는 cookie가 전혀 실려 있지 않을 때만 handshake를 수락하도록 되어 있습니다. 따라서 onopen이 PASS 신호이고, onerror("Connection was rejected (request contained cookies)")가 fix 이전의 failure 신호에 해당합니다. Server-side handler는 제공된 diff에 포함되어 있지 않습니다.
이 취약점은 ITP third-party cookie 차단 경계, 즉 무관한 first party에서 로드된 cross-site resource가 자신의 origin에 대한 사용자 cookie를 받지 못하도록 막는 anti-tracking policy를 약화시킵니다. 이때 전제로 삼고 있던 것은, cross-site server에 도달할 수 있는 모든 subresource 경로에서 차단 판단이 균일하게 이루어진다는 점이었습니다. Fix 이전에는 페이지에 심어진 tracker가 DedicatedWorker 내부에서 WebSocket을 열어 handshake 과정에서 자신의 cookie를 돌려받을 가능성이 높았고, 그 결과 사용자의 cross-site identity linkage가 다시 복원될 수 있었습니다. 부수적으로는 정책상 차단되어야 할 context에서 tracker origin의 session cookie가 노출될 위험도 있었습니다. Memory-safety나 process-isolation 측면의 영향은 없습니다. 다만 이 flag는 WebContent process가 직접 전달하는 값이므로, 완전히 compromise된 WebContent process라면 실제 worker 요청에 대해서도 No를 보내 fix 이전의 동작을 재현할 수 있습니다. 그러나 이미 그 수준의 attacker는 더 넓은 범위의 cookie 접근 권한을 갖고 있으므로, 기존 경계가 추가로 확장되는 것은 아닙니다.
Insight: 이 fix가 하필 그 위치에서 이루어져야 했던 이유는, worker인지 document인지의 구분이 worker thread에서만 저렴하게 파악 가능한 반면, 정작 policy 판단은 network process에서 이루어지기 때문입니다. 중간에 놓인 Peer는 소유 Document로 downcast함으로써 의도적으로 worker라는 정보를 지워버립니다. 이 스택에서 request provenance를 나타내는 신호는 반드시 최상단에서 포착해 명시적으로 전달해야 합니다. Document 하나만으로 downstream에서 재구성한 값은 worker가 발생시킨 요청에 대해서는 구조적으로 틀릴 수밖에 없습니다. 한 가지 더 짚어둘 점은, ThreadableWebSocketChannel::create(Document&, ...)가 새 parameter의 기본값을 IsInitiatedByDedicatedWorker::No로 두고 있고, ScriptExecutionContext& overload 역시 여전히 인자 없이 document overload를 호출한다는 사실입니다. 따라서 앞으로 worker와 유사한 semantics를 갖게 될 새로운 경로가 생기더라도, compile error 없이 조용히 flag가 꺼진 기존 동작으로 이어질 가능성이 있습니다.
Audit directions
-
동일한 정책 판단 지점(enforcement point)에 도달하는 request 경로가 두 개 이상 존재하는데, 한쪽 경로의 parameter bundle에만 provenance metadata가 추가된 privacy/security policy. Policy sink에 도달하는 모든 경로는 동일한 decision input을 실어 날라야 합니다. 문제는 이 부분이 조용히 깨진다는 점입니다. Plumbing이 누락된 경로도 컴파일과 실행 자체는 정상적으로 되기 때문입니다. 다만 판단 결과만 달라질 뿐입니다. 좁게 보면, ITP/privacy 판단에 쓰이는 resource-load parameter bundle의 필드들을 나열한 뒤,
NetworkConnectionToWebProcess.messages.in의CreateSocketChannelparameter 목록 및SocketProvider::initializeWebTransportSession의 WebTransport 대응 목록과 diff해볼 필요가 있습니다. 한쪽에는 있고 다른 쪽에는 없는 필드가 후보가 됩니다. 조금 더 넓게 보면, 메인 request pipeline에 더해 손으로 짠 parameter 목록을 가진 부가 프로토콜을 갖는 subsystem들도 대상입니다. EventSource,sendBeacon, preconnect/prefetch hint, ServiceWorker 경유 load 등이 해당됩니다. 가장 넓게 보면, sibling 경로에서 policy input이 누락되는 일반적인 클래스 전체가 대상이 됩니다. 예를 들어network::ResourceRequest의 trusted-params 필드나, security-relevant field가 한 message에만 추가되고 sibling message에는 추가되지 않은 RPC schema가 이에 해당합니다. 모든 단계에서 공통되는 match tell은, optional 혹은 default 값을 갖는 context parameter를 받는 policy 함수가 있고, 최소 하나 이상의 caller가 이를 생략한다는 점입니다. -
API 경계에서 security 또는 privacy와 관련된 parameter에 default argument가 지정된 경우.
ThreadableWebSocketChannel::create(Document&, WebSocketChannelClient&, SocketProvider&, IsInitiatedByDedicatedWorker = IsInitiatedByDedicatedWorker::No)가 전형적인 형태입니다.No값이 "더 약한 policy를 적용한다"는 의미를 가진 default enum이기 때문입니다. 각 사례에서, 안전한 답을 알 수 없는 상황임에도 진짜 negative가 아니라는 이유로 production caller가 이 default 값에 의존하고 있지는 않은지 확인해야 합니다. 조금 더 넓게 보면,Source/WebCore와Source/WebKit에서 함수명에Policy,Blocking,Allowed,Trusted,Origin이 포함된 시그니처 중= false)나= std::nullopt)형태를 검색해볼 필요가 있습니다. 가장 넓게 보면, security decision을 인코딩하는 default parameter는 argument 누락 버그를 컴파일 에러에서 조용한 policy downgrade로 바꿔버립니다. 어떤 코드베이스에서든 "새로운 caller가 이 argument를 빠뜨리면 strict한 동작과 lax한 동작 중 어느 쪽을 받게 되는가"를 물어야 합니다. 코드 리뷰 시 확인할 tell은, default 값이 해당 enum의 두 상태 중 덜 제한적인 쪽이라는 점입니다. -
Thread 또는 process hop을 거치며 context가 downcast된 뒤, 그 downcast로 인해 소실된 property를 다시 re-derive하는 패턴.
Peer::Peer는downcast<Document>(context)를 수행하는데, 이는 thread hop 지점에서 의도적으로 provenance를 지워버리는 동작에 해당합니다. 이후 이Document로부터 policy input을 재구성하는 기능이 있다면, worker의 경우에는 잘못된 값을 얻게 됩니다.WorkerLoaderProxy::postTaskToLoader를 통해 도달하는 다른 main-thread proxy object들부터 시작해서, 각각이 worker 쪽으로부터 값을 전달받는 대신 document로부터 policy-relevant property를 다시 계산하고 있지는 않은지 확인할 필요가 있습니다. 조금 더 넓게 보면,ScriptExecutionContext가 decision 이전에Document로 좁혀지는 모든 지점, 그리고 worker 자신의 origin, CSP, referrer policy가 owner document의 것과 동일하다고 가정되는 모든 지점이 대상입니다. 가장 넓게 보면, 한 context의 request를 다른 context로 옮기는 proxy를 갖는 모든 아키텍처에서, policy layer가 필요로 하는 값은 반드시 data로 전달되어야 하며 proxy 자신의 context로부터 재도출되어서는 안 됩니다. 코드 리뷰 시 확인할 tell은, cross-thread post의 수신 측에서 이루어지는downcast</static_cast<narrowing과, 그 뒤에 이어지는 policy call입니다. -
WebContent→Network IPC 경계에 놓인 trusted privacy flag. 현재
NetworkConnectionToWebProcess::createSocketChannel은request.url().isValid()와allowsFirstPartyForCookies(...)에는MESSAGE_CHECK를 적용하지만, 새로운 flag는 trusted input으로 취급합니다. 같은 message에 담긴 나머지 privacy flag들(hadMainFrameMainResourcePrivateRelayed,allowPrivacyProxy,storedCredentialsPolicy)이 독립적으로 validate되고 있는지, 그리고 이들 사이에 일관된 policy가 존재하는지 조사해볼 필요가 있습니다. 이 클래스는 WebKit의MESSAGE_CHECKidiom과 WebContent/Network 사이의 특정 trust split에 묶여 있으므로, 이 audit의 범위는 WebKit의 IPC 경계 안으로 한정됩니다. Match tell은,.messages.in선언에 담긴 security 또는 privacy 관련 enum이나 bool 값을 handler가 대응하는MESSAGE_CHECK나 server-side re-derivation 없이 그대로 전달하는 패턴입니다.