← All reports

[4] Unkeyed AudioHardwareListener memoization outlives its client

HighWebKit UIProcess media sessionUAF

Every caller got the first caller's listener, and it never left.

d147fe7

High. 프로세스 전역 singleton이 처음 호출한 client에 묶인 listener를 캐시한 뒤 끝까지 해제하지 않습니다. 그 결과 renderer에서 도달 가능한 IPC handler 세 개가, 이미 사라진 client를 향해 virtual dispatch를 수행할 수 있습니다. 확장 조건은 다음 hardware-change 메시지가 도착하기 전에 해제된 client 영역을 재확보하는 것입니다.

WebKit의 media 관련 코드에서 callback을 등록할 때는 암묵적인 계약이 하나 따라붙습니다. 알림을 받는 객체가 알림 대상 객체보다 오래 살아남아서는 안 된다는 것입니다. AudioHardwareListener는 "audio hardware가 활성화되거나 비활성화될 때, 또는 출력 장치가 바뀔 때 알려달라"는 요구를 추상화한 WebCore 인터페이스입니다. 실제 인스턴스는 UI process가 시작 시점에 설치하는 프로세스 전역 생성 함수를 거쳐 생성됩니다. UI process에서 이 함수는 RemoteMediaSessionManagerProxy로 연결되는데, 이 객체는 content process가 보내는 RemoteMediaSessionManager* IPC 메시지도 함께 처리하는 프로세스 전역 singleton입니다.

관전 포인트: content process가 audio-hardware-change 메시지를 보내는 것만으로, UI process가 stale한 client reference를 따라 virtual call을 수행하게 만들 수 있습니다. 그 reference를 계속 살려두는 것은 어느 client의 소유도 아닌 캐시입니다.

패치 이전의 factory는 자신이 받은 파라미터를 무시했습니다. 첫 호출에서 listener 하나를 생성한 뒤, 이후의 모든 호출자에게 동일한 인스턴스를 반환했습니다.

Source/WebKit/UIProcess/Media/RemoteMediaSessionManagerProxy.cpp

// ensureAudioHardwareListenerProxy(Client& client)는 `client`를 무시했습니다.
// 첫 호출에서 RemoteMediaSessionManagerAudioHardwareListener 하나를 생성해
// strong RefPtr인 m_audioHardwareListenerProxy에 저장한 뒤,
// 이후의 모든 호출자에게 같은 인스턴스를 반환했습니다.

그리고 listener는 자신의 sink를 bare reference로 보관했습니다.

Source/WebCore/platform/audio/AudioHardwareListener.h

Client& m_client;

이제는 요청한 client가 singleton proxy 자신인 경우에만 캐싱이 이루어집니다. 새로 추가된 &client == static_cast<WebCore::AudioHardwareListener::Client*>(this) 비교가 그 판별을 담당합니다. 나머지 client는 proxy가 붙잡지 않는 새 listener를 각각 받게 됩니다. 캐시 핸들은 strong RefPtr에서 ThreadSafeWeakPtr로 격하되었습니다. m_client는 WeakPtr<Client>로 바뀌었고, 각 dispatch 지점에서 호출 직전에 RefPtr로 upgrade합니다. Client에 AbstractRefCountedAndCanMakeWeakPtr를 요구한 것이 나머지 변경의 출발점입니다. 그 결과 RemoteAudioHardwareListenerProxy는 RefCounted가 되어야 했고, GPU process 쪽 map도 unique_ptr 대신 Ref를 담게 되었습니다.

hunk 하나는 버그 수정이 아니라 hardening에 해당합니다. MediaSessionManagerCocoa::audioOutputDeviceChanged()는 이미 m_audioHardwareListener가 null이면 곧바로 반환하도록 되어 있었습니다. 따라서 패치 이전에도 이 경로에서 null을 dereference할 수는 없었습니다. 변경된 부분은 해당 멤버를 로컬 RefPtr로 끌어올려 참조를 붙잡아 두는 것입니다. 이렇게 하면 null 검사 이후 뒤따르는 supportedBufferSizes() / updateSessionState() 호출 사이에서 listener가 해제되지 않습니다.

호출자별 상태를 담으면서도 호출자 identity를 키로 삼지 않는 memoized 객체가, 모든 호출자보다 오래 사는 소유자에게 붙잡혀 있는 패턴.

이 코드가 있는 위치. RemoteMediaSessionManagerProxy는 WebCore의 MediaSessionManagerCocoa를 상속한 UI process singleton이며, 동시에 content process가 보내는 RemoteMediaSessionManager* IPC의 수신자 역할도 겸합니다. 소스에는 private 생성자와 함께 NeverDestroyed<Ref<RemoteMediaSessionManagerProxy>>를 반환하는 singleton(), 그리고 singletonWeakPtr()와 singletonIfCreated() 접근자가 있습니다. 결국 이 proxy는 프로세스가 살아 있는 동안 사실상 파괴되지 않습니다.

프로세스 전역 생성 함수. AudioHardwareListener 같은 WebCore 추상 인터페이스는 embedding process가 설치한 함수 포인터를 거쳐 생성됩니다. 덕분에 WebCore는 IPC의 존재를 몰라도 되고, UI process는 remote 구현으로 바꿔 끼울 수 있습니다. AudioHardwareListener::create(client) 호출은 proxy의 생성자가 설치해 둔 함수로 그대로 흘러 들어갑니다.

WeakPtr upgrade와 raw reference의 차이. WeakPtr<T>는 대상 객체를 살려 두지는 않지만, 대상이 해제되면 null이 됩니다. 호출 지점에서 RefPtr로 upgrade하면 생존 여부를 확인하는 동시에 호출이 끝날 때까지 객체를 붙잡아 둘 수 있습니다. 반면 raw T&는 둘 중 어느 것도 하지 않습니다. 강제력이 없는 lifetime 주장에 지나지 않습니다.

AbstractRefCountedAndCanMakeWeakPtr. 추상 인터페이스에 refcount와 weak pointer 지원을 동시에 부여하는 WebKit base class입니다. 인터페이스 타입의 멤버를 weak하게 보관했다가 필요할 때 upgrade하는 방식이 여기서 가능해집니다. Client에 이 base를 요구했기 때문에 WeakPtr<Client> 저장이 비로소 성립하며, 동시에 ownership 요구사항이 모든 구현체로 전파됩니다.

여기서는 두 개의 invariant가 한꺼번에 빠져 있었습니다. 먼저 호출자별 상태를 담는 memoized 객체라면 호출자 identity를 키로 삼아야 합니다. 또한 listener는 자신이 dispatch하는 client를 weak하게 들고 있지 않는 한, 그 client보다 오래 살아남아서는 안 됩니다. bug class로 보면 dangling raw reference를 통한 virtual dispatch에서 발생하는 use-after-free이며, 그 무대는 UI process입니다.

  first client          singleton proxy            later client
  ────────────          ───────────────            ────────────
  create(A) ──────────► build listener{m_client=A}
                        cache in strong RefPtr
                        return listener ──────────► create(B)
  ~A()                  cache still holds it ─────► same listener (m_client=A)
    ▲ A freed           (immortal owner,
      listener lives      never released)
                        IPC: remoteAudioOutputDeviceChanged
                          └─► m_client.audioOutputDeviceChanged()
                                ▲ virtual call into freed A

캐시된 인스턴스 하나에서 서로 다른 결함 두 가지가 파생됩니다. aliasing 쪽 결함은 패치 이전 함수 본문에 그대로 드러납니다. 프로세스 안에서 최초로 실행된 create() 호출이 listener의 raw m_client를 첫 번째 Client로 영구히 고정했습니다. 그래서 두 번째 client가 create()를 호출하면, 첫 번째 client로 dispatch하는 listener를 받게 되었습니다. lifetime 쪽 결함은 그보다 심각합니다. m_audioHardwareListenerProxy가 파괴되지 않는 singleton이 소유한 strong RefPtr이었기 때문입니다. 첫 client가 파괴되어도, MediaSessionManagerCocoa가 자신의 m_audioHardwareListener를 해제해도, 캐시된 listener는 파괴되지 않았습니다.

심각도를 끌어올리는 지점은 도달 가능성입니다. IPC handler 세 개가 여기에 해당하는데, remoteAudioHardwareDidBecomeActive, remoteAudioHardwareDidBecomeInactive, remoteAudioOutputDeviceChanged입니다. 이들은 content process 메시지만으로 살아남은 캐시 listener에 곧바로 도달해 m_client.audioOutputDeviceChanged()를 실행합니다. 바인딩된 client가 이미 사라진 상태라면, 해제된 메모리를 가리키는 reference를 따라 virtual call이 수행되는 셈입니다. 결국 권한이 높은 UI process에서 dangling dereference를 공격자가 원하는 시점에 발생시킬 수 있습니다.

새로 들어간 identity 비교 자체가 singleton이 아닌 client도 이 factory에 도달한다는 근거입니다. 구분할 대상이 없다면 비교를 넣을 이유도 없기 때문입니다. 다만 그 client가 구체적으로 어떤 클래스인지는 여기서 확인되지 않습니다. 따라서 어떤 client의 파괴가 window를 여는지는 열린 질문으로 남습니다.

이 vulnerability는 content에서 UI로 넘어가는 IPC 경계에서 UI process의 memory safety를 약화시킵니다. 게다가 해제된 메모리에 대한 접근이 단순 data read가 아니라 virtual dispatch 지점에서 발생합니다.