d2af128b14 Crash when rehashing DOMWrapperWorld::m_wrappers hash map
Triage note: Mitigation/diagnostic hardening targeting a memory-corruption crash in the wrapper hash map during rehashing.
Contents
The bug at a glance
This commit is NOT a vulnerability fix; it is opt-in, sampled diagnostic instrumentation to locate an unresolved memory-corruption bug that crashes high volumes of WebContent processes in JSC::WeakImpl::clear() while rehashing DOMWrapperWorld::m_wrappers. The underlying defect is a genuine heap corruption (a stray write leaving a garbage WeakImpl pointer, with the same reused-memory fingerprint appearing in unrelated tables like CodeBlockSet) that Apple could not reproduce even under ASAN. Severity of the underlying bug is potentially high, but this change itself only adds guard-page detection, so medium reflects that it is tooling, not a patch.
The wrapper map’s hash-table backing is corrupted by some external stray write that only manifests much later as a crash during an unrelated rehash. The angle is turning that silent, delayed corruption into an immediate fault: place the m_wrappers backing in its own mmap’d pages and keep them read-only except during the world’s own legitimate mutations, so the actual corrupting writer traps at the moment of the write with its stack intact.
Root cause
DOMWrapperWorld::m_wrappers is a DOMObjectWrapperMap = HashMap<void*, JSC::Weak<JSC::JSObject>> mapping native DOM objects to their JS wrappers. Entries are added/removed by cacheWrapper/uncacheWrapper (via weakAdd/weakRemove) and cleared by clearWrappers. Apple observed a high volume of crashes in JSC::WeakImpl::clear() during the HashMap’s rehash: a bucket’s Weak<JSObject> held a garbage WeakImpl pointer, i.e. the table backing had been overwritten by something. The same reused-memory fingerprint showed up corrupting other WTF hash tables (CodeBlockSet), pointing at a wild write rather than a logic bug local to the wrapper map, and it was not reproducible locally even with ASAN and stress tests.
Because ASAN could not catch it, this patch installs a guard-page tripwire specifically around m_wrappers. On Cocoa, DOMObjectWrapperMap is re-declared as a HashMap parameterized with a custom allocator WrapperMapTableMalloc and ShouldValidateKey::Yes. When per-process guarding is enabled, WrapperMapTableMalloc::allocate mmaps the table backing into its own page(s) (rounded to pageSize, PROT_READ|PROT_WRITE), records base->size in a lock-protected backingRegistry (so free() can munmap the right length), and reports the backing to the currently mutated world via noteTableBacking. free() munmaps and forgets it.
The backing is kept PROT_READ-only between mutations. Every legitimate mutation is bracketed by a WrapperMutationScope RAII guard (added at cacheWrapper, uncacheWrapper, and clearWrappers). Its enter() sets a thread_local s_active and calls setWrappersTableWritable(true); leave() restores read-only. setWrappersTableWritable ref-counts nesting via m_wrappersTableWritableDepth and mprotects m_wrappersTableBase to PROT_READ (+ PROT_WRITE only while writable). Because a rehash reallocates the backing mid-scope, WrapperMapTableMalloc::allocate consults currentlyMutatedWorld() and updates noteTableBacking so the scope re-protects the new table. The destructor path (and ~DOMWrapperWorld, which must let the HashMap destructor free buckets) temporarily restores PROT_READ|PROT_WRITE.
Guarding is sampled: gGuardWrapperMaps is chosen once per process in initializeWrapperMapGuardingOnce as !(weakRandomNumber<unsigned>() % 64) (~1/64). When false, WrapperMapTableMalloc::malloc/zeroedMalloc fall back to WTF::fastMalloc/fastZeroedMalloc and the scopes are cheap no-ops, avoiding the ~1.2% Speedometer cost of the two mprotect calls per mutation. In a guarded process, any stray write from outside a mutation scope hits a read-only page and faults immediately at the corrupting instruction, capturing the true writer’s stack instead of a downstream rehash crash.
Key code
Depth-counted mprotect that keeps the m_wrappers backing read-only between mutations (DOMWrapperWorld.cpp).
void DOMWrapperWorld::setWrappersTableWritable(bool writable)
{
if (writable) {
if (m_wrappersTableWritableDepth++)
return;
} else {
ASSERT(m_wrappersTableWritableDepth);
if (--m_wrappersTableWritableDepth)
return;
}
if (m_wrappersTableBase)
RELEASE_ASSERT(!mprotect(m_wrappersTableBase, m_wrappersTableSize, PROT_READ | (writable ? PROT_WRITE : 0)));
}
Patch walkthrough
Source/WebCore/bindings/js/DOMWrapperWorld.h— On Cocoa, redefines DOMObjectWrapperMap to use the WrapperMapTableMalloc allocator (and ShouldValidateKey::Yes), declares the WrapperMapTableMalloc struct and the extern gGuardWrapperMaps flag, adds the WrapperMutationScope RAII class, and adds per-world state (m_wrappersTableBase, m_wrappersTableSize, m_wrappersTableWritableDepth) plus noteTableBacking/forgetTableBacking/setWrappersTableWritable. Off Cocoa everything collapses to a plain HashMap and empty no-op scope.Source/WebCore/bindings/js/DOMWrapperWorld.cpp— Implements the facility: initializeWrapperMapGuardingOnce (1/64 sampling via weakRandomNumber), the lock-protected backingRegistry for mmap sizes, WrapperMapTableMalloc::allocate/malloc/zeroedMalloc/free (mmap+register vs FastMalloc fallback), setWrappersTableWritable (depth-counted mprotect), WrapperMutationScope::enter/leave, initialization in the DOMWrapperWorld constructor, the destructor’s re-enabling of writes before the HashMap is torn down, and wrapping m_wrappers.clear() in clearWrappers with a WrapperMutationScope.Source/WebCore/bindings/js/JSDOMWrapperCache.h— Adds a WrapperMutationScope around the actual table mutations: before weakAdd(world.wrappers(), …) in cacheWrapper and before weakRemove(…) in uncacheWrapper, so those legitimate writes occur while the backing is writable (and are no-ops when guarding is off).
Background
DOMWrapperWorld::m_wrappers — Each DOMWrapperWorld holds a hash map from native DOM objects (void*) to their JavaScript wrapper via JSC::Weak<JSObject>. It is central to bindings: cacheWrapper/uncacheWrapper keep it in sync as wrappers are created and collected. Corruption of this table surfaces during GC-time weak processing.
JSC::WeakImpl and Weak<JSObject> — A Weak<JSObject> holds a WeakImpl that the collector clears when the referent dies. WeakImpl::clear() dereferences that pointer; if the table bucket holding the Weak was overwritten with garbage, clear() operates on a bogus WeakImpl, which is where the observed crashes cluster.
HashMap rehash — When a WTF HashMap grows or shrinks it reallocates its backing and re-inserts entries, touching every bucket. A latent corruption written earlier into a bucket is often first dereferenced during this rehash, so the crash site (rehash) is far removed in time and stack from the actual corrupting write.
Guard-page / mprotect tripwire — Placing a data structure in its own mmap’d pages and marking them PROT_READ makes any write fault (SIGBUS/SIGSEGV) at the offending instruction. Keeping the wrapper backing read-only except inside sanctioned mutation scopes converts a silent stray write into an immediate, attributable crash with the writer’s stack.
Per-process sampling — Always-on mprotecting costs two syscalls per mutation (~1.2% Speedometer). The patch decides once per process (weakRandomNumber % 64) whether to guard, so ~1/64 of processes pay full cost for full fidelity while the fleet-average overhead stays negligible and unguarded processes use FastMalloc with no-op scopes.
Vulnerability window
- Crash telemetry — A high volume of field crashes appears in JSC::WeakImpl::clear() during DOMWrapperWorld::m_wrappers rehash, with buckets holding garbage WeakImpl pointers.
- Correlation — The same reused-memory corruption fingerprint is seen in unrelated WTF hash tables (e.g. CodeBlockSet), indicating a wild write rather than a bug local to the wrapper map.
- Reproduction failure — The corruption cannot be reproduced locally even with ASAN and stress tests, so ordinary sanitizers are insufficient to catch the writer.
- Diagnostic added — webkit.org/b/317371 (rdar://179144468, referencing rdar://157587352): Chris Dumez adds sampled guard-page instrumentation around m_wrappers to fault at the corrupting write; reviewed by Ben Nham. Canonical 315505@main.
- Temporary by design — The commit message states this is a temporary facility to be removed once the root cause is found; it does not itself fix the corruption.
Triggering
No PoC exists and none is expected: this is diagnostic tooling for a bug that Apple explicitly could not reproduce locally even with ASAN and stress tests. The corruption is observed only as field crashes in JSC::WeakImpl::clear() during m_wrappers rehash. There is no known trigger; the instrumentation’s purpose is precisely to discover it by faulting the unknown stray writer in the ~1/64 sampled processes.
Exploitation
- Nature of the underlying bug — The real defect is a stray/wild write into heap memory that lands on the wrapper-map backing (and other hash tables), leaving a corrupted refcounted-weak pointer. Such wrapper-map corruption is a memory-safety issue with potential for exploitation, but its source is unknown.
- This commit’s role — It adds no attacker-relevant surface and provides no primitive; it only detects the corruption. If anything it slightly changes the allocation of m_wrappers (mmap vs FastMalloc) in sampled processes.
- Crash-only observation — All that is publicly established is a crash (WeakImpl::clear on garbage). Whether the corruption is controllable/attacker-influenced is unresolved, so no exploitation chain can be responsibly asserted from this diff.
Detection & hunting
For defenders and SOC / detection engineers:
- WeakImpl::clear() crashes during rehash — Cluster crash reports on JSC::WeakImpl::clear() with a HashMap rehash frame and DOMWrapperWorld::m_wrappers context; these are the signature of the corruption this facility targets.
- Guard-page faults in sampled processes — After deployment, faults on the mprotected m_wrappers backing (write to a PROT_READ page) captured in guarded (~1/64) processes directly identify the stray writer’s stack; these are the intended high-value signal.
- Shared corruption fingerprint across tables — Look for the same reused-memory pattern corrupting CodeBlockSet or other WTF hash tables, which indicates a common wild-write source rather than a wrapper-map-specific logic bug.
Audit directions
- Wrapper lifetime and re-entrancy — Audit cacheWrapper/uncacheWrapper/clearWrappers and weakAdd/weakRemove for any path that mutates m_wrappers outside a WrapperMutationScope, or that re-enters during a rehash, which would either miss the guard or fault legitimately.
- Cross-table wild-write source — Since the fingerprint recurs in CodeBlockSet and other tables, investigate allocators/pools whose freed memory is reused by these hash tables and any component writing through stale pointers into that memory.
- Weak/WeakImpl handling under GC — Review JSC weak-reference clearing and finalization ordering relative to DOMWrapperWorld teardown (the destructor’s PROT_READ|PROT_WRITE re-enable is a hint that ordering is subtle) for lifetime bugs that could leave dangling WeakImpls.
- Correctness of the guard facility itself — Verify the backingRegistry locking, noteTableBacking updates on rehash-time reallocation, and depth counting in setWrappersTableWritable cannot leave the table wrongly writable (defeating detection) or wrongly read-only (faulting legitimate writes).