fd4e928e7b3e5e2e11528a587c4330a1852192cb [parser] Store stable element identity in adoption agency Bookmark
The bug at a glance
A raw interior Entry* pointer into the HeapVector backing store of the active formatting elements list could dangle when re-entrant script (via synchronously-dispatched events during DOM reparenting in the adoption agency algorithm) appends enough formatting elements to force the vector to reallocate, giving a use-after-free / use-after-poison write when SwapTo() resumes. It is reachable from any untrusted HTML page and the two added crashtests demonstrate deterministic triggering, hence high severity renderer memory corruption.
An attacker-controlled web page crafts mis-nested formatting tags plus a custom-element connectedCallback or iframe pagehide listener that calls document.write() during the adoption agency algorithm, reallocating the formatting-element vector and causing the parser to write through a stale interior pointer—a controllable renderer heap UAF suitable for a sandbox-escape exploit chain.
Root cause
HTMLFormattingElementList::Bookmark stored a raw Entry* pointing into the entries_ HeapVector buffer. During HTMLTreeBuilder::CallTheAdoptionAgency(), step 4.14 reparents the furthest-block, which can synchronously fire events (custom element connectedCallback, or iframe pagehide on subtree detach). A listener calling document.write() re-enters the parser and appends active formatting elements; when the count exceeds the vector capacity the HeapVector reallocates, leaving the Bookmark’s interior Entry* dangling. SwapTo() then dereferences/writes through that stale pointer (Mark()->ReplaceElement, or pointer arithmetic Mark()-First() for insert index), yielding a UAF/use-after-poison. The fix stores a stable Element* in the Bookmark and re-resolves the index dynamically via ReverseFind() in SwapTo (failing closed on kNotFound), removes the First()/pointer-arithmetic path and the 4.13.4 bookmark-regeneration workaround, and validates formatting_element and furthest_block still live in OpenElements() after the step-4.14 insertion before continuing.
Key code
SwapTo re-resolves the mark by Element identity instead of using a stale interior pointer*
- size_t index = bookmark.Mark() - First();
- SECURITY_DCHECK(index < size());
- entries_.insert(static_cast<wtf_size_t>(index + 1), new_item);
+ wtf_size_t mark_index = entries_.ReverseFind(bookmark.Mark());
+ if (mark_index != kNotFound) {
+ entries_.insert(mark_index + 1, new_item);
+ }
Patch walkthrough
third_party/blink/renderer/core/html/parser/html_formatting_element_list.h— Changes Bookmark to hold a stable Element* (default-initialized) instead of an interior Entry* and makes MoveToAfter/Mark take Element*; removes First() helper used for pointer arithmetic—eliminating the dangling-pointer class.third_party/blink/renderer/core/html/parser/html_formatting_element_list.cc— BookmarkFor now stores the Element*; SwapTo resolves old_element and the mark via ReverseFind() and bails on kNotFound, so a vector reallocation or re-entrant mutation can no longer be dereferenced through a stale pointer (fails closed).third_party/blink/renderer/core/html/parser/html_tree_builder.cc— Removes the 4.13.4 bookmark-regeneration workaround that existed only to survive Entry* vector shifts, updates MoveToAfter calls to pass node->GetElement(), and adds a post-step-4.14 guard that returns if formatting_element or furthest_block are no longer in OpenElements(), preventing use of re-entrantly-removed nodes.third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-custom-furthest-block.html— Regression crashtest triggering reallocation via a custom-element connectedCallback that document.write()s 14 formatting elements during step 4.14.third_party/blink/web_tests/external/wpt/html/syntax/parsing/crashtests/adoption-agency-pagehide-reentrant-write.html— Regression crashtest triggering the same reallocation via an iframe pagehide listener firing on subtree detach during reparenting.
Background
Adoption Agency Algorithm (AAA) — HTML parsing algorithm that repairs mis-nested formatting elements, implemented in HTMLTreeBuilder::CallTheAdoptionAgency().
Active formatting elements list — HTMLFormattingElementList backed by a HeapVector, tracking open formatting elements for AAA.
Bookmark — A marker into the formatting-element list used by AAA to insert a cloned element at the right position; previously a raw interior pointer.
Parser re-entrancy — Synchronous events (pagehide, custom element connectedCallback) fired during DOM mutation can call document.write(), re-entering the parser mid-algorithm.
Vulnerability window
- Reachability — Any HTML page parsed by Blink can enter the adoption agency algorithm with mis-nested formatting tags.
- Trigger — Step 4.14 reparenting of the furthest-block synchronously dispatches pagehide/connectedCallback whose listener calls document.write().
- Primitive — Re-entrant appends grow the formatting-element HeapVector past capacity, reallocating it and dangling the Bookmark’s interior Entry* (use-after-free).
- Escalation — SwapTo() writes through the stale pointer (ReplaceElement / insert-index arithmetic), giving a controllable heap write usable in an exploit chain (inferred).
Proof of concept
Conceptually: emit mis-nested formatting tags (e.g. …<div …>) so AAA runs, with the furthest-block being an element whose detach/reparent synchronously fires an event (custom element connectedCallback, or iframe pagehide). In that handler call document.write() to append enough new formatting elements (using distinct attributes to bypass Noah’s Ark eviction) to exceed the active-formatting-elements HeapVector capacity, forcing a reallocation; when SwapTo() resumes it dereferences the now-dangling Bookmark pointer. The two added crashtests describe this exact structure.
Exploitation
- Setup — Serve HTML with mis-nested formatting elements that reach the adoption agency algorithm.
- Re-entry — Install an event listener (pagehide/connectedCallback) that fires during step-4.14 reparenting and calls document.write().
- Reallocation — Append >capacity formatting elements so the HeapVector relocates, dangling the Bookmark pointer.
- Corruption — SwapTo() reads/writes through the freed interior pointer; grooming the freed slot would yield a controlled write primitive (inferred).
Detection & hunting
For defenders and detection engineers:
- weggli for raw interior pointers (&at(i), ptr - First()) stored across code that can run script — Flag any cached pointer into a HeapVector/WTF::Vector buffer held across DOM mutation or event dispatch in the parser.
- grep adoption agency / HTMLFormattingElementList for Entry members and SECURITY_DCHECK index arithmetic* — Audit for pointer-vs-identity assumptions that break on reallocation.
Audit directions
- HTMLTreeBuilder and other parser code holding pointers across InsertAlreadyParsedChild / reparenting — Verify no raw element/entry pointers survive synchronous event dispatch; prefer identity-based re-resolution with fail-closed checks.
- Other HeapVector-backed Blink lists cached by interior pointer across script-reentrant callbacks — Look for Bookmark-like markers, iterators, or indices assumed stable while re-entrant document.write()/custom-element callbacks can mutate the container.