Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DOM
DescriptionUse after free in DOM
ComponentDOM
Bug ClassUAF
Tracker496271580
Fix commit6acb62a1f250 (chromium/src) +66/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
modified
if
third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
modified

Files Changed

  • third_party/blink/renderer/core/dom/processing_instruction.cc
  • third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
  • third_party/blink/web_tests/external/wpt/lint.ignore
  • third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl
  • third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl
  • third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
From 6acb62a1f2500f44c446f301d71575c5b9916987 Mon Sep 17 00:00:00 2001
From: Keishi Hattori <keishi@google.com>
Date: Thu, 14 May 2026 22:10:13 -0700
Subject: [PATCH] [XSLT] Mitigate potential UAF in XSLStyleSheet::LoadChildSheets

This CL mitigates UAF in XSLStyleSheet by adding liveness checks during
child sheet loading. Synchronous recursive destruction could occur when
a child sheet triggers an XSL transformation that frees the parent
stylesheet's xmlDoc while it was still being iterated.

Key changes:
- Added checks for stylesheet_doc_taken_ in LoadChildSheets loop.
- Added missing ClearResource() call in ProcessingInstruction when
  switching to a local stylesheet to prevent processing stale network
  responses.

Bug: 496271580
Change-Id: Ib579489073a9e71dc9433307e3238db2939a14ba
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7702938
Reviewed-by: Mason Freed <masonf@chromium.org>
Commit-Queue: Keishi Hattori <keishi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1631070}
---

diff --git a/third_party/blink/renderer/core/dom/processing_instruction.cc b/third_party/blink/renderer/core/dom/processing_instruction.cc
index a0175d40..738b036d 100644
--- a/third_party/blink/renderer/core/dom/processing_instruction.cc
+++ b/third_party/blink/renderer/core/dom/processing_instruction.cc
@@ -397,6 +397,11 @@
                                                    final_url, true);
       loading_ = false;
     }
+
+    // crbug.com/496271580: Clear the resource to prevent late-arriving
+    // network responses from being processed if the stylesheet has
+    // switched to a local source.
+    ClearResource();
     return;
   }
 
diff --git a/third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc b/third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
index 4bc346b..64f72a6 100644
--- a/third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
+++ b/third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
@@ -198,6 +198,12 @@
             xsltGetNsProp(curr, (const xmlChar*)"href", XSLT_NAMESPACE);
         LoadChildSheet(String::FromUtf8((const char*)uri_ref));
         xmlFree(uri_ref);
+
+        // crbug.com/496271580: LoadChildSheet() can trigger synchronous
+        // destruction of the stylesheet's xmlDoc. Bail out to avoid UAF.
+        if (stylesheet_doc_taken_) {
+          return;
+        }
       } else {
         break;
       }
@@ -212,6 +218,12 @@
             xsltGetNsProp(curr, (const xmlChar*)"href", XSLT_NAMESPACE);
         LoadChildSheet(String::FromUtf8((const char*)uri_ref));
         xmlFree(uri_ref);
+
+        // crbug.com/496271580: LoadChildSheet() can trigger synchronous
+        // destruction of the stylesheet's xmlDoc. Bail out to avoid UAF.
+        if (stylesheet_doc_taken_) {
+          return;
+        }
       }
       curr = curr->next;
     }
diff --git a/third_party/blink/web_tests/external/wpt/lint.ignore b/third_party/blink/web_tests/external/wpt/lint.ignore
index 7b21a36c..88789eb 100644
--- a/third_party/blink/web_tests/external/wpt/lint.ignore
+++ b/third_party/blink/web_tests/external/wpt/lint.ignore
@@ -444,6 +444,7 @@
 SET TIMEOUT: speculation-rules/prerender/resources/media-play.html
 SET TIMEOUT: html/browsers/browsing-the-web/back-forward-cache/timers.html
 SET TIMEOUT: dom/abort/crashtests/timeout-close.html
+SET TIMEOUT: xml/xslt/xslt-mutation-crash.xhtml
 SET TIMEOUT: storage-access-api/storage-access-beyond-cookies.locks.sub.https.window.js
 SET TIMEOUT: pointerevents/crashtests/longpress-crash.html
 
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl
new file mode 100644
index 0000000..e3d28506
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+</xsl:stylesheet>
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl
new file mode 100644
index 0000000..c224799
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+  <xsl:import href="xslt-mutation-crash-import.xsl"/>
+  <xsl:template match="/">
+    <html><body>hello world</body></html>
+  </xsl:template>
+</xsl:stylesheet>
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
new file mode 100644
index 0000000..cd20c54
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" class="test-wait">
+<head>
+  <title>XSLT LoadChildSheets crash test</title>
+  <link rel="help" href="https://crbug.com/496271580"/>
+</head>
+<body>
+<p>Test passes if it does not crash.</p>
+<script><![CDATA[
+function go() {
+  // Unique URL to avoid memory cache returning synchronously.
+  var slow = 'xslt-mutation-crash-slow.xsl?pipe=trickle(d1)&t=' + Date.now() + Math.random();
+
+  // 1. Create an xml-stylesheet PI pointing at an external (delayed) XSL.
+  var pi = document.createProcessingInstruction(
+      'xml-stylesheet', 'type="text/xsl" href="' + slow + '"');
+
+  // 2. Insert as a direct child of the Document to initiate the external fetch.
+  document.insertBefore(pi, document.documentElement);
+
+  // 3. Mutate the PI to a local href while the external fetch is in flight.
+  //    This verifies that the browser correctly detaches the pending network
+  //    resource and does not crash when the delayed response eventually arrives.
+  pi.data = 'type="text/xsl" href="#x"';
+
+  // 4. Wait 1.5 seconds for the trickle(d1) fetch to complete and verify no crash occurs.
+  setTimeout(() => {
+    document.documentElement.classList.remove('test-wait');
+  }, 1500);
+}
+if (document.readyState === 'complete') {
+  go();
+} else {
+  window.addEventListener('load', go);
+}
+]]></script>
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/lint.ignore b/third_party/blink/web_tests/external/wpt/lint.ignore
index 7b21a36c..88789eb 100644
--- a/third_party/blink/web_tests/external/wpt/lint.ignore
+++ b/third_party/blink/web_tests/external/wpt/lint.ignore
@@ -444,6 +444,7 @@
 SET TIMEOUT: speculation-rules/prerender/resources/media-play.html
 SET TIMEOUT: html/browsers/browsing-the-web/back-forward-cache/timers.html
 SET TIMEOUT: dom/abort/crashtests/timeout-close.html
+SET TIMEOUT: xml/xslt/xslt-mutation-crash.xhtml
 SET TIMEOUT: storage-access-api/storage-access-beyond-cookies.locks.sub.https.window.js
 SET TIMEOUT: pointerevents/crashtests/longpress-crash.html
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl
new file mode 100644
index 0000000..e3d28506
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xsl
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+</xsl:stylesheet>
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl
new file mode 100644
index 0000000..c224799
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xsl
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+  <xsl:import href="xslt-mutation-crash-import.xsl"/>
+  <xsl:template match="/">
+    <html><body>hello world</body></html>
+  </xsl:template>
+</xsl:stylesheet>
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
new file mode 100644
index 0000000..cd20c54
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" class="test-wait">
+<head>
+  <title>XSLT LoadChildSheets crash test</title>
+  <link rel="help" href="https://crbug.com/496271580"/>
+</head>
+<body>
+<p>Test passes if it does not crash.</p>
+<script><![CDATA[
+function go() {
+  // Unique URL to avoid memory cache returning synchronously.
+  var slow = 'xslt-mutation-crash-slow.xsl?pipe=trickle(d1)&t=' + Date.now() + Math.random();
+
+  // 1. Create an xml-stylesheet PI pointing at an external (delayed) XSL.
+  var pi = document.createProcessingInstruction(
+      'xml-stylesheet', 'type="text/xsl" href="' + slow + '"');
+
+  // 2. Insert as a direct child of the Document to initiate the external fetch.
+  document.insertBefore(pi, document.documentElement);
+
+  // 3. Mutate the PI to a local href while the external fetch is in flight.
+  //    This verifies that the browser correctly detaches the pending network
+  //    resource and does not crash when the delayed response eventually arrives.
+  pi.data = 'type="text/xsl" href="#x"';
+
+  // 4. Wait 1.5 seconds for the trickle(d1) fetch to complete and verify no crash occurs.
+  setTimeout(() => {
+    document.documentElement.classList.remove('test-wait');
+  }, 1500);
+}
+if (document.readyState === 'complete') {
+  go();
+} else {
+  window.addEventListener('load', go);
+}
+]]></script>
+</body>
+</html>
Loading diff…

Original Bug Report

reported by rj...@google.com

UAF in XSLStyleSheet::LoadChildSheets via Stale ResourceClient

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic error in ProcessingInstruction::ProcessStylesheet allows a stale resource fetch to trigger an XSL transformation prematurely. This transformation frees the stylesheet document while it is still being iterated in XSLStyleSheet::LoadChildSheets, leading to a heap-use-after-free of libxml2 xmlNodes.

Affected files:

  • third_party/blink/renderer/core/dom/processing_instruction.cc
  • third_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc
  • third_party/blink/renderer/core/xml/xslt_processor_libxslt.cc
  • third_party/blink/renderer/core/xml/document_xslt.cc

Estimated timestamp from git blame: 2026-03-11

Summary

A potential heap-use-after-free (UAF) vulnerability exists in XSLStyleSheet::LoadChildSheets. The issue stems from a logic flaw in ProcessingInstruction::ProcessStylesheet where mutating a stylesheet’s href from an external URL to a local anchor (e.g., #id) disarms the loading_ state guard but fails to clear the pending ResourceClient.

When the stale external fetch subsequently completes, it prematurely triggers an XSL transformation. This transformation frees the underlying libxml2 xmlDoc tree while LoadChildSheets is still iterating over its nodes. Because libxml2 objects are allocated via C malloc, they are not protected by MiraclePtr (BRP), making this a highly exploitable UAF in the renderer process.

Root Cause Analysis

In third_party/blink/renderer/core/dom/processing_instruction.cc, the ProcessStylesheet method handles two main branches: external and local.

  1. When an external href is processed, ClearResource() is called to detach previous clients, and loading_ is set to true.
  2. When a local href (starting with #) is processed, the code creates a local XSLStyleSheet, sets loading_ = false, and returns early without calling ClearResource().

If a script mutates the ProcessingInstruction data from an external URL to a local one while a fetch is in-flight, the loading_ guard is disarmed, but the stale ResourceClient remains registered. When the external fetch completes, NotifyFinished() is called. Because loading_ is now false, guards in SheetLoaded and ApplyXSLTransform are bypassed.

Potential Attack Scenario

Note: Our setup cannot currently verify this with a working exploit, but the code path appears highly reliable.

  1. Preparation: An attacker creates an HTML page embedding an XML document with a <?xml-stylesheet type="text/xsl" href="external.xsl"?> ProcessingInstruction (PI). The external.xsl server is configured to delay its response.
  2. External Fetch Initiated: ProcessingInstruction::ProcessStylesheet sets loading_ = true and initiates the asynchronous fetch for external.xsl.
  3. State Mutation: Before the fetch completes, the attacker uses synchronous JavaScript to mutate the PI’s data attribute: pi.data = 'type="text/xsl" href="#local"';.
  4. Guard Disarmed: This triggers a second call to ProcessStylesheet("#local"). Because it’s a local href, the function sets loading_ = false and returns early, bypassing the crucial ClearResource() call. The PI remains registered for the delayed external.xsl fetch.
  5. Fetch Completion: The external.xsl fetch finishes, triggering ProcessingInstruction::NotifyFinished. This creates a new XSLStyleSheet and calls ParseString(), which parses the XML via libxml2 into an xmlDocPtr.
  6. Child Iteration: ParseString calls LoadChildSheets(), which begins a while loop iterating over the xmlNodePtr children of the parsed document.
  7. Premature Transformation: The loop encounters an <xsl:import> element and synchronously fetches it. This bubbles up a CheckLoaded() call. Because loading_ was maliciously set to false in Step 4, ProcessingInstruction::IsLoading() returns false.
  8. UAF Trigger: Believing all stylesheets are fully loaded, DocumentXSLT::ApplyXSLTransform is called. It compiles the stylesheet, transferring ownership of the xmlDocPtr to libxslt, performs the transformation, and finally calls xsltFreeStylesheet, which entirely frees the xmlDocPtr tree via malloc.
  9. Synchronous Execution: The transformation result is committed via a frame navigation. The attacker’s XSLT output contains a <script> tag, which executes synchronously, allowing the attacker to groom the heap and perfectly reclaim the newly freed xmlNodePtr chunks.
  10. The Crash/Exploit: The call stack unwinds back to the while loop in LoadChildSheets(). The code executes curr = curr->next; on the attacker-controlled, freed memory, leading to an arbitrary read/write primitive and RCE.

Suggested Fix

Ensure ClearResource() is always called in ProcessingInstruction::ProcessStylesheet before the local-href early return path. This guarantees that no stale fetches can trigger callbacks while the object is transitioning to an inconsistent state.

void ProcessingInstruction::ProcessStylesheet(const String& href,
                                              const String& charset) {
  CHECK(IsXMLStylesheet());
  if (IsLocalSheet(href)) {
    ClearResource(); // <--- Add this line to clear any pending fetches
    local_href_ = href.substr(1);
    // ... rest of the local sheet handling
    return;
  }

  ClearResource();
  // ... external sheet handling
}

Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker