← All reports

[6] WebXRSystem re-posts a completion handler past the reference that protected it

MediumWebCore WebXRUAF

2b68a68

Medium. Read 자체는 실제로 발생하며, permission 경로에서는 해제된 storage에 1바이트를 쓰는 동작까지 추가됩니다. 다만 두 경우 모두 iframe이 detach되고 그 wrapper가 수거되는 시점이 reply lambda의 해제와 큐에 쌓인 main-thread task 사이의 window 안에 들어와야 하며, 이 window는 페이지가 영향을 줄 수는 있어도 직접 타이밍을 맞출 수는 없습니다.

비동기 callback 경계를 넘나드는 객체 lifetime은 보통 callback이 receiver에 대한 자체 strong reference를 갖도록 해서 안전하게 유지됩니다. 이렇게 하면 reply가 진행 중인 동안 객체가 파괴될 수 없습니다. WebXRSystemnavigator.xr의 구현체로, frame의 Navigator에 supplement로 설치되며, 비동기 ChromeClient 호출을 통해 플랫폼 XR 스택에 device enumeration과 session 요청을 중개합니다. enumeration reply lambda는 protectedThis를 캡처하는데, 이 strong reference는 reply body가 실행되는 동안만 정확히 시스템을 살아있게 유지합니다.

관전 포인트: enumeration 도중 iframe을 detach시켜 navigator.xr wrapper를 수거되게 만드는 페이지는, 재게시된 continuation이 해제된 메모리에서 device state를 읽도록 유도할 수 있습니다. session 요청 경로에서는 그 메모리에 쓰기까지 가능합니다.

세 개의 호출 지점과 하나의 guard 변경이 있습니다. ensureImmersiveXRDeviceIsSelected를 직접 호출하는 obtainCurrentDeviceisSessionSupported는 기존에 completion handler에서 raw this만 캡처했으나, 이제 둘 다 자체 strong reference를 갖게 되었습니다. requestSession은 한 단계 더 깊은 곳에서 같은 재게시 패턴에 도달합니다 — 이 함수가 protectedThis를 갖는 lambda는 obtainCurrentDevice에 넘기는 completion handler이고, 이는 다시 obtainCurrentDevice의 continuation이 전달하는 callback입니다 — 이 경로 역시 동일하게 수정되었습니다. 별도로, resolveFeaturePermissions reply lambda의 결합된 if (!weakThis || !requestedFeatures) guard가 분리되어, receiver가 죽은 branch가 더 이상 m_pendingImmersiveSession = false를 수행하는 body로 흘러들어가지 않게 되었습니다.

reply body가 만들어내는 지연된 task가 아니라 reply body 자체에만 scope가 한정된 strong reference로 인해, ownership이 receiver를 dereference하는 코드 바로 앞 한 hop에서 끊어지는 패턴입니다.

navigator.xr와 supplement. WebXRSystemActiveDOMObject이자 EventTarget으로, NavigatorWebXR을 통해 Navigator supplement로 설치됩니다. strong owner는 NavigatorWebXR::m_xr이며, NavigatorWebXR.h에 정의된 RefPtr<WebXRSystem> 타입으로 frame의 navigator.xr에서 도달할 수 있습니다.

Device selection flow. ensureImmersiveXRDeviceIsSelectedChrome::client()를 통해 enumerateImmersiveXRDevices 호출을 발행하고, enumeration이 완료되면 reply lambda를 받습니다.

makeScopeExitcallOnMainThread. makeScopeExit는 종료 경로와 무관하게 감싸고 있는 scope가 unwind될 때 callable을 실행합니다. callOnMainThread는 callable을 별도의 task로 main run loop에 게시합니다 — inline으로 실행되지 않으므로, task가 실행될 시점에는 게시했던 scope가 이미 unwind된 상태입니다.

Ref, WeakPtr, ThreadSafeWeakPtr. Ref/RefPtr는 참조 대상을 살아있게 유지하지만, WeakPtr는 그렇지 않으며 참조 대상이 파괴되면 false로 평가됩니다. m_activeImmersiveDevice의 타입인 ThreadSafeWeakPtr<PlatformXR::Device>는 cross-thread 변형으로, 사용 시점에 RefPtr로 승격됩니다.

근본 원인은 ownership이 한 hop 짧게 전파되었다는 데 있습니다. enumerateImmersiveXRDevices의 reply lambda는 protectedThis = protect(*this)를 캡처하여 reply가 진행되는 동안 WebXRSystem이 살아있음을 보장합니다. 하지만 그 reply 내부에서 makeScopeExitcallOnMainThread(WTF::move(callback))을 수행하는데, 이때 호출자가 넘긴 CompletionHandler가 이동되어 자체 task로 재게시됩니다. reply lambda가 파괴되는 순간 protectedThis가 해제되고, 재게시된 task는 호출자의 callback이 우연히 갖고 있던 ownership만으로 실행됩니다. 두 직접 호출자 모두 raw this만 캡처하고 있었습니다.

  ensureImmersiveXRDeviceIsSelected()
    +- ChromeClient::enumerateImmersiveXRDevices(reply)
         reply lambda: [protectedThis = protect(*this)]  -- strong --+
  reply runs on main thread                                          |
    +- makeScopeExit: callOnMainThread(WTF::move(callback))          |
         (caller's CompletionHandler re-posted as its own task)      |
  reply lambda destroyed ---------------------------------------------+
    protectedThis released              <-- failure window opens
  iframe detached; wrapper collected
    NavigatorWebXR::m_xr RefPtr dropped -> WebXRSystem freed
  queued task runs
    reads m_activeImmersiveDevice out of freed storage

여기서 빠진 invariant는 일반적인 원칙입니다. receiver를 보호하는 frame의 lifetime을 넘어서 전달되는 callable은 반드시 자체 strong reference를 가져야 합니다. reply lambda의 Ref는 잘못된 scope를 보호하고 있습니다 — reply body는 보호하지만, 정작 그 reply body가 만들어내는 지연된 task는 보호하지 못합니다.

iframe을 detach시키고 그 JS wrapper를 수거하면 Navigator supplement가 파괴되고 RefPtr<WebXRSystem>이 해제될 것으로 예상됩니다. 이 일이 reply lambda의 protectedThis가 해제된 이후, 그러나 큐에 쌓인 callOnMainThread task가 실행되기 이전에 발생하면, 그 task는 해제된 storage를 dereference하게 됩니다. 구체적으로는, isSessionSupported의 continuation이 이미 죽은 객체에서 m_activeImmersiveDevice를 읽어 그 값으로 RefPtr를 구성했고, obtainCurrentDevice의 continuation도 같은 member를 읽어 그대로 전달했습니다.

resolveFeaturePermissions의 결함은 별개의 문제이지만 같은 함수 안에 있습니다. 해당 reply lambda는 weakThis = WeakPtr { *this }와 raw this를 함께 갖고 있었습니다. 결합된 if (!weakThis || !requestedFeatures) guard는 receiver가 죽은 경우와 permission이 거부된 경우를 하나의 body로 몰아넣었고, 이 body는 m_pendingImmersiveSession = false를 실행했습니다. 이는 WebXRSystem allocation의 고정된 offset에 대한 store이며, 하필 null weakThis가 객체의 소멸을 증명하는 바로 그 branch에서 수행됩니다. null check 자체는 존재했지만, 그 실패 branch가 여전히 member를 건드리고 있었습니다.

따라서 도달 가능한 primitive는 enumeration 경로에서의 해제된 storage 읽기, 그리고 permission 경로에서 해제된 allocation의 고정 offset에 대한 1바이트 쓰기입니다. 둘 중 어느 쪽이든 exploit하려면 release와 큐에 쌓인 task 사이에 controlled allocation을 해제된 storage에 안착시켜야 합니다. 페이지는 allocation pressure를 통해 이를 시도할 수는 있지만, script가 순서를 정할 수 있는 무언가가 아니라 main run loop의 스케줄링에 의해 그 구간이 제한되기 때문에 직접 동기화할 수는 없습니다. reclaim하는 객체의 layout을 조정할 수 있다면, 고정 offset 쓰기 쪽이 두 primitive 중 더 유용한 편입니다.

이 취약점은 XR device-selection 경계를 넘나들 때 페이지 script가 기대하는 lifetime 보장을 약화시킵니다. owning frame이 이미 해제되고 수거된 객체가, 그 frame이 죽기 전에 예약해 둔 작업으로부터 여전히 주소를 가리킬 수 있고, 심지어 쓰기까지 가능한 상태로 남아 있게 됩니다.