High CVSS 8.8 chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in XML
DescriptionUse after free in XML
ComponentXML
Bug ClassMemory Corruption
Tracker503627446
Fix commitcd30fc331762 (chromium/src) +60/-1
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-17 (per vendor advisory)
Fix landed2026-04-22 (public git)
Disclosed2026-05-27
Patch gap35 days — fix public in source before the advisory

Background

libxslt / libxml2 dictionaries
libxml2 interns many strings into a per-document xmlDict. A pointer “owned” by the dictionary must not be individually xmlFreed; 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 content pointer is dictionary-owned.
`xml:space="preserve"` + blank text nodes
During xsltPreprocessStylesheet, blank (whitespace-only) text nodes are normally deleted; with xml:space="preserve" they are kept. The keep path (IS_BLANK_NODE branch) bypasses the interning else if branch, 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.

Key insight
The 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

  1. Web content A page runs script using XSLTProcessor (standard Blink web API), no special privilege.
  2. Craft the stylesheet Build an xsl:stylesheet whose 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.
  3. Import and transform importStylesheet parses it, and preprocessing keeps the blank node (space preserved) without interning its heap buffer; transformToDocument/transformToString runs xsltAttribute, which aliases the raw pointer into the result tree.
  4. Double free Blink cleanup frees the result document and then the stylesheet, calling xmlFree on the same buffer twice.
  5. 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

Reachable from unprivileged web content in the renderer process through the 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/TestExpectations
  • third_party/blink/web_tests/external/wpt/xml/xslt/xslt-attribute-double-free-crash.html
  • third_party/libxslt/chromium/0005-Verify-dictionary-ownership-before-aliasing-attribut.patch
  • third_party/libxslt/chromium/roll.py
  • third_party/libxslt/src/libxslt/attributes.c

Audit Directions

  • Other `ctxt->internalized` fast paths
    Search libxslt for places that alias ->content or reuse pointers guarded only by internalized/dict == checks without xmlDictOwns, e.g. text/CDATA copying in xsltCopyText, xsltCopyTextString, and value-of handling.
  • Preprocessing keep/intern asymmetry
    Audit xsltPreprocessStylesheet for every branch that keeps a node without routing it through the interning else if; any kept node with a heap content can reintroduce this mismatch (whitespace, PI, comment nodes under space-preserve).
  • Blink XSLT cleanup ownership
    Review 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 gaps
    Treat 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.
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 */
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by [email protected]

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.c
  • third_party/libxslt/src/libxslt/xslt.c
  • third_party/libxml/src/tree.c
  • third_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:

  1. It calls xmlFreeDoc(result_doc). The DICT_FREE macro checks if the pointer is owned by the dictionary (it is not) and calls xmlFree, returning the chunk to PartitionAlloc’s thread cache.
  2. Immediately after, it calls xsltFreeStylesheet(sheet). This traverses the stylesheet tree, finds the original text node, and calls xmlFree on 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 ReleaseFromAllocator detects that kMemoryHeldByAllocatorBit is already cleared. This correctly triggers a deterministic PA_IMMEDIATE_CRASH(), resulting only in a safe Denial of Service.
  • Standard Android: MiraclePtr is explicitly disabled for the renderer process (BackupRefPtrEnabledProcesses::kNonRenderer in partition_alloc_features.cc). Consequently, the double-free check is bypassed. PartitionAlloc’s ThreadCache::PutInBucket pushes the same pointer onto the freelist twice. This encodes the next pointer to point to itself (entry->next = entry). Because the shadow entry remains perfectly consistent with this corrupted next pointer, 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.

View on issue tracker