CVE-2026-87636
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/libxslt/chromium/0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patch |
modified | |
ifthird_party/libxslt/src/libxslt/functions.c |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.jsthird_party/libxslt/chromium/0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patchthird_party/libxslt/chromium/roll.pythird_party/libxslt/src/libxslt/functions.c
Patch
From 8005fbd4ba5355dc88df069fd99bf76e3fd2b525 Mon Sep 17 00:00:00 2001 From: Daniel Cheng <[email protected]> Date: Fri, 31 Jul 2026 20:32:17 -0700 Subject: [PATCH] Return an empty node-set from document('') with an invalid base URI `document('')` is sometimes used as an idiom for returning a node-set corresponding to the current stylesheet. However, resolving the empty string against an invalid base URI produces an invalid URI, and per section 12.1 of the XSLT 1.0 standard: If there is an error retrieving the resource, then the XSLT processor may signal the error; if it does not signal the error, it must recover by returning an empty node-set. The spec is a bit contradictory here; elsewhere, it also states: Note that a zero-length URI reference is a reference to the document relative to which the URI reference is being resolved; thus document("") refers to the root node of the stylesheet; the tree representation of the stylesheet is exactly the same as if the XML document containing the stylesheet was the initial source document. But the standard also says: The URI reference may be relative. The base URI of the node in the second argument node-set that is first in document order is used as the base URI for resolving the relative URI into an absolute URI. And resolving the empty string against an invalid URI should arguably produce an invalid URI. In some ways, this brings Chrome behavior closer to Firefox, though it still differs in other ways: the specific test case here returns a different result in Firefox, because Firefox appears to ignore an attempt to set an invalid base URI, while Chrome does not. Bug: 495541478 Change-Id: I9ea2be319c1d4295c75d7fa3bf9b177b428cfd65 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8178615 Reviewed-by: Mason Freed <[email protected]> Reviewed-by: Tom Sepez <[email protected]> Commit-Queue: Daniel Cheng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1672234} --- diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js b/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js index a01f3b3..99fab7ab 100644 --- a/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js +++ b/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js @@ -41,3 +41,26 @@ assert_true(Array.prototype.every.call(resultDoc.documentElement.children, (e) => e.localName == "success")); }, `xsl:document function disabled in transformToDocument`); + +test(() => { + // "http://[" is an invalid URI; [ signals the start of an IPv6 literal, but + // there is no actual address, nor is there a closing ]. + const xmlWithInvalidBase = parser.parseFromString( + `<foo xml:base="http://[" />`, "application/xml"); + const xsltStringWithInvalidBaseNode = ` + <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> + <xsl:template match="/"> + <result> + <count><xsl:value-of select="count(document('', /foo))" /></count> + </result> + </xsl:template> + </xsl:stylesheet> + `; + const xsltDocWithInvalidBaseNode = parser.parseFromString( + xsltStringWithInvalidBaseNode, "application/xml"); + const processor = new XSLTProcessor(); + processor.importStylesheet(xsltDocWithInvalidBaseNode); + const resultDoc = processor.transformToDocument(xmlWithInvalidBase); + assert_equals(resultDoc.querySelector("count").textContent, "0"); +}, `document() with invalid xml:base target node returns an empty node-set`); + diff --git a/third_party/libxslt/chromium/0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patch b/third_party/libxslt/chromium/0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patch new file mode 100644 index 0000000..b10b7272 --- /dev/null +++ b/third_party/libxslt/chromium/0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patch @@ -0,0 +1,38 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: David Cheng <[email protected]> +Date: Fri, 31 Jul 2026 15:00:00 -0700 +Subject: [PATCH] Return an empty node-set from document('') with an invalid base URI + +`document('')` is sometimes used as an idiom for returning a node-set +corresponding to the current stylesheet. However, resolving the empty string +against an invalid base URI produces an invalid URI, and per section +12.1 of the XSLT 1.0 standard: + + If there is an error retrieving the resource, then the XSLT processor + may signal the error; if it does not signal the error, it must recover + by returning an empty node-set. +--- + libxslt/functions.c | 9 +-------- + 1 file changed, 1 insertion(+), 8 deletions(-) + +diff --git a/libxslt/functions.c b/libxslt/functions.c +index 11ec039f540cc..317a44e5d01fe 100644 +--- a/libxslt/functions.c ++++ b/libxslt/functions.c +@@ -337,14 +337,7 @@ xsltDocumentFunction(xmlXPathParserContextPtr ctxt, int nargs) + if (base != NULL) + xmlFree(base); + if (URI == NULL) { +- if ((tctxt != NULL) && (tctxt->style != NULL) && +- (tctxt->style->doc != NULL) && +- (xmlStrEqual(URI, tctxt->style->doc->URL))) { +- /* This selects the stylesheet's doc itself. */ +- valuePush(ctxt, xmlXPathNewNodeSet((xmlNodePtr) tctxt->style->doc)); +- } else { +- valuePush(ctxt, xmlXPathNewNodeSet(NULL)); +- } ++ valuePush(ctxt, xmlXPathNewNodeSet(NULL)); + } else { + xsltDocumentFunctionLoadDocument(ctxt, URI, fragment); + xmlFree(URI); +-- diff --git a/third_party/libxslt/chromium/roll.py b/third_party/libxslt/chromium/roll.py index 277e0f4..16bf047 100755 --- a/third_party/libxslt/chromium/roll.py +++ b/third_party/libxslt/chromium/roll.py @@ -75,6 +75,7 @@ '0004-Use-a-dedicated-node-type-to-maintain-the-list-of-ca.patch', '0005-Verify-dictionary-ownership-before-aliasing-attribut.patch', '0006-Fix-type-confusion-in-xsltParseTemplateContent.patch', + '0007-Return-an-empty-node-set-from-document-function-with-invalid-uri.patch', ] diff --git a/third_party/libxslt/src/libxslt/functions.c b/third_party/libxslt/src/libxslt/functions.c index 11ec039..317a44e 100644 --- a/third_party/libxslt/src/libxslt/functions.c +++ b/third_party/libxslt/src/libxslt/functions.c @@ -337,14 +337,7 @@ if (base != NULL) xmlFree(base); if (URI == NULL) { - if ((tctxt != NULL) && (tctxt->style != NULL) && - (tctxt->style->doc != NULL) && - (xmlStrEqual(URI, tctxt->style->doc->URL))) { - /* This selects the stylesheet's doc itself. */ - valuePush(ctxt, xmlXPathNewNodeSet((xmlNodePtr) tctxt->style->doc)); - } else { - valuePush(ctxt, xmlXPathNewNodeSet(NULL)); - } + valuePush(ctxt, xmlXPathNewNodeSet(NULL)); } else { xsltDocumentFunctionLoadDocument(ctxt, URI, fragment); xmlFree(URI);
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js b/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js
index a01f3b3..99fab7ab 100644
--- a/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js
+++ b/third_party/blink/web_tests/external/wpt/xml/xslt/document-function.window.js
@@ -41,3 +41,26 @@
assert_true(Array.prototype.every.call(resultDoc.documentElement.children,
(e) => e.localName == "success"));
}, `xsl:document function disabled in transformToDocument`);
+
+test(() => {
+ // "http://[" is an invalid URI; [ signals the start of an IPv6 literal, but
+ // there is no actual address, nor is there a closing ].
+ const xmlWithInvalidBase = parser.parseFromString(
+ `<foo xml:base="http://[" />`, "application/xml");
+ const xsltStringWithInvalidBaseNode = `
+ <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+ <xsl:template match="/">
+ <result>
+ <count><xsl:value-of select="count(document('', /foo))" /></count>
+ </result>
+ </xsl:template>
+ </xsl:stylesheet>
+ `;
+ const xsltDocWithInvalidBaseNode = parser.parseFromString(
+ xsltStringWithInvalidBaseNode, "application/xml");
+ const processor = new XSLTProcessor();
+ processor.importStylesheet(xsltDocWithInvalidBaseNode);
+ const resultDoc = processor.transformToDocument(xmlWithInvalidBase);
+ assert_equals(resultDoc.querySelector("count").textContent, "0");
+}, `document() with invalid xml:base target node returns an empty node-set`);
+
Original Bug Report
Type Confusion in libxslt xsltDocumentFunction via stylesheet document exposure
Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.
Overview: A logic error in xsltDocumentFunction allows an attacker to obtain a reference to the active stylesheet document. By applying generate-id() to uncompiled instruction nodes in this document, internal psvi fields are overwritten with integer IDs, leading to a potential type confusion and arbitrary code execution during transformation.
Affected files:
third_party/libxslt/src/libxslt/functions.c
Estimated timestamp from git blame: 2015-06-23
Summary
A potential type confusion vulnerability exists in libxslt’s document() function. When URI resolution fails, the function can incorrectly return the active stylesheet document itself instead of an empty node-set. This exposure allows an attacker to manipulate the document’s internal metadata using the generate-id() function, leading to type confusion when the stylesheet is later processed.
Technical Details
In third_party/libxslt/src/libxslt/functions.c, the xsltDocumentFunction handles the XSLT document() call. The function attempts to resolve the URI for the document using xmlBuildURI:
336: URI = xmlBuildURI(url, base);
337: if (base != NULL)
338: xmlFree(base);
339: if (URI == NULL) {
340: if ((tctxt != NULL) && (tctxt->style != NULL) &&
341: (tctxt->style->doc != NULL) &&
342: (xmlStrEqual(URI, tctxt->style->doc->URL))) {
343: /* This selects the stylesheet's doc itself. */
344: valuePush(ctxt, xmlXPathNewNodeSet((xmlNodePtr) tctxt->style->doc));
345: } else {
346: valuePush(ctxt, xmlXPathNewNodeSet(NULL));
347: }
348: } else {
349: xsltDocumentFunctionLoadDocument(ctxt, URI, fragment);
350: xmlFree(URI);
351: }
If xmlBuildURI returns NULL (which happens if base is an invalid URI like invalid://::), the code checks if URI is equal to the stylesheet document’s URL. If the stylesheet was loaded from memory (e.g., via XSLTProcessor.importStylesheet()), tctxt->style->doc->URL is NULL. Since xmlStrEqual(NULL, NULL) returns 1, the function pushes the live stylesheet document onto the XPath stack.
This short-circuit logic bypasses a previous fix (issue 413080347) in xsltDocumentFunctionLoadDocument, which securely copies the document and strips internal psvi pointers.
Once the live stylesheet document is returned, an attacker can use XPath to select an uncompiled XSLT instruction node (like <xsl:message>) whose psvi field is still NULL. Calling the generate-id() function on this node in xsltGenerateIdFunction assigns it a new ID by incrementing tctxt->currentId and storing this integer directly into the psvi field:
796: id = ++tctxt->currentId;
797: *psviPtr = (void *) (size_t) id;
When the XSLT engine subsequently evaluates this instruction in xsltApplySequenceConstructor (transform.c:2736), it expects the psvi field to be a valid pointer to an xsltStylePreComp structure:
2736: xsltStylePreCompPtr info = (xsltStylePreCompPtr) cur->psvi;
...
2756: if (info->func != NULL) {
...
2761: info->func(ctxt, contextNode, cur, (xsltElemPreCompPtr) info);
Because info is now an attacker-controlled integer (the generated ID), dereferencing it to call info->func results in a type confusion. An attacker could potentially spray the heap and increment currentId (by calling generate-id() repeatedly) to point to a controlled fake xsltStylePreComp object, leading to Remote Code Execution in the renderer process.
Suggested Steps to Reproduce (Theoretical)
- Load a malicious XSLT stylesheet from a memory buffer (e.g., using
XSLTProcessor.importStylesheet()). - In the XML input, include a node with an invalid base URI:
<foo xml:base="invalid://::"/>. - In the stylesheet, call
document('', $foo_node). This forcesxmlBuildURIto returnNULLand exposes the active stylesheet document. - Select an uncompiled instruction node (e.g.,
<xsl:message>) from the returned document using XPath. - Call
generate-id()on dummy nodes to incrementcurrentIdto a predictable heap address where a fakexsltStylePreCompobject is sprayed. - Call
generate-id()on the uncompiled instruction node to overwrite itspsvifield with this target address. - Trigger the execution of the corrupted instruction node in the stylesheet.
- The engine dereferences the fake object and calls the attacker-controlled function pointer.
Note: These are suggested steps, as this AI agent does not yet have the ability to run code to verify the exploit end-to-end.
Suggested Fix
In xsltDocumentFunction, remove the short-circuit logic that directly pushes tctxt->style->doc. Instead, always rely on xsltDocumentFunctionLoadDocument, which handles the safe copying of the stylesheet document and stripping of psvi fields. Alternatively, explicitly check that URI and tctxt->style->doc->URL are not NULL before calling xmlStrEqual.
Evaluated with Chrome root at commit: 9760e6c70cd33a320713361f17c6dcca85648c0f
Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.