← All reports

[5] LazyLoadVideoObserver use-after-free after document teardown

MediumWebCore DOM / HTML mediaUAF

A document at refcount zero is still alive — and still cloneable.

b74177d

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를 읽고 쓰게 만들 수 있습니다.

DocumentLazyLoadVideoObserver를 소유하고 있는 상황에서, Document의 조기 소멸이 LazyLoadVideoObserver::observe()LazyLoadVideoObserver의 use-after-free로 이어질 수 있던 케이스가 수정되었습니다. lazyLoadVideoObserver() getter에는 LIFETIME_BOUND가 추가되었는데, 이 attribute가 있었다면 애초에 안전하지 않은 코드가 걸러졌을 것입니다. Node::cloneNodeDocument를 보호하도록 수정되었고, Document::m_lazyLoadObserver가 nullable이라는 점을 감안해 observer도 추가 안전장치로 CheckedPtr에 담기게 되었습니다. 이번 코드 변경과 테스트는 Kristian Monsen의 초기 작업을 거의 그대로 기반으로 합니다.

Source/WebCore/html/LazyLoadVideoObserver.cpp

void LazyLoadVideoObserver::observe(HTMLVideoElement& element)
{
 
- auto& observer = protect(element.document())->lazyLoadVideoObserver();
 
- RefPtr intersectionObserver = observer.intersectionObserver(protect(element.document()));
 
- if (!intersectionObserver)
 
- return;
 
- intersectionObserver->observe(element);
+ Ref document = element.document();
+ if (RefPtr intersectionObserver = protect(document->lazyLoadVideoObserver())->intersectionObserver(document))
+ intersectionObserver->observe(element);
}
 
IntersectionObserver* LazyLoadVideoObserver::intersectionObserver(Document& document)
...
 
- m_observer = observer.returnValue().ptr();
+ lazyInitialize(m_observer, observer.releaseReturnValue());

Source/WebCore/html/LazyLoadVideoObserver.h

-class LazyLoadVideoObserver {
+class LazyLoadVideoObserver final : public CanMakeCheckedPtr<LazyLoadVideoObserver> {
WTF_MAKE_TZONE_ALLOCATED(LazyLoadVideoObserver);
+ WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR(LazyLoadVideoObserver);
...
 
- RefPtr<IntersectionObserver> m_observer;
+ const RefPtr<IntersectionObserver> m_observer;

Source/WebCore/dom/Document.h

 
- LazyLoadVideoObserver& lazyLoadVideoObserver();
+ LazyLoadVideoObserver& lazyLoadVideoObserver() LIFETIME_BOUND;

Source/WebCore/dom/Node.cpp

Ref<Node> Node::cloneNode(bool deep) const
{
 
- return cloneNodeInternal(document(), deep ? CloningOperation::Everything : CloningOperation::SelfOnly, registry.get());
+ return cloneNodeInternal(protect(document()), deep ? CloningOperation::Everything : CloningOperation::SelfOnly, registry.get());
}

LayoutTests/fast/dom/lazy-video-clone-after-document-teardown-crash.html

+function createVideoInDetachedDocument() {
+ const document = Document.parseHTMLUnsafe("");
+ return document.createElementNS("http://www.w3.org/1999/xhtml", "video");
+}
+(async function () {
+ const video = createVideoInDetachedDocument();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ if (typeof GCController !== "undefined")
+ GCController.collect();
+ video.cloneNode(false);
+ ...
+})();

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::cloneNodecloneNodeInternal(document(), ...)cloneNodeInternal(protect(document()), ...)로 바꿔, clone 작업 전체 동안 refcount를 고정시킵니다. 그리고 LazyLoadVideoObserver.h는 클래스를 final이자 CanMakeCheckedPtr로 만들고 WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR를 추가했으며, RefPtr<IntersectionObserver> m_observerconst RefPtr<IntersectionObserver> m_observer로 바꿔 단순 대입 대신 lazyInitialize(...)를 통해 한 번만 초기화되도록 했습니다.

Temporary owning smart pointer를 거쳐 도달한 sub-object에 long-lived reference를 바인딩하는 패턴으로, statement가 끝나는 시점에 owner가 파괴되는데도 reference는 여전히 사용 중인 상태로 남습니다.

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를 생성합니다.

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입니다.

  1. Document.parseHTMLUnsafe("")가 frame 없는 document를 생성합니다.
  2. createElementNS(..., "video")가 그 안에 HTMLVideoElement를 할당하고, 스크립트에는 element만 반환하므로 document의 JS wrapper는 도달 불가능한 상태가 됩니다.
  3. await setTimeout(0)으로 현재 task가 끝나면서 C++ stack에서 document를 보호하는 대상이 사라지고, GCController.collect()가 wrapper를 수거하면서 마지막 strong reference가 해제됩니다. 다만 video node가 여전히 document를 참조하고 있어 document는 refcount 0으로 살아남습니다.
  4. video.cloneNode(false)Node::cloneNode에 진입해 HTMLVideoElement::create()LazyLoadVideoObserver::observe()로 이어집니다.
  5. protect(element.document())가 유일한 strong reference를 다시 획득하고, getter가 observer reference를 돌려준 뒤 temporary는 세미콜론에서 해제됩니다.
  6. 다음 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 작업입니다.

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

void WorkerThreadableWebSocketChannel::Bridge::initialize(WorkerGlobalScope& scope)
{
...
BinarySemaphore semaphore;
 
- m_loaderProxy->postTaskToLoader([&semaphore, &peer, workerThread = Ref { scope.thread() }, workerContextIdentifier = scope.identifier(), workerClientWrapper = m_workerClientWrapper, taskMode = m_taskMode.isolatedCopy(), provider = m_socketProvider](ScriptExecutionContext& context) mutable {
 
- peer = mainThreadInitialize(context, workerThread.get(), workerContextIdentifier, WTF::move(workerClientWrapper), taskMode, WTF::move(provider));
+ auto isInitiatedByDedicatedWorker = is<DedicatedWorkerGlobalScope>(scope) ? IsInitiatedByDedicatedWorker::Yes : IsInitiatedByDedicatedWorker::No;
+ m_loaderProxy->postTaskToLoader([&semaphore, &peer, ..., provider = m_socketProvider, isInitiatedByDedicatedWorker](ScriptExecutionContext& context) mutable {
+ peer = mainThreadInitialize(context, workerThread.get(), workerContextIdentifier, WTF::move(workerClientWrapper), taskMode, WTF::move(provider), isInitiatedByDedicatedWorker);
semaphore.signal();
});

Source/WebCore/Modules/websockets/WorkerThreadableWebSocketChannel.cpp

WorkerThreadableWebSocketChannel::Peer::Peer(..., SocketProvider& provider, IsInitiatedByDedicatedWorker isInitiatedByDedicatedWorker)
: m_workerClientWrapper(clientWrapper.ptr())
 
- , m_mainWebSocketChannel(ThreadableWebSocketChannel::create(downcast<Document>(context), *this, provider))
+ , m_mainWebSocketChannel(ThreadableWebSocketChannel::create(downcast<Document>(context), *this, provider, isInitiatedByDedicatedWorker))

Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.messages.in

 
- [EnabledBy=WebSocketEnabled] CreateSocketChannel(..., enum:uint8_t WebCore::StoredCredentialsPolicy storedCredentialsPolicy)
+ [EnabledBy=WebSocketEnabled] CreateSocketChannel(..., enum:uint8_t WebCore::StoredCredentialsPolicy storedCredentialsPolicy, enum:bool WebCore::IsInitiatedByDedicatedWorker isInitiatedByDedicatedWorker)

LayoutTests/http/tests/websocket/tests/hybi/resources/websocket-blocked-sending-cookie-as-third-party-worker.js

+// Opened from 127.0.0.1:8000; WebSocket connects to localhost:8880 (cross-origin).
+// With ITP third-party cookie blocking enabled, the handshake must not carry cookies.
+let ws = new WebSocket("ws://localhost:8880/websocket/tests/hybi/websocket-blocked-sending-cookie-as-third-party");
+ws.onopen = () => { ... postMessage({ type: "pass", message: "Connection was allowed (request did not contain cookies)." }); };
+ws.onerror = () => { ... postMessage({ type: "fail", message: "Connection was rejected (request contained cookies)." }); };

이번 변경은 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::createWebSocketChannelEmptySocketProvider::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::createSocketChannelNetworkSocketChannel::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로 향하는 다른 경로에서는 조용히 누락되는 패턴입니다.

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를 대신해 fetchWebSocket 같은 네트워크 요청을 발생시킬 수 있습니다.

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 기준으로 구분되므로, localhost127.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도 함께 맞춰야 합니다.

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에 대해 setAsFirstPartyHTTPsetAsFirstPartyJS를 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:8000document.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가 꺼진 기존 동작으로 이어질 가능성이 있습니다.