← WebKit Silent-Fix Report — 2026-W22

4f359a0df3  Crash when recreating option::checkmark

severity medium class UAF confidence 0.70 WebCore render tree form controls exploitable-grade
Anne van Kesteren Sat May 30 00:51:00 2026 -0700 full: 4f359a0df3734d9b3081fc937eb10563241c448f bug report ↗ view on GitHub ↗
Primitive: stale beforeChild after destroying pseudo-element
Triage note: Destroying the existing pseudo-element left beforeChild dangling; advancing it fixes a use-after-free during render-tree update.
Contents

The bug at a glance

A renderer use-after-free reachable purely from author CSS/DOM on a base-appearance <select>: an author rule makes ::checkmark content depend on :checked, and changing the selection re-runs updatePseudoElement, which frees the existing ::checkmark and then re-attaches a fresh one at a now-dangling beforeChild. The crash lands in RenderTreeBuilder::Block::attach, i.e. inside the render tree with a freed RenderObject* still in hand. It is content-triggerable without special privileges, which argues for more than low; but it is a render-tree-update UAF whose freed object is a pseudo-element renderer and the corrupt pointer is consumed almost immediately, giving a limited grooming window, so medium is appropriate rather than high.

The bug is not in destroying the pseudo-element, it is that the local beforeChild variable still names the object that was just destroyed. On the first update ::checkmark is inserted as renderer.firstChild(); on the second update firstChild() is the existing ::checkmark, so beforeChild == existingPseudoElement. Destroying existingPseudoElement leaves beforeChild pointing at freed memory, and the immediately following re-attach threads that dangling pointer into the render-tree builder.

Root cause

RenderTreeBuilder::FormControls::updateAfterDescendants handles the internal pseudo-elements of form controls. For an <option> in a base-appearance <select> (appearance: base-select), it synthesizes an ::checkmark pseudo and inserts it as the option’s first child by passing renderer.firstChild() as the beforeChild anchor to updatePseudoElement. beforeChild is the sibling before which the new renderer is spliced, so passing firstChild() makes the checkmark the leading child.

updatePseudoElement is re-entrant across style resolutions. When the pseudo’s computed content changes – e.g. an author writes option:not(:checked)::checkmark { content: “.” } and the :checked state flips because the selected <option> changed – the function takes the existingPseudoElement branch: it must tear down the stale renderer and build a fresh one reflecting the new content. The problem is ordering. On the first pass the checkmark did not yet exist, so firstChild() was some other node; but after the first attach the checkmark is itself the first child, so on the content-change pass the caller has computed beforeChild = renderer.firstChild() = existingPseudoElement.

The pre-patch body then calls m_builder.destroy(*existingPseudoElement) and nulls the local existingPseudoElement pointer, but beforeChild is a separate local that still holds the address of the just-destroyed renderer. The subsequent re-attach path (RenderTreeBuilder::Block::attach and friends) uses beforeChild to position the replacement pseudo, dereferencing and linking against a RenderObject that has already been freed – a classic use-after-free where the stale pointer is a sibling anchor in the render tree.

The fix advances the anchor before the free: if beforeChild == existingPseudoElement, beforeChild is reassigned to existingPseudoElement->nextSibling() (captured while the object is still alive) so that after destruction the replacement is attached in the same logical position – as the child that follows whatever came after the old checkmark – instead of before a corpse. Because ::checkmark is normally the first child, its nextSibling() is the option’s real content, which is exactly where the new checkmark should sit.

Key code

Advance the sibling anchor off the doomed pseudo-element before destroying it (RenderTreeBuilderFormControls.cpp)

    if (existingPseudoElement) {
        if (beforeChild == existingPseudoElement)
            beforeChild = existingPseudoElement->nextSibling();
        m_builder.destroy(*existingPseudoElement);
        existingPseudoElement = nullptr;
    }

Patch walkthrough

  • Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp — In updatePseudoElement, immediately inside the if (existingPseudoElement) block, a guard is added: when beforeChild aliases existingPseudoElement, beforeChild is advanced to existingPseudoElement->nextSibling() before m_builder.destroy(*existingPseudoElement) runs. This is the entire code fix; everything else in the block (destroy + null-out) is unchanged.
  • LayoutTests/fast/forms/select/base/option-checkmark-content-change-crash.html — New regression test: a base-select with two options and an author rule option:not(:checked)::checkmark { content: "." }; it activates the select, then sets select.value = “Two” and forces layout (document.body.offsetHeight), reproducing the destroy-then-reattach path that previously crashed.
  • LayoutTests/fast/forms/select/base/option-checkmark-content-change-crash-expected.txt — Expected output asserting select.value is “Two” and TEST COMPLETE, i.e. the update completes without crashing.

Background

::checkmark pseudo-element — A UA-generated pseudo-element inside each <option> of a base-appearance <select> (appearance: base-select / ::picker(select)); it renders the selected indicator and, like ::before/::after, its box is a synthesized child renderer in the render tree, subject to content: styling.

RenderTreeBuilder::FormControls::updatePseudoElement — The builder routine that creates, updates, or replaces a form control’s internal pseudo renderer. It is called from updateAfterDescendants with an explicit beforeChild so the pseudo is placed at a fixed position (first child for ::checkmark).

beforeChild anchor — Throughout RenderTreeBuilder, insertion is expressed relative to a beforeChild sibling: the new renderer is spliced in immediately before it (nullptr means append). Anchors must stay live across a destroy, or they become dangling.

m_builder.destroy() — Detaches and frees a RenderObject subtree. After it runs, any raw RenderObject* still pointing at the destroyed node (here the local beforeChild) is dangling and unsafe to dereference or link against.

RenderTreeBuilder::Block::attach — The block-container attach path that consumes beforeChild to position a newly created child. It is where the crash manifested: it dereferenced the freed pseudo as an insertion anchor.

Vulnerability window

  1. Initial layout — updateAfterDescendants calls updatePseudoElement for the <option> with beforeChild = renderer.firstChild(); the ::checkmark is created and becomes the option’s first child.
  2. State change — The selected option changes (script sets select.value, or user interaction), flipping :checked and thus the author-controlled ::checkmark content.
  3. Re-entry — Render-tree update calls updatePseudoElement again; this time renderer.firstChild() is the existing ::checkmark, so beforeChild == existingPseudoElement.
  4. Free — m_builder.destroy(*existingPseudoElement) frees the checkmark renderer; the local existingPseudoElement is nulled but beforeChild keeps the freed address.
  5. Use-after-free — The replacement pseudo is attached using the stale beforeChild; RenderTreeBuilder::Block::attach dereferences freed memory and crashes.
  6. Fix — The guard reassigns beforeChild to existingPseudoElement->nextSibling() before the destroy, so the new checkmark is placed at the equivalent live position.

Proof of concept

The added LayoutTest option-checkmark-content-change-crash.html. The :not(:checked)::checkmark content rule forces the checkmark content to differ per selection state; activating the select builds the checkmark as first child, then select.value=“Two” plus a forced layout re-runs updatePseudoElement with beforeChild == the existing checkmark, exercising the destroy-then-reattach UAF.

<!DOCTYPE html>
<html>
<head>
<script src="../../../../resources/js-test.js"></script>
<script src="../../../../resources/ui-helper.js"></script>
<style>
select, ::picker(select) {
    appearance: base-select;
}

select option:not(:checked)::checkmark {
    content: ".";
}
</style>
</head>
<body>
<select id="select">
    <option>One</option>
    <option>Two</option>
</select>
<script>
description("Changing the selected option while the picker is open must not crash when an author rule makes ::checkmark content depend on :checked.");

const select = document.getElementById("select");

jsTestIsAsync = true;

(async () => {
    await UIHelper.activateElement(select);
    await UIHelper.animationFrame();
    select.value = "Two";
    document.body.offsetHeight;
    shouldBe('select.value', '"Two"');
    finishJSTest();
})();
</script>
</body>
</html>

Exploitation

  1. Trigger — Author a base-select with an ::checkmark content rule keyed on :checked, then change the selected option to force a content-driven pseudo replacement with beforeChild aliasing the existing checkmark.
  2. Free — m_builder.destroy frees the ::checkmark RenderObject; the freed slot is a render-tree node whose type/size an attacker can influence via the surrounding option content.
  3. Reclaim — To weaponize, the freed renderer’s memory would have to be reoccupied before Block::attach reads beforeChild – a very tight window since destroy and attach are adjacent in the same synchronous update, limiting practical grooming to same-size renderer reuse.
  4. Corrupt — attach links the replacement against the stale anchor’s sibling/parent pointers; a controlled reclaim could redirect those links, but the immediate observable is a controlled-ish crash rather than a clean write primitive.

Detection & hunting

For defenders and SOC / detection engineers:

  • Crash in RenderTreeBuilder::Block::attach reached from FormControls::updatePseudoElement — ASan/GuardMalloc backtraces showing a freed RenderObject dereferenced as beforeChild during a form-control pseudo update are the signature; correlate with base-select ::checkmark styling on the page.
  • Author CSS targeting ::checkmark content with :checked/:not(:checked) — Pages that make ::checkmark (or other option pseudo) content depend on selection state and then mutate select.value are the reproduction class; flag in fuzz corpora.
  • Render-tree UAF near option/pseudo destroy — Watch for use-after-free reports whose freed allocation is a pseudo-element renderer and whose consumer is a sibling/anchor pointer immediately after a destroy call.

Audit directions

  • Other beforeChild anchors across RenderTreeBuilder — Audit every updatePseudoElement / attach caller that passes firstChild() or a cached sibling as beforeChild and then destroys a child before re-inserting; the same alias-then-free pattern can recur for ::before, ::after, ::marker, list markers, and other synthesized renderers.
  • Form-control pseudo lifecycle — Review FormControls handling of <select>/<option>/<optgroup> pseudo updates for any place that frees an existing renderer while a stale local pointer to it survives into a subsequent build step.
  • Base-appearance select internals — The appearance: base-select / ::picker(select) machinery is comparatively new; audit its render-tree construction and update ordering for other re-entrancy hazards when author styles depend on :checked/:open state.
  • nextSibling() liveness assumptions — Confirm the new beforeChild = existingPseudoElement->nextSibling() is itself always live at attach time (e.g. when the following sibling is also being restyled/destroyed in the same pass).

Before / after

Loading diff…