CVE-2026-87636
Overview
Background
- `document()` function
- an XSLT/XPath function that loads an external XML resource and returns its nodes as a node-set, with the idiom
document('')conventionally selecting the current stylesheet’s own document. - `xsltDocumentFunction`
- the libxslt C implementation of
document()infunctions.c, which resolves the argument URI against a base URI and pushes the resulting node-set onto the XPath value stack viavaluePush. - Base URI resolution
- computing an absolute URI from a reference and a base with
xmlBuildURI; when the base is malformed (e.g.http://[, an unterminated IPv6 literal) resolution fails and yieldsNULL. - Type confusion
- treating an object as a type it is not; here, injecting the stylesheet’s own document node into a node-set that is being evaluated over a different tree (the source document).
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Diff confirms the NULL-URI comparison pushing the stylesheet doc. But the claimed invariant (node-sets hold only source-tree nodes) is unsupported: document() legitimately returns other documents' nodes; the diff does not show where the stylesheet node is misinterpreted.
The vulnerable code path is the URI == NULL branch of xsltDocumentFunction, which is entered when the second-argument node’s xml:base is invalid and xmlBuildURI therefore returns NULL. On that failure path the old code tried to detect the document('')-selects-stylesheet idiom by evaluating xmlStrEqual(URI, tctxt->style->doc->URL) — but URI is already known to be NULL inside this branch, so the comparison is degenerate and, when the stylesheet document’s URL is also NULL (as it is for a stylesheet parsed from a string), it returns true and pushes tctxt->style->doc (the stylesheet’s document node) into the result node-set. That inserts a node belonging to the stylesheet’s document tree into a node-set produced while evaluating XPath over the source document, violating the invariant that such a node-set contains only nodes from the evaluation’s own tree. Because the stylesheet document and its nodes are typed and lifetime-managed differently from source nodes, downstream code that consumes the node-set can misinterpret the node — type confusion.
The fix removes the entire idiom-detection branch so a failed URI resolution unconditionally pushes an empty node-set (xmlXPathNewNodeSet(NULL)), which matches the XSLT 1.0 recovery rule and eliminates the stray stylesheet node.
NULL URI against the stylesheet’s URL on the resolution-failure path, which let the stylesheet’s own document node leak into a source-context node-set; the fix simply returns an empty node-set whenever resolution fails.Attack Path
- Craft an invalid base URI
The attacker supplies source XML whose element carries
xml:base="http://[", an invalid URI where[opens an IPv6 literal that has no address and no closing]. - Invoke `document('')` against that node
A stylesheet calls
document('', /foo), passing the invalid-base node/fooas the second argument so that its base URI drives the resolution. - Force a `NULL` resolution
xmlBuildURIresolving the empty string against the invalid base returnsNULL, sending execution into theURI == NULLbranch ofxsltDocumentFunction. - Leak the stylesheet document node
Because
URIisNULLand a string-parsed stylesheet’sdoc->URLis alsoNULL, the degeneratexmlStrEqualtest passes andtctxt->style->docis pushed into the source-context node-set instead of an empty node-set. - Trigger type confusion Later processing of that node-set treats the stylesheet’s document node as a source-tree node, causing type confusion and potential memory corruption (inferred).
Impact Assessment
XSLTProcessor transform can cause the stylesheet’s own document node to be injected into a source-tree node-set, producing a type confusion inside libxslt running in the renderer process. The preconditions are that the attacker can run an XSLT transform with a controllable xml:base on the document() second-argument node and a stylesheet whose document URL is NULL (as with parseFromString). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8. A severity rating are not stated in the patch, and any escalation from the type confusion to memory corruption or code execution is (inferred) rather than demonstrated by the diff.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
Audit Directions
- Cross-tree node leakageReview other libxslt/libxml functions that
valuePushnodes onto the XPath stack for cases that can mix stylesheet-tree nodes with source-tree nodes within a single node-set. - Degenerate guards on error pathsLook for conditions that test a variable already known to be
NULLor an error sentinel inside its own failure branch (especiallyxmlStrEqual/pointer checks) guarding node-set or object construction. - `xmlBuildURI` failure fallbacksAudit callers that branch on a
NULLresolution result and fall back to a document-self or default node instead of returning an empty/error result.
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/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); 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`); +
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.