CVE-2026-9947
Overview
Files Changed
third_party/blink/web_tests/TestExpectationsthird_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.htmlthird_party/libxslt/chromium/0005-Verify-dictionary-ownership-before-aliasing-attribut.patchthird_party/libxslt/chromium/roll.pythird_party/libxslt/src/libxslt/attributes.c
Patch
From cd30fc3317627618d206710777b7e9909d171af7 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Wed, 22 Apr 2026 14:51:09 -0700 Subject: [PATCH] Verify dictionary ownership before aliasing attribute text content A potential double-free vulnerability exists in libxslt when handling whitespace-only text nodes within xsl:attribute elements that have xml:space="preserve" set. The text content bypasses dictionary interning but is later fast-copied via pointer aliasing, causing the same heap buffer to be freed twice during cleanup. This patch adds a check to ensure that the content is actually owned by the dictionary before performing the fast-path pointer aliasing. Fixed: 503627446 Change-Id: I5e8835f1f0b81fb9314d48daac05d6622d64ec60 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7778588 Commit-Queue: Andrew Paseltiner <[email protected]> Reviewed-by: Daniel Cheng <[email protected]> Reviewed-by: James Scott <[email protected]> Reviewed-by: Nico Weber <[email protected]> Cr-Commit-Position: refs/heads/main@{#1619107} --- diff --git a/third_party/blink/web_tests/TestExpectations b/third_party/blink/web_tests/TestExpectations index 64069a6..6771a92b 100644 --- a/third_party/blink/web_tests/TestExpectations +++ b/third_party/blink/web_tests/TestExpectations @@ -1665,6 +1665,7 @@ # `xslt-disabled` virtual suite re-enables the XSLTSpecialTrial flag and # disables the main XSLT flag, which should keep XSLT disabled. crbug.com/421650040 external/wpt/xml/xslt/* [ Failure ] +crbug.com/503627446 external/wpt/xml/xslt/xslt-attribute-double-free-crash.html [ Pass ] crbug.com/421650040 virtual/xslt-enabled/external/wpt/xml/xslt/* [ Pass ] crbug.com/421650040 http/tests/xsl/xslt/* [ Failure ] crbug.com/421650040 virtual/xslt-enabled/http/tests/xsl/xslt/* [ Pass ] diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html new file mode 100644 index 0000000..e96b9c00 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<title>XSLT attribute double-free crash test</title> +<link rel="help" href="https://crbug.com/503627446"> +<script> +function runTest() { + // The crash only occurs with > 60 characters, as otherwise an optimization is bypassed. + const xsl = + "<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">" + + "<xsl:template match=\"/\"><r><xsl:for-each select=\"//*\">" + + "<e><xsl:attribute name=\"a\" xml:space=\"preserve\">" + " ".repeat(64) + "</xsl:attribute></e>" + + "</xsl:for-each></r></xsl:template></xsl:stylesheet>"; + + const p = new DOMParser(); + const proc = new XSLTProcessor(); + + proc.importStylesheet(p.parseFromString(xsl, "text/xml")); + proc.transformToDocument(p.parseFromString("<r><a></a><b></b></r>", "text/xml")); +} +</script> +<body onload="runTest()"> +</body> diff --git a/third_party/libxslt/chromium/0005-Verify-dictionary-ownership-before-aliasing-attribut.patch b/third_party/libxslt/chromium/0005-Verify-dictionary-ownership-before-aliasing-attribut.patch new file mode 100644 index 0000000..f3d92c2 --- /dev/null +++ b/third_party/libxslt/chromium/0005-Verify-dictionary-ownership-before-aliasing-attribut.patch @@ -0,0 +1,35 @@ +From 86951e62ddbf3667683d10387f3667683d10387f Mon Sep 17 00:00:00 2001 +From: Andrew Paseltiner <[email protected]> +Date: Mon, 20 Apr 2026 08:45:04 -0400 +Subject: [PATCH] Verify dictionary ownership before aliasing attribute text content + +A potential double-free vulnerability exists in libxslt when handling +whitespace-only text nodes within xsl:attribute elements that have +xml:space="preserve" set. The text content bypasses dictionary interning +but is later fast-copied via pointer aliasing, causing the same heap +buffer to be freed twice during cleanup. + +This patch adds a check to ensure that the content is actually owned by +the dictionary before performing the fast-path pointer aliasing. + +Bug: 503627446 +--- + third_party/libxslt/src/libxslt/attributes.c | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/third_party/libxslt/src/libxslt/attributes.c b/third_party/libxslt/src/libxslt/attributes.c +index 86f3768b..3667683d 100644 +--- a/libxslt/attributes.c ++++ b/libxslt/attributes.c +@@ -1038,7 +1038,8 @@ xsltAttribute(xsltTransformContextPtr ctxt, xmlNodePtr node, + */ + if (ctxt->internalized && + (ctxt->insert->doc != NULL) && +- (ctxt->insert->doc->dict == ctxt->dict)) ++ (ctxt->insert->doc->dict == ctxt->dict) && ++ xmlDictOwns(ctxt->dict, inst->children->content)) + { + copyTxt = xmlNewText(NULL); + if (copyTxt == NULL) /* TODO: report error */ +-- +2.50.0.rc0.642.g800a2b2222-goog diff --git a/third_party/libxslt/chromium/roll.py b/third_party/libxslt/chromium/roll.py index 4244b56..410762e 100755 --- a/third_party/libxslt/chromium/roll.py +++ b/third_party/libxslt/chromium/roll.py @@ -73,6 +73,7 @@ PATCHES = [ 'xslt-locale.patch', '0004-Use-a-dedicated-node-type-to-maintain-the-list-of-ca.patch', + '0005-Verify-dictionary-ownership-before-aliasing-attribut.patch', ] diff --git a/third_party/libxslt/src/libxslt/attributes.c b/third_party/libxslt/src/libxslt/attributes.c index 4cc49d0d..fb15a5b0 100644 --- a/third_party/libxslt/src/libxslt/attributes.c +++ b/third_party/libxslt/src/libxslt/attributes.c @@ -1036,7 +1036,8 @@ */ if (ctxt->internalized && (ctxt->insert->doc != NULL) && - (ctxt->insert->doc->dict == ctxt->dict)) + (ctxt->insert->doc->dict == ctxt->dict) && + xmlDictOwns(ctxt->dict, inst->children->content)) { copyTxt = xmlNewText(NULL); if (copyTxt == NULL) /* TODO: report error */
Regression Test / PoC
diff --git a/third_party/blink/web_tests/TestExpectations b/third_party/blink/web_tests/TestExpectations
index 64069a6..6771a92b 100644
--- a/third_party/blink/web_tests/TestExpectations
+++ b/third_party/blink/web_tests/TestExpectations
@@ -1665,6 +1665,7 @@
# `xslt-disabled` virtual suite re-enables the XSLTSpecialTrial flag and
# disables the main XSLT flag, which should keep XSLT disabled.
crbug.com/421650040 external/wpt/xml/xslt/* [ Failure ]
+crbug.com/503627446 external/wpt/xml/xslt/xslt-attribute-double-free-crash.html [ Pass ]
crbug.com/421650040 virtual/xslt-enabled/external/wpt/xml/xslt/* [ Pass ]
crbug.com/421650040 http/tests/xsl/xslt/* [ Failure ]
crbug.com/421650040 virtual/xslt-enabled/http/tests/xsl/xslt/* [ Pass ]
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html
new file mode 100644
index 0000000..e96b9c00
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<title>XSLT attribute double-free crash test</title>
+<link rel="help" href="https://crbug.com/503627446">
+<script>
+function runTest() {
+ // The crash only occurs with > 60 characters, as otherwise an optimization is bypassed.
+ const xsl =
+ "<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">" +
+ "<xsl:template match=\"/\"><r><xsl:for-each select=\"//*\">" +
+ "<e><xsl:attribute name=\"a\" xml:space=\"preserve\">" + " ".repeat(64) + "</xsl:attribute></e>" +
+ "</xsl:for-each></r></xsl:template></xsl:stylesheet>";
+
+ const p = new DOMParser();
+ const proc = new XSLTProcessor();
+
+ proc.importStylesheet(p.parseFromString(xsl, "text/xml"));
+ proc.transformToDocument(p.parseFromString("<r><a></a><b></b></r>", "text/xml"));
+}
+</script>
+<body onload="runTest()">
+</body>
Original Bug Report
Potential double free in libxslt via xsl:attribute and xml:space="preserve"
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A potential double-free vulnerability exists in libxslt when handling whitespace-only text nodes within xsl:attribute elements that have xml:space="preserve" set. The text content bypasses dictionary interning but is later fast-copied via pointer aliasing, causing the same heap buffer to be freed twice during cleanup. On standard Android, where MiraclePtr is disabled for the renderer, this corrupts the PartitionAlloc thread cache, potentially leading to overlapping allocations and renderer Remote Code Execution.
Affected files:
third_party/libxslt/src/libxslt/attributes.cthird_party/libxslt/src/libxslt/xslt.cthird_party/libxml/src/tree.cthird_party/blink/renderer/core/xml/xslt_processor_libxslt.cc
Estimated timestamp from git blame: 2015-09-16
Summary
A potential double-free vulnerability has been identified in the XSLT processing logic used by Chromium. The issue occurs due to an assumption mismatch in libxslt regarding dictionary interning. When a whitespace-only text node inside an xsl:attribute element preserves space, its content is allocated on the heap without being added to the parser dictionary. However, during transformation, the attribute logic assumes the content is dictionary-backed and aliases the raw pointer directly into the result tree. When Blink cleans up the result document and the stylesheet, the exact same heap pointer is passed to xmlFree twice.
While MiraclePtr mitigates this issue into a safe crash on Desktop platforms, standard Android builds disable MiraclePtr for the renderer process, allowing this to be leveraged for overlapping memory allocations and Remote Code Execution.
Root Cause Analysis
The vulnerability traces through three main phases:
1. Allocation and Parsing (third_party/libxml/src/SAX2.c)
When libxml2 parses an XSLT document, the xmlSAX2TextNode callback attempts to intern text into the document’s dictionary. For whitespace-only strings (“blank” nodes), it checks if len < 60. If an attacker provides 60 or more whitespace characters, this optimization is bypassed, and the string is allocated on the heap via xmlStrndup.
2. Stylesheet Preprocessing (third_party/libxslt/src/libxslt/xslt.c)
During xsltPreprocessStylesheet, the engine processes the stylesheet tree. At line 3613, it encounters the whitespace text node:
} else if (cur->type == XML_TEXT_NODE) {
if (IS_BLANK_NODE(cur)) {
if (xmlNodeGetSpacePreserve(cur->parent) != 1) {
deleteNode = cur;
}
} else if ((cur->content != NULL) && (internalize) && (!xmlDictOwns(style->dict, cur->content))) {
// ... intern the string ...
}
Because the node contains only whitespace (IS_BLANK_NODE is true) and the attacker explicitly set xml:space="preserve" on the parent xsl:attribute, the node is kept. Crucially, because it entered the if (IS_BLANK_NODE) branch, it bypasses the else if branch responsible for interning heap-allocated strings into the dictionary. The node retains its raw heap pointer.
3. Attribute Generation (third_party/libxslt/src/libxslt/attributes.c)
During the transformation, xsltAttribute evaluates the attribute’s children to attach them to the result tree. At line 1037:
if (ctxt->internalized && (ctxt->insert->doc != NULL) && (ctxt->insert->doc->dict == ctxt->dict)) {
copyTxt = xmlNewText(NULL);
copyTxt->content = inst->children->content; // Direct pointer aliasing
Because ctxt->internalized is true (inherited from the stylesheet initialization), libxslt assumes the string is safely interned in the dictionary and performs a direct pointer alias instead of a deep copy. The result tree now contains a node pointing to the exact same heap allocation as the stylesheet tree.
Double Free Trigger
When the transformation completes, XSLTProcessor::TransformToString in Blink performs cleanup:
- It calls
xmlFreeDoc(result_doc). TheDICT_FREEmacro checks if the pointer is owned by the dictionary (it is not) and callsxmlFree, returning the chunk to PartitionAlloc’s thread cache. - Immediately after, it calls
xsltFreeStylesheet(sheet). This traverses the stylesheet tree, finds the original text node, and callsxmlFreeon the exact same pointer again, triggering a synchronous double free.
Exploitability and Platform Impact
- Desktop/ChromeOS: MiraclePtr (BackupRefPtr) is enabled for the renderer process. The second free attempts to release the chunk, but
ReleaseFromAllocatordetects thatkMemoryHeldByAllocatorBitis already cleared. This correctly triggers a deterministicPA_IMMEDIATE_CRASH(), resulting only in a safe Denial of Service. - Standard Android: MiraclePtr is explicitly disabled for the renderer process (
BackupRefPtrEnabledProcesses::kNonRendererinpartition_alloc_features.cc). Consequently, the double-free check is bypassed. PartitionAlloc’sThreadCache::PutInBucketpushes the same pointer onto the freelist twice. This encodes thenextpointer to point to itself (entry->next = entry). Because the shadow entry remains perfectly consistent with this corruptednextpointer, standard release builds will not crash during reallocation.
An attacker can trigger this vulnerability and subsequently request allocations of the same size class via Javascript. The corrupted freelist cycle will cause GetFromCache to repeatedly return the same memory address, granting the attacker overlapping objects. This is a classic and highly reliable primitive for achieving Remote Code Execution within the sandboxed Android renderer process.
Potential Reproduction Steps
(Note: These are theoretical steps based on code analysis; our tooling agent cannot execute code to verify the PoC.)
<script>
// 1. Create a stylesheet with >60 spaces inside an xsl:attribute preserving space.
var xsl =
"<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">" +
"<xsl:template match=\"/\"><r><xsl:for-each select=\"//*\">" +
"<e><xsl:attribute name=\"a\" xml:space=\"preserve\">" + " ".repeat(64) + "</xsl:attribute></e>" +
"</xsl:for-each></r></xsl:template></xsl:stylesheet>";
var p = new DOMParser();
var proc = new XSLTProcessor();
// 2. Import stylesheet (parses and skips interning)
proc.importStylesheet(p.parseFromString(xsl, "text/xml"));
// 3. Transform (aliases pointer, then double-frees during Blink cleanup)
proc.transformToDocument(p.parseFromString("<r><a/><b/></r>", "text/xml"));
</script>
Proposed Fix
The mismatch between preprocessing and attribute generation must be resolved in libxslt. One potential fix in third_party/libxslt/src/libxslt/attributes.c is to verify dictionary ownership before taking the fast path:
if (ctxt->internalized &&
(ctxt->insert->doc != NULL) &&
(ctxt->insert->doc->dict == ctxt->dict) &&
xmlDictOwns(ctxt->dict, inst->children->content)) // ADD THIS CHECK
{
copyTxt = xmlNewText(NULL);
copyTxt->content = inst->children->content;
// ...
}
Alternatively, xsltPreprocessStylesheet could be updated to ensure that nodes bypassing the deleteNode logic due to xml:space="preserve" are still correctly interned into the dictionary if their content is currently heap-allocated.
Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.