cf20d123b3 Concurrent HashMap access leads to 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
- WebGPU HDR canvas — A page uses a WebGPU canvas with extended range; GPUCanvasContextCocoa must compute display EDR headroom via updateScreenHeadroomFromScreenProperties().
- Off-main read — WebGPU work runs on a worker/GPU thread and iterates getScreenProperties().screenDataMap.values() with no lock.
- Concurrent mutation — The main thread updates the global ScreenProperties (display reconfiguration / setScreenProperties), inserting/rehashing the HashMap.
- Race — The worker iterator dereferences relocated/freed buckets; MTE flags the tag mismatch and the process crashes.
- 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.
- 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
- Establish threads — Get GPUCanvasContextCocoa headroom computation running on a non-main thread via WebGPU canvas presentation while the main thread mutates the global ScreenProperties.
- 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.
- 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.
- 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.