← All reports

[4] Iterator invalidation in `Page::forEachPage` and its sibling walkers

MediumWebCore PageUAF

60fe8ff

Medium. A caller-supplied callback invoked inside a live hash-set traversal can construct or destroy a Page, and either one can rehash the container out from under the loop cursor. Reaching it requires a callback that actually creates or destroys pages, which narrows the triggering paths considerably.

Iterator invalidation is a C++ container rule with a browser-shaped failure mode: walk a hash set while something inside the loop body adds or removes an element, and the cursor you hold points into a buffer that no longer exists. WebCore keeps a process-global registry of every live Page — the per-browsing-context root object that owns a frame tree, settings, and the focus and back/forward controllers — which a Page joins and leaves over its lifetime. Four static helpers fan work out across that registry, and each hands every Page to code that is free to do anything, including make another page.

The angle: a callback reachable from one of the four registry walkers that constructs or destroys a Page turns the loop into a read of freed heap, with the following iteration performing a reference-count write through a pointer taken out of the freed bucket array.

Four call sites share the same shape and all four are changed. Page::forEachPage held an iterator into allPages() and, for each element, invoked an arbitrary caller-supplied Function<void(Page&)>, materialising Ref { page.get() } per element. clearPreviousItemFromAllPages walked the same set with a loop body reaching into page->localMainFrame() and back/forward item state. updateStyleForAllPagesAfterGlobalChangeInEnvironment ran a full style update per page, and updateControlTintsForAllPages a tint update. The change is to stop holding a live iterator into allPages() across the call-out in each of them; the per-element Ref temporaries that three of the four already carried were never the relevant protection, since they extend the current Page's lifetime and say nothing about the container's backing store.

  for (auto& page : allPages())        allPages() bucket array
  ───────────────────────────────      ───────────────────────
  it ──► bucket[3]                     [ .. .. p0 p1 .. ]  @0xA000
  callback(p1)
    └─ constructs a Page ──► add()     load factor exceeded:
                                       alloc @0xB000, rehash, free(0xA000)
  ++it  ──► 0xA000 + stride            (freed)
  Ref { page.get() }                   refcount write via pointer read
                                       out of the freed bucket array  ← UAF

An arbitrary callback invoked inside a live container traversal, where the callback can structurally modify the container being walked.

Where this lives. Page is the renderer-side root of one browsing context: it owns the frame tree, the settings object, the browser chrome client, and the focus and back/forward controllers. Because a great deal of global state in WebCore is nominally per-page — style environment, control tints, back/forward items — there are helpers whose job is to apply a change to every Page in the process, and those helpers need a registry to walk.

The global registry. allPages() is that registry: a process-global hash set that a Page joins on construction and leaves on destruction. The set holds a non-owning reference wrapper rather than strong Ref<Page> entries — the pre-fix page.get() and page-> usage, plus the explicit Ref { ... } temporary three of the four sites needed, are what identify it, consistent with Page.h including both <wtf/WeakHashSet.h> and <wtf/RobinHoodHashSet.h>.

Open addressing and rehashing. WTF's hash sets store entries in a single contiguous bucket array. An insertion that pushes the table past its load factor allocates a larger array, rehashes the existing entries into it, and frees the old one; removals can trigger a shrink and rehash on the same mechanics. An iterator is a raw cursor into that array — not a handle the container knows about — so a reallocation leaves every live iterator pointing at freed memory with no diagnostic.

Ref and what it protects. Ref<T> is WebKit's non-null strong reference: constructing one increments the referent's count, and destroying it decrements. It keeps the object it names alive. It has no relationship to the container the object was found in, which is the distinction that matters here.

The bug class is use-after-free via iterator invalidation, driven by re-entrancy. The missing invariant is the one every open-addressed hash container requires: the container must not be structurally modified while an iterator into it is live. Because a Page registers itself in allPages() when it is built and unregisters when it is destroyed, any callback that synchronously constructs or destroys a Page mutates the exact set being walked.

Follow the diagram's sequence. The for loop's cursor is a raw pointer into the bucket array at 0xA000. Inside the loop body, a Page construction reached from the callback calls add(), the table crosses its load factor, a larger array is allocated at 0xB000, entries are rehashed into it, and 0xA000 is freed — while the loop still holds a cursor into it. The next increment and the next element read touch freed heap. In forEachPage the pre-fix code then built Ref { page.get() } from whatever words happen to occupy that freed block, which means it performed a reference-count operation through a pointer read out of freed memory; in clearPreviousItemFromAllPages the freed-block read fed page->localMainFrame() directly. Destruction is the mirror case: a removal can trigger a shrink and rehash with the same outcome.

The per-element Ref temporary present in three of the four sites is the detail most likely to mislead a reviewer. It looks like lifetime hygiene and it is — for the current Page. The object that actually gets freed is the bucket array, which no Ref in this code refers to.

Exploitability turns on what a reachable callback can do. The most direct primitive is the refcount write in forEachPage: an increment and later a decrement applied at an offset into a freed allocation, which is the shape an attacker grooms for when they can control what reclaims the block. Building that into anything further requires deterministic control over both the reallocation timing and the contents of the reclaimed block, neither of which this change establishes; and the reachable trigger set is limited to callbacks that create or destroy pages, which is a much smaller surface than "anything script can call".

This vulnerability weakens the integrity of a process-global registry that every page-wide broadcast in WebCore walks — a container whose corruption is observed by code far away from wherever the offending callback was written.