← All issues

[2] IPC::Connection SyncMessageState destruction escapes its lock

A lock refactor let a shared object's last reference escape the lock.

Severity: Medium | Component: WebKit IPC layer | 89d4e73

Medium. Locking을 정리하는 리팩토링 과정에서 refcount 해제 하나가 이를 직렬화하던 critical section 밖으로 빠져나갔습니다. 그 결과 매 reply마다 반복되는 hot teardown 경로가 공유 IPC state를 둘러싼 cross-thread race로 바뀌었습니다. 이 문제가 확장되려면 attacker가 해당 destruction race를 안정적으로 이겨야 하고, 그 뒤 ThreadSafeRefCounted 해제 시점을 겨냥해 heap을 grooming해야 합니다.

WebKit IPC layer에서 cross-thread reference counting은 보통 하나의 lock으로 안전하게 유지됩니다. 이 lock이 공유된 per-connection state를 읽고 사용하고 해제하는 과정을, 동시에 발생할 수 있는 teardown과 직렬화하기 때문입니다. Connection은 WebContent, GPU, Networking, UI process 사이에서 메시지를 전달하는 객체로, 각 connection은 전용 work-queue thread에서 수신 메시지를 처리하며 SyncMessageState에 대한 reference를 하나 보유합니다. SyncMessageState는 같은 target thread에 묶인 모든 connection이 공유하는 per-dispatcher coordinator입니다. 이 공유 coordinator는 마지막 reference가 해제될 때만 destroy되며, 그 마지막 release는 dispatcher thread에서 invalidate()의 동시 release를 보호하는 것과 동일한 m_incomingMessagesLock 아래에서 실행되어야 한다는 전제가 있습니다.

관전 포인트: IPC connection 한쪽 끝에 이미 자리 잡은 peer는 WebPage teardown과 race를 벌여 수신 측 process의 공유 SyncMessageState를 손상시킬 수 있습니다. 보고된 teardown 경로는 WebContent process의 GPUProcess connection을 거쳐 진행됩니다.

316617@mainConnection::processIncomingMessage()의 async-reply-with-dispatcher 분기에 incomingMessagesLocker.unlockEarly() / waitForMessagesLocker.unlockEarly()를 추가했습니다. Reply handler가 lock을 잡지 않은 상태로 실행되도록 하여 다른 분기들과 동작을 맞추고 re-entrancy를 피하기 위한 변경이었습니다. 다만 함수 앞부분에서 선언된 RefPtr syncState = m_syncState local은 return 시점에 이 locker들보다 먼저 destroy됩니다. 선언 순서의 역순으로 destroy되기 때문입니다. 결과적으로 이 변경은 SyncMessageState의 마지막 deref를, 그리고 그로 인해 트리거될 수 있는 ~SyncMessageState()m_incomingMessagesLock 보호 밖으로 밀어냈습니다. 이 경로는 hot path에 해당하며, 모든 async reply — GPUProcess의 sendWithAsyncReply reply를 포함 — 마다 실행됩니다. Fix는 두 async-reply 분기 모두에서 unlockEarly() 이전, 즉 m_incomingMessagesLock을 여전히 보유한 상태에서 SyncMessageState reference를 먼저 해제(syncState = nullptr)하도록 합니다.

Source/WebKit/Platform/IPC/Connection.cpp

if (message->isAsyncReplyMessage()) {
if (!AtomicObjectIdentifier<AsyncReplyIDType>::isValidIdentifier(message->destinationID())) {
+ // Drop our SyncMessageState reference while still holding m_incomingMessagesLock. Otherwise the
+ // ~SyncMessageState triggered by this last deref would run without the lock and could race with
+ // invalidate() dropping its own reference (both under m_incomingMessagesLock) on the dispatcher thread.
+ syncState = nullptr;
incomingMessagesLocker.unlockEarly();
waitForMessagesLocker.unlockEarly();
...
return;
}
if (auto replyHandlerWithDispatcher = takeAsyncReplyHandlerWithDispatcherWithLockHeld(...)) {
+ // Drop our SyncMessageState reference while still holding m_incomingMessagesLock, before unlocking to
+ // run the reply handler. Otherwise the ~SyncMessageState triggered by this last deref would run without
+ // the lock and could race with invalidate() dropping its own reference on the dispatcher thread.
+ syncState = nullptr;
incomingMessagesLocker.unlockEarly();
waitForMessagesLocker.unlockEarly();

이 변경은 IPC::Connection::processIncomingMessage() 한 곳에만 영향을 미칩니다. 두 async-reply 분기 — 유효하지 않은 destinationID에 대한 early-return 분기와 takeAsyncReplyHandlerWithDispatcherWithLockHeld() 분기 — 모두에서, 기존 incomingMessagesLocker.unlockEarly() / waitForMessagesLocker.unlockEarly() 호출 바로 앞에 syncState = nullptr;를 삽입합니다. 이렇게 하면 local RefPtr syncState = m_syncState가 함수 return 시점(locker들이 이미 조기 해제된 이후)이 아니라, m_incomingMessagesLock이 아직 held 상태인 동안 reference를 해제하게 됩니다. 다른 로직 변경은 없으며, reply handler는 여전히 lock이 해제된 상태로 실행됩니다.

이 버그는 순전히 scope exit에서의 destruction 순서 문제입니다.

  Declaration order            Destruction order (reverse)
  ─────────────────            ───────────────────────────
  RefPtr syncState  (early)      Locker incomingMessagesLocker
  Locker incomingMessagesLocker  Locker waitForMessagesLocker
  Locker waitForMessagesLocker   RefPtr syncState   <-- last, and now
                                                        AFTER unlockEarly()

Critical section 안에 머물러야 하는 refcounted resource의 destruction이 그 밖으로 빠져나갑니다. 역순 scope destruction 때문에, 소유권을 가진 smart pointer의 release가 조기 lock release 이후로 밀려나면서 발생하는 문제입니다.

Four-process 모델. WebKit은 UIProcess, WebContent, GPUProcess, Networking process 사이에서 cross-process message transport를 수행합니다. IPC::Connection은 이 역할들 전반에서 공유되는 transport로, 각 connection은 전용 work queue에서 수신 메시지를 처리합니다(processIncomingMessage()는 "Called on the connection work queue"로 문서화되어 있습니다).

Dispatcher와 공유 state. WebKit의 threading model에서 SerialFunctionDispatcher는 run loop나 work queue를 추상적으로 소유하는 개체로, 어떤 코드가 어느 thread에서 실행될지를 결정합니다. 각 Connection은 하나의 dispatcher에 묶여 있습니다. SyncMessageStateSyncMessageState::getOrCreate()를 통해 SerialFunctionDispatcher마다 한 번 생성되는 per-dispatcher coordinator이며, 해당 dispatcher에 묶인 모든 Connection이 이를 공유합니다. 이 coordinator는 어떤 thread가 동기 reply를 기다리는 동안 message dispatch를 조율하는 역할을 합니다. Dispatcher 추상화가 존재하는 이유는, 동일한 target thread에 묶인 여러 connection이 coordination state를 각자 중복 보유하지 않고 공유할 수 있게 하기 위해서입니다.

SyncMessageState의 lifetime. SyncMessageStateThreadSafeRefCounted이며, 자신의 dispatcher에 묶인 모든 connection의 m_syncState에 의해 살아있는 상태로 유지됩니다(예를 들어 main UIProcess connection과 RemoteRenderingBackendProxy GPUProcess connection). 이 객체의 destructor는 syncMessageStateMapLock을 잡고 global map에서 해당 dispatcher의 entry를 제거합니다. Connection::invalidate()는 dispatcher thread에서 실행되며, 자신의 m_syncState reference를 m_incomingMessagesLock 아래에서 해제합니다. 바로 이 lock이 connection work queue에서 "m_syncState를 읽고, 사용하고, 해제하는" 구간을 invalidate()와 직렬화해주는 장치입니다.

unlockEarly()와 destruction 순서. Locker는 scope exit 시 자동으로 release되는 RAII lock guard입니다. unlockEarly()를 호출하면 즉시 release되어 이후 코드가 unlocked 상태로 실행됩니다. WebKit은 hot IPC path에서 unlockEarly()를 의도적으로 사용합니다. Reply handler를 실행하는 동안 IPC lock을 계속 잡고 있으면, 그 reply 뒤에 대기 중인 다른 message들이 막히기 때문에 reply handler는 unlocked 상태로 실행되도록 설계되어 있습니다. 한편 C++은 scope exit 시 automatic local들을 선언 순서의 역순으로 destroy합니다. 이는 언어 규칙일 뿐이지만, 이번 문제에서는 이 규칙이 핵심적으로 작용합니다. Locker보다 먼저 선언된 RefPtr은 그 Locker보다 나중에 destroy됩니다.

Root cause는 unlockEarly()와 C++의 scope-exit 순서 사이의 상호작용에서 비롯된 data race이며, 그 결과 ThreadSafeRefCounted 객체에 대한 use-after-free가 발생합니다. unlockEarly()는 enclosing scope가 끝나기 전에 m_incomingMessagesLock을 미리 해제하지만, 두 locker보다 앞서 선언된 syncState는 이들보다 나중에 destroy됩니다. 따라서 함수 return 시점에 이루어지는 syncState의 release — 마지막 reference라면 ~SyncMessageState()를 트리거할 수 있는 시점 — 는 이미 m_incomingMessagesLock이 풀린 상태에서 실행됩니다.

  Work-queue thread                Dispatcher thread
  ─────────────────                ─────────────────
  processIncomingMessage()
    reply handler runs
    unlockEarly()  (lock released)
    ...return...
    ~RefPtr syncState  ─┐          Connection::invalidate()
    last deref          │            drops m_syncState under
    ~SyncMessageState() │            m_incomingMessagesLock
      takes             │            (its own last-ref path)
      syncMessageStateMapLock ◄────► concurrent teardown of the
                                      same shared object -> UAF

마지막 deref가 unlocked 상태로 이동하면서, work-queue thread의 ~SyncMessageState()syncMessageStateMapLock 아래에서 syncMessageStateMap()에 진입해 dispatcher entry를 제거하는 코드 — 가 동일한 공유 객체에 대한 invalidate()의 동시 teardown과 race를 벌일 수 있게 됩니다. Commit message에는 이 문제가 WebPage teardown 중 ASan이 잡아낸 heap-use-after-free로 보고되어 있으며, 경로는 ~WebPage → ~RemoteRenderingBackendProxy → disconnectGPUProcess → Connection::invalidate()입니다.

Exploitability는 connection work-queue thread와 dispatcher thread의 invalidate() 사이에서 destruction race를 attacker가 안정적으로 이길 수 있는지에 달려 있습니다. 만약 이것이 가능하다면, ThreadSafeRefCounted 객체에 대한 use-after-free가 controlled heap 조건 하에서 refcount나 map state를 손상시킬 가능성이 있습니다. 다만 이를 실제 memory-corruption primitive로 발전시키려면 이번 변경 자체가 제공하지 않는 완전한 exploit chain과 결정적인 race 제어가 추가로 필요합니다.

이 취약점은 racing connection을 소유한 process 안에서 memory safety를 약화시킵니다(보고된 teardown은 WebContent process의 GPUProcess connection에서 발생합니다). 여기서 깨지는 security model의 전제는, 공유된 per-dispatcher SyncMessageState에 대한 접근과 destruction이 m_incomingMessagesLock으로 직렬화된다는 가정입니다. 이번 regression은 정확히 그 가정을 위반했습니다.

이는 전형적인 C++ destruction-order 함정에 해당합니다. Smart-pointer owner가, 자신이 반드시 lock 아래에서 해제되어야 하는데도 그 lock guard보다 먼저 선언된 경우입니다. unlockEarly()는 "Locker 아래에 있는 모든 코드는 locked 상태로 실행된다"는 가정을 은연중에 깨뜨리는데, Locker보다 위에 선언된 automatic local들은 여전히 그 Locker보다 나중에 destruct됩니다. Callback을 unlocked 상태로 실행하기 위해 unlockEarly()를 사용하는 함수라면, lock guard보다 먼저 선언된 모든 RAII local 중 destructor가 cross-thread side effect를 갖는 것이 있는지 반드시 점검해야 합니다. 316617@main 변경이 다른 두 분기에서는 정확성을 유지할 수 있었던 이유는, 단지 그 분기들에는 조기 unlock 이후까지 살아남는 그런 owner가 우연히 없었기 때문입니다.

Note: 이번 regression은 commit message에 의해 316617@main으로 귀속되며, heap-use-after-free와 teardown 경로는 ASan report로 보고되어 있습니다. 다만 이전 commit과 해당 report 자체는 제공된 context에 포함되어 있지 않으므로, 이 출처 표기는 원문 그대로 인용합니다.