CVE-2026-9947
Overview
Background
- libxslt / libxml2 dictionaries
- libxml2 interns many strings into a per-document
xmlDict. A pointer “owned” by the dictionary must not be individuallyxmlFreed; a pointer not owned by the dictionary is a plain heap allocation that must be freed exactly once by whoever owns the node. - `ctxt->internalized`
- A transform-context flag meaning the stylesheet/result are dictionary-interned. The fast path trusted this flag to imply any given
contentpointer is dictionary-owned. - `xml:space="preserve"` + blank text nodes
- During
xsltPreprocessStylesheet, blank (whitespace-only) text nodes are normally deleted; withxml:space="preserve"they are kept. The keep path (IS_BLANK_NODEbranch) bypasses the interningelse ifbranch, so a kept blank node can retain a raw, non-dictionary heap pointer. - MiraclePtr / PartitionAlloc
- On desktop/ChromeOS the renderer’s BackupRefPtr turns a double-free into a deterministic safe crash; on standard Android the renderer disables MiraclePtr, so a double-free corrupts the PartitionAlloc thread-cache freelist.
Root Cause Analysis
The violated invariant is that the attribute fast-path pointer-alias may only be taken when inst->children->content is actually owned by the dictionary — otherwise two distinct nodes (the stylesheet text node and the aliased result node) each believe they own the same heap buffer and both free it. The old guard in xsltAttribute was if (ctxt->internalized && ctxt->insert->doc != NULL && ctxt->insert->doc->dict == ctxt->dict), which checks only that interning is enabled and that the insert document shares the context dictionary — it never checks ownership of this specific buffer. For a whitespace-only text node (≥60 blanks, bypassing libxml2’s short-blank interning in xmlSAX2TextNode) kept under xml:space="preserve", preprocessing skipped interning, so the content is a raw xmlStrndup heap buffer not in the dictionary. The fast path nonetheless set copyTxt->content = inst->children->content, aliasing the raw pointer into the result tree. At cleanup Blink’s XSLTProcessor::TransformToString frees the result document (the DICT_FREE macro sees the pointer is not dictionary-owned and calls xmlFree) and then frees the stylesheet, which xmlFrees the same pointer again — a double free.
The patch adds a fourth condition to the guard: && xmlDictOwns(ctxt->dict, inst->children->content), so the alias is only taken when the buffer is genuinely dictionary-owned; otherwise the normal (deep-copy) path runs and each tree owns its own buffer. The change is applied both to the vendored source (third_party/libxslt/src/libxslt/attributes.c) and as a tracked Chromium patch (chromium/0005-Verify-dictionary-ownership-…patch, registered in roll.py), plus a WPT regression test (xslt-attribute-double-free-crash.html) using 64 spaces inside an xsl:attribute with xml:space="preserve". The advisory’s bugClass is “UAF” (CWE-416); the diff shows the precise mechanism is a double free (CWE-415) of a non-interned heap buffer, not a use-after-free of a dangling object — the realized primitive is freelist corruption rather than reuse of a stale pointer.
xsl:attribute fast path aliased a text node’s content pointer on the mere assumption that “interning is on” implied “this buffer is dictionary-owned” — but a whitespace-only node preserved via xml:space="preserve" kept a raw heap pointer, so the alias handed the same buffer to two trees that each freed it.Attack Path
- Web content
A page runs script using
XSLTProcessor(standard Blink web API), no special privilege. - Craft the stylesheet
Build an
xsl:stylesheetwhose template emits an element carrying<xsl:attribute name="a" xml:space="preserve">containing ≥60 whitespace characters (64 in the test), defeating libxml2’s short-blank interning. - Import and transform
importStylesheetparses it, and preprocessing keeps the blank node (space preserved) without interning its heap buffer;transformToDocument/transformToStringrunsxsltAttribute, which aliases the raw pointer into the result tree. - Double free
Blink cleanup frees the result document and then the stylesheet, calling
xmlFreeon the same buffer twice. - Primitive On desktop/ChromeOS this is a safe crash (DoS) via MiraclePtr; on standard Android (MiraclePtr off for the renderer) the thread-cache freelist is corrupted so subsequent same-size-class allocations alias one address — overlapping objects (inferred as the exploitation primitive; the diff proves only the double free).
Impact Assessment
XSLTProcessor API. Confirmed by the diff: a double-free of a heap buffer shared between the stylesheet and result trees. On desktop/ChromeOS the outcome is a deterministic safe crash (denial of service) because BackupRefPtr detects the second free. On standard Android, where MiraclePtr is disabled for the renderer, the double free corrupts the PartitionAlloc freelist and can be leveraged into overlapping allocations — a strong, reliable heap primitive toward renderer RCE; that escalation is (inferred) and platform-gated, not demonstrated by the diff.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
Audit Directions
- Other `ctxt->internalized` fast pathsSearch libxslt for places that alias
->contentor reuse pointers guarded only byinternalized/dict ==checks withoutxmlDictOwns, e.g. text/CDATA copying inxsltCopyText,xsltCopyTextString, and value-of handling. - Preprocessing keep/intern asymmetryAudit
xsltPreprocessStylesheetfor every branch that keeps a node without routing it through the interningelse if; any kept node with a heapcontentcan reintroduce this mismatch (whitespace, PI, comment nodes under space-preserve). - Blink XSLT cleanup ownershipReview
XSLTProcessor::TransformToString/document teardown for other cases where result-tree and stylesheet-tree nodes could share a buffer across the result-then-stylesheet free order. - MiraclePtr coverage gapsTreat third-party C libraries (libxslt/libxml2) on Android renderer as unprotected; prioritize fuzzing their free paths where double-free/UAF would not be caught by BRP.
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.