CVE-2026-9897
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.cc |
modified | |
ifthird_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml |
modified |
Files Changed
third_party/blink/renderer/core/dom/processing_instruction.ccthird_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.ccthird_party/blink/web_tests/external/wpt/lint.ignorethird_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-import.xslthird_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash-slow.xslthird_party/blink/web_tests/external/wpt/xml/xslt/xslt-mutation-crash.xhtml
Patch
From 6acb62a1f2500f44c446f301d71575c5b9916987 Mon Sep 17 00:00:00 2001 From: Keishi Hattori <[email protected]> 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 <[email protected]> Commit-Queue: Keishi Hattori <[email protected]> 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>
Regression Test / PoC
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>
Original Bug Report
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.ccthird_party/blink/renderer/core/xml/xsl_style_sheet_libxslt.ccthird_party/blink/renderer/core/xml/xslt_processor_libxslt.ccthird_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.
- When an external
hrefis processed,ClearResource()is called to detach previous clients, andloading_is set totrue. - When a local
href(starting with#) is processed, the code creates a localXSLStyleSheet, setsloading_ = false, and returns early without callingClearResource().
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.
- Preparation: An attacker creates an HTML page embedding an XML document with a
<?xml-stylesheet type="text/xsl" href="external.xsl"?>ProcessingInstruction (PI). Theexternal.xslserver is configured to delay its response. - External Fetch Initiated:
ProcessingInstruction::ProcessStylesheetsetsloading_ = trueand initiates the asynchronous fetch forexternal.xsl. - State Mutation: Before the fetch completes, the attacker uses synchronous JavaScript to mutate the PI’s
dataattribute:pi.data = 'type="text/xsl" href="#local"';. - Guard Disarmed: This triggers a second call to
ProcessStylesheet("#local"). Because it’s a local href, the function setsloading_ = falseand returns early, bypassing the crucialClearResource()call. The PI remains registered for the delayedexternal.xslfetch. - Fetch Completion: The
external.xslfetch finishes, triggeringProcessingInstruction::NotifyFinished. This creates a newXSLStyleSheetand callsParseString(), which parses the XML via libxml2 into anxmlDocPtr. - Child Iteration:
ParseStringcallsLoadChildSheets(), which begins awhileloop iterating over thexmlNodePtrchildren of the parsed document. - Premature Transformation: The loop encounters an
<xsl:import>element and synchronously fetches it. This bubbles up aCheckLoaded()call. Becauseloading_was maliciously set tofalsein Step 4,ProcessingInstruction::IsLoading()returnsfalse. - UAF Trigger: Believing all stylesheets are fully loaded,
DocumentXSLT::ApplyXSLTransformis called. It compiles the stylesheet, transferring ownership of thexmlDocPtrto libxslt, performs the transformation, and finally callsxsltFreeStylesheet, which entirely frees thexmlDocPtrtree viamalloc. - 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 freedxmlNodePtrchunks. - The Crash/Exploit: The call stack unwinds back to the
whileloop inLoadChildSheets(). The code executescurr = 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.