← WebKit Silent-Fix Report — 2026-W22

cf20d123b3  Concurrent HashMap access leads to MTE crashes

severity medium class Race confidence 0.70 WebCore GPUCanvasContextCocoa exploitable-grade
Mike Wyrzykowski Thu May 28 19:34:45 2026 -0700 full: cf20d123b3b1aef8ba0ecfe83ee2b493e33f28e8 bug report ↗ view on GitHub ↗
Primitive: data race on screen-properties HashMap causing MTE crashes
Triage note: Message and diff indicate a threading/data-race fix by caching screen state and gating updates.
Contents

The bug at a glance

A data race / memory-safety fix: GPUCanvasContextCocoa read WebCore::getScreenProperties() and iterated its screenDataMap HashMap from a worker thread while the main thread could be mutating that same map, producing MTE-detected crashes on Apple hardware. It is a real concurrency UAF/type-confusion class over a HashMap, WebGPU-reachable from web content, but requires a specific main/worker timing race and the corrupted data is display headroom state, so medium. This is the branch-landed precursor to the fuller PlatformScreen singleton rework (08911bd034).

getScreenProperties() returns a reference to a process-global ScreenProperties whose screenDataMap is a plain HashMap owned by the main thread. WebGPU worker code iterated .screenDataMap.values() with no synchronization, so a concurrent main-thread mutation (rehash/insert on display reconfiguration) could leave the worker walking freed/relocated buckets. The fix forces every getScreenProperties() read to occur on the main thread (bouncing via callOnMainThread + BinarySemaphore when off-main) and adds an ASSERT(isMainThread()) tripwire.

Root cause

GPUCanvasContextCocoa tracks display EDR (extended dynamic range) headroom so that HDR WebGPU canvases can adapt. Its updateScreenHeadroomFromScreenProperties() (renamed here to …IfNeeded) iterated WebCore::getScreenProperties().screenDataMap.values() to compute the maximum currentEDRHeadroom and OR together suppressEDR flags. surfaceBufferToImageBuffer() and setDynamicRangeLimit() call into this path, and WebGPU work can run on a worker/GPU thread rather than the main thread.

WebCore::getScreenProperties() hands back a reference to a single process-wide ScreenProperties instance (a NeverDestroyed) whose screenDataMap is an ordinary WTF::HashMap. That map is populated and updated on the main thread (screen reconfiguration, WebProcess::setScreenProperties). When a worker thread iterates values() concurrently with a main-thread insert/remove/rehash, the iterator can dereference reallocated or freed bucket storage – a textbook HashMap data race. On MTE-equipped devices the mismatched allocation tag is caught immediately and reported as a crash, which is how the bug surfaced.

The patch keeps the read on the main thread. In updateScreenHeadroomFromScreenPropertiesIfNeeded(), the gathering loop is wrapped in a lambda gatherScreenProperties; if isMainThread() it runs directly, otherwise it is dispatched with callOnMainThread and the calling thread blocks on a BinarySemaphore until the main thread finishes, so the HashMap is only ever iterated on its owner thread. It also caches per-context screen state (m_screenEDRHeadroom, m_screenSuppressEDR) and splits headroom application into updateHeadroomFromScreenProperties()/updateScreenHeadroom, gates work behind m_layerContentsDisplayDelegate->hasExtendedRange() (only RGBA16F/extended-range canvases need it), and rewrites the observer callback to cache the screenData fields instead of iterating. Finally PlatformScreen.cpp’s screenProperties() accessor gains ASSERT(isMainThread()) so any remaining off-main access trips in debug.

Note this fix still exposes the underlying getScreenProperties() global; commit 08911bd034 later removes those free functions entirely in favor of a thread-safe, copy-on-write PlatformScreen singleton – this commit is the targeted, branch-landed mitigation.

Key code

Force the screenDataMap iteration onto the main thread with a blocking hop (GPUCanvasContextCocoa.mm)

    auto gatherScreenProperties = [&] {
        for (const auto& screenData : WebCore::getScreenProperties().screenDataMap.values()) {
            maxEDRHeadroom = std::max(maxEDRHeadroom, screenData.currentEDRHeadroom);
            suppressEDR |= screenData.suppressEDR;
        }
    };

    if (isMainThread())
        gatherScreenProperties();
    else {
        BinarySemaphore semaphore;
        callOnMainThread([&gatherScreenProperties, &semaphore] {
            gatherScreenProperties();
            semaphore.signal();
        });
        semaphore.wait();
    }

Patch walkthrough

  • Source/WebCore/html/canvas/GPUCanvasContextCocoa.mm — updateScreenHeadroomFromScreenProperties() is renamed to updateScreenHeadroomFromScreenPropertiesIfNeeded() and rewritten: the screenDataMap iteration is moved into a gatherScreenProperties lambda run on the main thread directly when isMainThread(), else dispatched via callOnMainThread with a BinarySemaphore wait. hasExtendedRange() gating short-circuits non-HDR canvases. A new updateHeadroomFromScreenProperties() applies cached m_screenEDRHeadroom/m_screenSuppressEDR, and the ScreenPropertiesChangedObserver callback now caches those fields instead of iterating. setDynamicRangeLimit() and surfaceBufferToImageBuffer() are updated to call the IfNeeded variant.
  • Source/WebCore/html/canvas/GPUCanvasContextCocoa.h — Declares the renamed updateScreenHeadroomFromScreenPropertiesIfNeeded() and new updateHeadroomFromScreenProperties(); adds cached members float m_screenEDRHeadroom {0.f} and bool m_screenSuppressEDR {false} so the observer callback can store screen state without touching the global map off-main.
  • Source/WebCore/platform/PlatformScreen.cpp — Adds ASSERT(isMainThread()) at the top of the internal screenProperties() accessor, turning any off-main access to the process-global ScreenProperties into a debug tripwire and documenting the main-thread-only contract.

Background

getScreenProperties() — A WebCore free function returning a reference to a process-global ScreenProperties (a NeverDestroyed) whose screenDataMap is a plain HashMap. Intended to be read on the main thread; it offers no internal synchronization.

EDR headroom / suppressEDR — Per-display extended dynamic range parameters (currentEDRHeadroom, suppressEDR) used to scale HDR content. GPUCanvasContextCocoa aggregates them across displays to decide the canvas’s dynamic range.

WTF HashMap concurrency — WTF::HashMap is not thread-safe; concurrent iteration during a mutation (insert/remove/rehash) can read relocated or freed bucket storage. On MTE hardware such stale reads are caught as tag-mismatch faults.

MTE (Memory Tagging Extension) — An ARM hardware feature that tags allocations and checks pointer tags on access; it turns latent races/UAFs into deterministic crashes, which is why this race began reporting as MTE crashes.

callOnMainThread + BinarySemaphore — The idiom used to run a closure on the main thread from a worker and block until it completes, guaranteeing main-thread-only access to shared state without persistent locks.

Vulnerability window

  1. WebGPU HDR canvas — A page uses a WebGPU canvas with extended range; GPUCanvasContextCocoa must compute display EDR headroom via updateScreenHeadroomFromScreenProperties().
  2. Off-main read — WebGPU work runs on a worker/GPU thread and iterates getScreenProperties().screenDataMap.values() with no lock.
  3. Concurrent mutation — The main thread updates the global ScreenProperties (display reconfiguration / setScreenProperties), inserting/rehashing the HashMap.
  4. Race — The worker iterator dereferences relocated/freed buckets; MTE flags the tag mismatch and the process crashes.
  5. Fix — The iteration is hopped to the main thread (direct if isMainThread, else callOnMainThread + BinarySemaphore), and an ASSERT(isMainThread()) guard is added to the global accessor.
  6. Follow-up — 08911bd034 removes the global free functions entirely in favor of a thread-safe copy-on-write PlatformScreen singleton.

Triggering

No test added (a timing race). Conceptual trigger: create a WebGPU (extended-range / RGBA16F) canvas that drives updateScreenHeadroom work off the main thread (e.g. via surfaceBufferToImageBuffer/setDynamicRangeLimit on a worker), while repeatedly forcing main-thread ScreenProperties updates (display configuration changes / setScreenProperties) so the worker iterates screenDataMap.values() during a concurrent rehash; on MTE hardware this reliably faults.

Exploitation

  1. Establish threads — Get GPUCanvasContextCocoa headroom computation running on a non-main thread via WebGPU canvas presentation while the main thread mutates the global ScreenProperties.
  2. Win the race — Iteration over a HashMap mid-rehash yields reads of freed/relocated bucket memory – a use-after-free / type-confusion read of ScreenData entries.
  3. Influence layout — An attacker with control over allocation churn might reoccupy freed bucket storage, but the consumed data is EDR headroom floats/bools, so a direct control-flow primitive is not evident; the practical outcome is corruption/crash.
  4. Reliability on MTE — On MTE devices the race is caught immediately as a crash (DoS-grade); on non-MTE it is a silent race that could yield inconsistent headroom or latent corruption.

Detection & hunting

For defenders and SOC / detection engineers:

  • MTE crash iterating screenDataMap off the main thread — Crash logs showing GPUCanvasContextCocoa::updateScreenHeadroom* iterating getScreenProperties().screenDataMap from a worker/GPU thread are the signature.
  • ASSERT(isMainThread()) in PlatformScreen::screenProperties() — In debug/asserts-enabled builds, the newly added assertion fires on any off-main access to the global ScreenProperties, pinpointing offenders.
  • ThreadSanitizer reports on ScreenProperties — TSan data-race reports naming the global screenProperties()/screenDataMap with one main-thread writer and one worker-thread reader confirm the class of bug.

Audit directions

  • All getScreenProperties()/screenData() callers — Enumerate every caller of the WebCore screen accessors and verify each runs on the main thread; the ASSERT added here plus 08911bd034’s singleton refactor indicate this was pervasive, so off-main callers elsewhere (media, PDF, model) deserve the same scrutiny.
  • WebGPU/GPUProcess threading — Audit GPUCanvasContextCocoa and neighboring Cocoa GPU code for other reads of main-thread-owned WebCore singletons (Settings, screen state, color spaces) from GPU/worker threads.
  • BinarySemaphore main-thread hops — Confirm the new callOnMainThread + semaphore.wait() cannot deadlock (e.g. if invoked while the main thread is blocked awaiting the worker), and that it is not on a hot per-frame path introducing jank.
  • HashMap-returning global accessors — Search for other process-global NeverDestroyed containers exposed by-reference and iterated without locks; the same concurrent-iteration hazard applies wherever a mutable global map is read cross-thread.

Before / after

Loading diff…