CVE-2026-9897
Overview
Background
- `ProcessingInstruction::ProcessStylesheet`
- the method that handles an
<?xml-stylesheet?>PI, branching on whether thehrefis a local anchor or an external URL (processing_instruction.cc:386). - `XSLStyleSheet::LoadChildSheets`
- walks the parsed stylesheet’s libxml2
xmlNodetree and synchronously fetches eachxsl:import/xsl:includechild (xsl_style_sheet_libxslt.cc:163). - `stylesheet_doc_taken_`
- a flag meaning libxslt has taken ownership of (and may have freed) the sheet’s
xmlDoc; the destructor skipsxmlFreeDocwhen it is set (xsl_style_sheet_libxslt.cc:98). - `ClearResource`
- detaches the
ProcessingInstructionas a client of its pending stylesheet fetch so a late response is not processed.
Root Cause Analysis
Verify before building a PoC
In the pre-fix ProcessStylesheet, the local-sheet branch (processing_instruction.cc:389) sets loading_ = false at processing_instruction.cc:398 and returns at processing_instruction.cc:401 without ever calling ClearResource(); only the external branch calls ClearResource() at processing_instruction.cc:403. If a PI switches from an external href to a local one while a fetch is in flight, the object stays registered as a client of that stale fetch. When the late response arrives, NotifyFinished builds a new XSLStyleSheet and calls ParseString (processing_instruction.cc:454-457), which runs LoadChildSheets. Inside the import and include loops, LoadChildSheet is called synchronously (xsl_style_sheet_libxslt.cc:199 and :213); that call can recurse into an XSL transformation that takes and frees the parent sheet’s stylesheet_doc_, yet the pre-fix loop keeps iterating with curr = curr->next (xsl_style_sheet_libxslt.cc:204,:216) over the now-freed libxml2 tree. The invariant — that the xmlDoc being iterated stays alive for the duration of the loop — is violated.
The fix restores it two ways: ClearResource() is added before the local-sheet return (diff at processing_instruction.cc @@ -397,6) to stop stale responses being processed, and a if (stylesheet_doc_taken_) { return; } guard is added after each LoadChildSheet()/xmlFree() in both loops (diff at xsl_style_sheet_libxslt.cc @@ -198,6 and @@ -212,6) to bail out the instant the doc has been taken.
xmlDoc never re-checked whether a synchronous LoadChildSheet() had caused that doc to be freed, and a PI switching to a local href left a stale fetch armed; the patch both clears the stale resource and makes the loop bail once stylesheet_doc_taken_ is set.Attack Path
- Start an external XSL fetch
A PI points at an external, deliberately slow
.xsl, soProcessStylesheettakes the external branch, setsloading_ = trueand begins the fetch (processing_instruction.cc:410). - Switch to a local href mid-flight
Script rewrites the PI’s data to a local
#anchor; the local branch setsloading_ = falseand returns withoutClearResource(), leaving the stale fetch armed (processing_instruction.cc:389-401). - Stale response parses a stylesheet
When the delayed response arrives,
NotifyFinishedcreates anXSLStyleSheetand callsParseString, which invokesLoadChildSheets(processing_instruction.cc:454-457). - Synchronous child load frees the doc
Processing an
xsl:importchild viaLoadChildSheet(xsl_style_sheet_libxslt.cc:199) recurses into a transform that frees the parent’sstylesheet_doc_while the loop is still active. (inferred) - Use-after-free on continued iteration
Execution returns to the loop, which advances
curr = curr->nextover the freed libxml2 node tree (xsl_style_sheet_libxslt.cc:204). Memory corruption beyond this point is (inferred).
Impact Assessment
xmlNode/xmlDoc memory in the renderer process. Because these objects are C malloc allocations outside Blink’s garbage collector, the freed memory is a corruption primitive; the advisory rates this high severity. The advisory records no in-the-wild exploitation; escalation to code execution is (inferred).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
Audit Directions
- Synchronous reentrancy during iterationWhen a loop iterates a C-allocated tree (libxml2) and calls out to code that can synchronously free that tree, re-check a liveness flag after every such call before dereferencing the next node.
- State transitions that disarm guardsWhen an object can switch modes (external to local fetch) and a mode flag like
loading_is cleared, verify that every associated resource/client is also released, not just the flag. - Non-GC memory in a GC worldTreat objects owned outside Oilpan (libxml2/libxslt
mallocallocations) as unprotected by MiraclePtr and audit every path where their lifetime can end mid-operation.
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.