Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected process termination
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentWebCore
Bug ClassUAF
Tracker321669
Fix commit15e7145a56c3 (webkit) +65/-5
CISA KEVNot listed
CreditedLuke Francis (@lukefr09)
Disclosed2026-10-26

Background

Render-tree builder for form controls
RenderTreeBuilder::FormControls builds/updates the render subtree for form controls (here, <select> with appearance: base-select and its ::checkmark pseudo-element).
Pseudo-element renderers
A ::checkmark is realized as a pseudo-element renderer attached as a child of the option’s renderer; renderer.pseudoElementRenderer(type) returns the existing one.
`updatePseudoElement`
Destroys any existing pseudo-element renderer and attaches a freshly created one at a position, specified (pre-patch) by a RenderObject* beforeChild anchor passed to m_builder.attach(...).
Anonymous block collapse
When a block-level pseudo-element and the option’s text are siblings, the text is wrapped in an anonymous block. Destroying the pseudo-element can collapse/remove that anonymous wrapper, destroying sibling renderers in the process.

Root Cause Analysis

RenderTreeBuilder::FormControls::updatePseudoElement (Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp) took a raw RenderObject* beforeChild anchor computed before it destroyed the existing pseudo-element. The caller passed renderer.firstChild() as beforeChild, and inside the function the old code tried to defend against the anchor being the pseudo-element itself: if (beforeChild == existingPseudoElement) beforeChild = existingPseudoElement->nextSibling(); followed by m_builder.destroy(*existingPseudoElement). This only handled the case where beforeChild was the pseudo-element. But destroying existingPseudoElement can have side effects on other renderers: when the ::checkmark is a block sibling of the option’s text, destroying it collapses the anonymous block that wrapped that text, destroying those sibling renderers too. If beforeChild (captured earlier as renderer.firstChild()) pointed at a renderer that the destroy collapses/frees, beforeChild becomes a dangling pointer. The subsequent m_builder.attach(renderer, WTF::move(pseudoElement), beforeChild) then uses that freed RenderObject* as the insertion anchor: a use-after-free.

The patch removes the stale raw-pointer anchor entirely. updatePseudoElement’s last parameter is changed from RenderObject* beforeChild to an enum class PseudoElementPosition : bool { FirstChild, LastChild } (defaulting to LastChild). The caller passes PseudoElementPosition::FirstChild instead of renderer.firstChild(). The pre-destroy beforeChild-adjustment block is deleted, and the actual anchor is now computed after the destroy: auto* beforeChild = position == PseudoElementPosition::FirstChild ? renderer.firstChild() : nullptr; immediately before m_builder.attach(...). Recomputing firstChild() post-destroy guarantees the anchor reflects the tree after any anonymous-block collapse, so no freed renderer is used. The advisory’s UAF/process-termination label matches the diff; the layout test (option-checkmark-content-change-anonymous-block-crash.html) reproduces the collapse by making option and option::checkmark block-level siblings and changing select.value.

Key insight
The insertion anchor beforeChild was captured as renderer.firstChild() before destroying the old ::checkmark, but that destroy could collapse the anonymous block holding the option’s text and free the very renderer beforeChild pointed at, so attach then used a dangling anchor; the fix recomputes the anchor after the destroy.

Attack Path

  1. Style the control Page sets <select>/::picker(select) to appearance: base-select and makes option and option::checkmark display: block, so the option’s text is wrapped in an anonymous block and the checkmark is a block sibling.
  2. Give the checkmark content option:not(:checked)::checkmark { content: "."; } so an existing ::checkmark renderer is present.
  3. Change the selection Script sets select.value to another option, triggering updateAfterDescendants → updatePseudoElement(Checkmark, ...) to recreate the checkmark.
  4. Capture stale anchor The builder records beforeChild = renderer.firstChild() before destroying the old pseudo-element.
  5. Collapse frees the anchor m_builder.destroy(*existingPseudoElement) collapses the anonymous block wrapping the text, freeing the renderer beforeChild referenced.
  6. UAF primitive m_builder.attach(renderer, newPseudoElement, beforeChild) dereferences the freed RenderObject* as the insertion point, a use-after-free in the render tree that causes process termination and is usable toward corruption (escalation inferred; the test asserts only no-crash).

Impact Assessment

This is a renderer-process (WebCore render-tree builder) use-after-free reachable from unprivileged JavaScript/CSS on a page using appearance: base-select with block-level ::checkmark and option text. The primitive is a dangling-RenderObject* used as an attach anchor after an anonymous-block collapse frees it. Apple’s advisory states “unexpected process termination,” which the diff and test substantiate as a UAF; escalation to code execution beyond the crash is not proven by the diff (inferred). Preconditions are attacker-controllable via CSS layout of the select/checkmark plus a scripted value change.

Changed Functions

FunctionChangeNotes
if
Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp
modified

Files Changed

  • LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt
  • LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html
  • Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp
  • Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.h

Audit Directions

  • Anchors captured across destroy
    Audit RenderTreeBuilder paths that compute a beforeChild/sibling RenderObject* and then call destroy(...) before attach(...); any anchor captured before a destroy that can collapse anonymous blocks is suspect.
  • Anonymous-block collapse side effects
    Review other pseudo-element and generated-content update paths (::before, ::after, ::marker, ::picker) for reliance on sibling pointers that a collapse can invalidate.
  • `appearance: base-select` render paths
    Examine the newer base-select/picker rendering code for similar raw-pointer lifetime assumptions during subtree rebuilds.
diff --git a/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp b/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp
index 94a269226803..b12a5b81f889 100644
--- a/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp
+++ b/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp
@@ -95,7 +95,7 @@ void RenderTreeBuilder::FormControls::updateAfterDescendants(RenderElement& rend
         RefPtr container = selectElement->optionContainer();
         CheckedPtr containerRenderer = container ? container->renderer() : nullptr;
         if (containerRenderer)
-            updatePseudoElement(PseudoElementType::Checkmark, renderer, containerRenderer->style().usedAppearance(), renderer.firstChild());
+            updatePseudoElement(PseudoElementType::Checkmark, renderer, containerRenderer->style().usedAppearance(), PseudoElementPosition::FirstChild);
 
         return;
     }
@@ -106,7 +106,7 @@ void RenderTreeBuilder::FormControls::updateAfterDescendants(RenderElement& rend
     }
 }
 
-void RenderTreeBuilder::FormControls::updatePseudoElement(PseudoElementType type, RenderElement& renderer, StyleAppearance usedAppearance, RenderObject* beforeChild)
+void RenderTreeBuilder::FormControls::updatePseudoElement(PseudoElementType type, RenderElement& renderer, StyleAppearance usedAppearance, PseudoElementPosition position)
 {
     WeakPtr existingPseudoElement = renderer.pseudoElementRenderer(type);
 
@@ -135,8 +135,6 @@ void RenderTreeBuilder::FormControls::updatePseudoElement(PseudoElementType type
     }
 
     if (existingPseudoElement) {
-        if (beforeChild == existingPseudoElement)
-            beforeChild = existingPseudoElement->nextSibling();
         m_builder.destroy(*existingPseudoElement);
     }
 
@@ -157,6 +155,7 @@ void RenderTreeBuilder::FormControls::updatePseudoElement(PseudoElementType type
 
     renderer.setPseudoElementRenderer(type, *pseudoElement.get());
 
+    auto* beforeChild = position == PseudoElementPosition::FirstChild ? renderer.firstChild() : nullptr;
     m_builder.attach(renderer, WTF::move(pseudoElement), beforeChild);
 }
 
diff --git a/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.h b/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.h
index 0f103d444a90..6e6d8e76e0a3 100644
--- a/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.h
+++ b/Source/WebCore/rendering/updating/RenderTreeBuilderFormControls.h
@@ -47,7 +47,8 @@ public:
 private:
     RenderBlock& findOrCreateParentForChild(RenderButton&);
 
-    void updatePseudoElement(PseudoElementType, RenderElement&, StyleAppearance, RenderObject* beforeChild = nullptr);
+    enum class PseudoElementPosition : bool { FirstChild, LastChild };
+    void updatePseudoElement(PseudoElementType, RenderElement&, StyleAppearance, PseudoElementPosition = PseudoElementPosition::LastChild);
 
     RenderTreeBuilder& m_builder;
 };
diff --git a/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt
new file mode 100644
index 000000000000..b819a6678928
--- /dev/null
+++ b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt
@@ -0,0 +1,13 @@
+Recreating ::checkmark must not crash when destroying the old one collapses the anonymous block wrapping the option's text.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS select.value is "Two"
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
+One
+Two
+
diff --git a/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html
new file mode 100644
index 000000000000..525dcd115b75
--- /dev/null
+++ b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../../../resources/js-test.js"></script>
+<style>
+select, ::picker(select) {
+    appearance: base-select;
+}
+
+/* Give the picker a renderer without opening it. */
+::picker(select) {
+    display: block;
+}
+
+/* Make the ::checkmark a block sibling of the option's text, so the text ends
+   up in an anonymous block wrapper. */
+select option, select option::checkmark {
+    display: block;
+}
+
+select option:not(:checked)::checkmark {
+    content: ".";
+}
+</style>
+</head>
+<body>
+<select id="select">
+    <option>One</option>
+    <option>Two</option>
+</select>
+<script>
+description("Recreating ::checkmark must not crash when destroying the old one collapses the anonymous block wrapping the option's text.");
+
+const select = document.getElementById("select");
+
+jsTestIsAsync = true;
+
+(async () => {
+    await new Promise(requestAnimationFrame);
+    select.value = "Two";
+    document.body.offsetHeight;
+    shouldBe('select.value', '"Two"');
+    finishJSTest();
+})();
+</script>
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt
new file mode 100644
index 000000000000..b819a6678928
--- /dev/null
+++ b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txt
@@ -0,0 +1,13 @@
+Recreating ::checkmark must not crash when destroying the old one collapses the anonymous block wrapping the option's text.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS select.value is "Two"
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
+One
+Two
+
diff --git a/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html
new file mode 100644
index 000000000000..525dcd115b75
--- /dev/null
+++ b/LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../../../resources/js-test.js"></script>
+<style>
+select, ::picker(select) {
+    appearance: base-select;
+}
+
+/* Give the picker a renderer without opening it. */
+::picker(select) {
+    display: block;
+}
+
+/* Make the ::checkmark a block sibling of the option's text, so the text ends
+   up in an anonymous block wrapper. */
+select option, select option::checkmark {
+    display: block;
+}
+
+select option:not(:checked)::checkmark {
+    content: ".";
+}
+</style>
+</head>
+<body>
+<select id="select">
+    <option>One</option>
+    <option>Two</option>
+</select>
+<script>
+description("Recreating ::checkmark must not crash when destroying the old one collapses the anonymous block wrapping the option's text.");
+
+const select = document.getElementById("select");
+
+jsTestIsAsync = true;
+
+(async () => {
+    await new Promise(requestAnimationFrame);
+    select.value = "Two";
+    document.body.offsetHeight;
+    shouldBe('select.value', '"Two"');
+    finishJSTest();
+})();
+</script>
+</body>
+</html>
Loading diff…