CVE-2026-112666
Overview
Background
- Render-tree builder for form controls
RenderTreeBuilder::FormControlsbuilds/updates the render subtree for form controls (here,<select>withappearance: base-selectand its::checkmarkpseudo-element).- Pseudo-element renderers
- A
::checkmarkis 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* beforeChildanchor passed tom_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.
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
- Style the control
Page sets
<select>/::picker(select)toappearance: base-selectand makesoptionandoption::checkmarkdisplay: block, so the option’s text is wrapped in an anonymous block and the checkmark is a block sibling. - Give the checkmark content
option:not(:checked)::checkmark { content: "."; }so an existing::checkmarkrenderer is present. - Change the selection
Script sets
select.valueto another option, triggeringupdateAfterDescendants→updatePseudoElement(Checkmark, ...)to recreate the checkmark. - Capture stale anchor
The builder records
beforeChild = renderer.firstChild()before destroying the old pseudo-element. - Collapse frees the anchor
m_builder.destroy(*existingPseudoElement)collapses the anonymous block wrapping the text, freeing the rendererbeforeChildreferenced. - UAF primitive
m_builder.attach(renderer, newPseudoElement, beforeChild)dereferences the freedRenderObject*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
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
| Function | Change | Notes |
|---|---|---|
ifSource/WebCore/rendering/updating/RenderTreeBuilderFormControls.cpp |
modified |
Files Changed
LayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash-expected.txtLayoutTests/fast/forms/select/base/option-checkmark-content-change-anonymous-block-crash.htmlSource/WebCore/rendering/updating/RenderTreeBuilderFormControls.cppSource/WebCore/rendering/updating/RenderTreeBuilderFormControls.h
Audit Directions
- Anchors captured across destroyAudit
RenderTreeBuilderpaths that compute abeforeChild/siblingRenderObject*and then calldestroy(...)beforeattach(...); any anchor captured before a destroy that can collapse anonymous blocks is suspect. - Anonymous-block collapse side effectsReview 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 pathsExamine the newer base-select/picker rendering code for similar raw-pointer lifetime assumptions during subtree rebuilds.
Patch
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>
Regression Test / PoC
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>