Writing something after such a long time. While I wrote the last post with some desperation, I really wanted to elaborate on something interesting I discovered, in some clean fashion. I submitted a couple of nice escalations to Cloudflare in the past couple of months and this one talks about one of them.

One fine evening after finishing work at daytime, I noticed Kenny’s post about Cloudflare’s new MCP Server Portal feature. I already knew something was cooking about it through the JS analysis a few days ago, but didn’t care enough before it came to reality. This post revolves around a simple OAuth misconfiguration & what your deep-target recon can turn from a Single App to an Entire Organization Takeover.

Preparation: WTH is MCP Server Portal

MCP (Model Context Protocol) Server Portal is a new feature in Cloudflare One that allows organizations to expose MCP servers through a centralized portal, in addition to various fine-grained access control policies.

It works by registering a new (sub)domain for MCP Server Portal (I’ll refer to it as MSP for short in the rest of the post), say mcp.rookie.com, and Cloudflare will internally handle OAuth for this domain and protect access to various MCP Servers exposed on this domain. Typical use cases involve restricting MCP Tools for users based on their roles, governing their usage, etc.

Opening: Second-Order XSS in MSP

While exploring the OAuth implementation of MSP, I noticed that the /register endpoint accepts a redirect_uris parameter during client registration.

The OAuth flow in MSP looks like:

sequenceDiagram
    participant Client as MCP Client
    participant MSP as MCP Server Portal<br/>mcp.rookie.com
    participant CF as Cloudflare Access<br/>team.cloudflareaccess.com

    Client->>MSP: 1. Initiate auth at /mcp
    Client->>MSP: 2. POST /register with redirect_uri
    MSP-->>Client: client_id

    Client->>MSP: 3. GET /authorize?client_id&redirect_uri

    alt No CF_Authorization cookie
        MSP->>CF: Redirect to Cloudflare Access
        CF->>Client: Login page
        Client->>CF: Authenticate
        CF->>MSP: Redirect back with session
    end

    MSP-->>Client: 4. Redirect to /authorize?code&client_id&redirect_uri
    MSP-->>Client: 5. window.location.href = redirect_uri

It was quite naive to discover that there was no validation on this parameter: it accepts any string input.

POST /register HTTP/1.1
Host: mcp.rookie.com
Content-Type: application/json

{
  "redirect_uris": ["random.example"],
  "client_name": "Malicious Client"
}

The server happily registers this malicious redirect URI and returns a client_id in the response:

{
  "client_id": "malicious-client-id-here",
  "redirect_uris": ["random.example"]
}

After couple of OAuth redirects, the final code is generated on /authorize endpoint and redirected back to the client using window.location.href: a known XSS Sink. The first attempt to use javascript: URI worked, meaning I can execute any javascript code on the MSP domain.

Here’s the actual payload I used: test

To sum up, I can create a malicious redirect_uri, associated with a server accepted client_id in the client registration part and then use this client_id in the later mcp.rookie.com/authorize endpoint to trigger the XSS.

The XSS looked like: test

This is a classic Second-Order XSS - the payload is stored during registration and triggered during authorization.

Executing JS on MSP was easy. Now, I focused on finding some real impact. I could see the App-scoped CF_Authorization cookies are non-HTTPOnly (however, they can be fine-tuned in the Cloudflare dashboard), susceptible to stealing via XSS. Also, I could interact with the MSP – the tools it has access to, and the other implications from it.

I spent some more time trying to increase the impact to other associated domains, but couldn’t find anything immediately, so I reported the initial finding to their security team.

Mid Game: From Single App to Org-Wide ATO

This is the most interesting and complex part of all.

To understand the full exploit chain, here’s a mini show on how the Cloudflare Access authentication works:

sequenceDiagram
  participant User
  participant App as Protected App<br/>app.rookie.com
  participant CF as Cloudflare Access<br/>team.cloudflareaccess.com
  participant IdP as Identity Provider<br/>(Google/Okta/etc)

  User->>App: 1. Access protected resource
  
  alt No CF_Authorization cookie
    App->>CF: 2. Redirect to Access login
    CF->>User: 3. Show login options
    User->>CF: 4. Select identity provider
    CF->>IdP: 5. Redirect to IdP login
    User->>IdP: 6. Authenticate
    IdP->>CF: 7. Return with SAML/OIDC assertion
    CF->>CF: 8. Validate & create session
    CF->>User: 9. Set CF_Authorization cookies:<br/>- Domain-specific (app.rookie.com)<br/>- Org-wide (*.cloudflareaccess.com)
    CF->>App: 10. Redirect back to app
  end
  
  App->>App: 11. Validate CF_Authorization
  App->>User: 12. Grant access to resource

The critical insight here is that Cloudflare sets TWO types of cookies:

  1. Domain-specific cookie: Valid only for the specific application domain
  2. Org-wide cookie: Valid for teamname.cloudflareaccess.com and can be exchanged for access to ANY app in the organization

I had previously discovered Account Takeovers of Cloudflare Access Organizations by stealing the org-wide CF_Authorization cookie (using another patched vulnerability), which was set on the team.cloudflareaccess.com domain. This cookie essentially allows access to every single application that the user is permitted to access.

Recalling from the previous vulnerability, I was attempting to mimic the flow used by the cloudflared CLI tool for org token exchange and transfer the App-specific and org-wide CF_Authorization cookie to the device initiating the attempt. The important thing to notice from this flow was that whenever the redirect_uri in the Cloudflare Access login flow contains isCLI=true and send_org_token=true parameters, the redirected URL contains an additional orgNonce parameter; otherwise, the URL only contains the nonce parameter. If I could get this orgNonce somehow, it would be possible to exchange it for the org-wide CF_Authorization cookie and hence achieve org-wide ATO!

Challenge 1: Getting the orgNonce

When the CF_Authorization cookie is present on teamdomain.cloudflareaccess.com (i.e., an authenticated victim session), passing these additional parameters in the redirect_uri param on Step 2 redirects directly to the MSP endpoint at mcp.rookie.com/cdn-cgi/access/authorized?nonce=…&orgNonce=…. When app-specific CF_Authorization cookies are also present, the subsequent redirect doesn’t include the orgNonce. I found out that if at this point App cookies are absent in the request headers, App-specific CF_Authorization cookies are specifically set in the response headers (because we’re on the mcp.rookie.com/cdn-cgi/access/authorized?nonce=… endpoint) and then it redirects to another MSP endpoint with orgNonce present in the query param. Since we have XSS on the MSP domain, I can now steal the orgNonce.

This approach requires me to delete/remove the App-specific CF_Authorization cookie on MSP domain after gaining XSS on MSP domain. Later stealing was possible because MSP’s cookie will be set again when absent and hence the subsequent requests will be authenticated.

Challenge 2: Can’t Delete HTTPOnly Cookies

The cookies on the MSP domain can be HTTPOnly (configurable from the Cloudflare dashboard), so I couldn’t delete or override them via JavaScript. I needed another way to invalidate the domain-specific session.

I tried the documented way to log out a session – mcp.rookie.com/cdn-cgi/access/logout, but this invalidates cookies on teamname.cloudflareaccess.com as well (because they’re tied to the same session identifier), which breaks the chain since we need the org-wide session to remain valid.

After extensive testing, I discovered an unusual URL that invalidates only the MSP domain-specific CF_Authorization cookie: https://mcp.rookie.com/cdn-cgi/access/login/mcp.rookie.com

A simple intermittent redirection to this endpoint clears the cookies on the MSP domain without affecting the session on teamname.cloudflareaccess.com.

The Final Chain

With the orgNonce generated and transferred to an MSP endpoint (post 3-4 automatic redirects), I can access that full URL by virtue of XSS on MSP (same origin policy allows it) and hereby retrieve the orgNonce.

Here’s the complete attack chain:

flowchart TB
  subgraph "Attack Setup"
    A1[Attacker registers malicious OAuth client<br/>with javascript: redirect URI]
    A2[Generate authorization URL<br/>mcp.rookie.com/authorize?client_id=xxx]
  end
  
  subgraph "Victim Interaction"
    V1[Victim clicks link<br/>Single click required]
    V2[XSS triggers on mcp.rookie.com]
  end
  
  subgraph "Exploit Chain via XSS"
    E1[Invalidate MSP cookie via<br/>mcp.rookie.com/cdn-cgi/access/login/mcp.rookie.com]
    E2[Navigate to teamname.cloudflareaccess.com/cdn-cgi/access/login/mcp.rookie.com?redirect_uri=...<br/> And finally redirected to mcp.rookie.com/cdn-cgi/access/cli?orgNonce=...]
    E3[Extract orgNonce from URL<br/>Now visible due to initial missing domain cookie]
    E4[Exchange orgNonce for<br/>org-wide CF_Authorization JWT]
  end
  
  subgraph "Impact"
    I1[Access ALL Cloudflare Access apps<br/>in victim's organization]
    I2[Steal MCP server data]
    I3[Access internal WARP/VPN resources]
  end
  
  A1 --> A2 --> V1 --> V2 --> E1 --> E2 --> E3 --> E4 --> I1
  I1 --> I2
  I1 --> I3

Impact

With the org-wide CF_Authorization cookie, an attacker can:

  1. Access ALL Cloudflare Access protected applications in the victim’s organization like Jira, Slack, etc…
  2. Impersonate the victim across all apps - Self-Hosted, SaaS, Private/Internal
  3. Access sensitive data from MCP servers which might hold internal information like internal documentation
  4. Potentially access WARP/VPN protected internal networks

I wanted to create a single click exploit, so the XSS performed the entire “logout-login” dancing and finally the orgNonce stealing.

End Game: Multiple Patch Bypasses through double-quote escaping & JS tricks

Cloudflare patched the above issue by restricting URLs with http/s protocol in the redirect_uris parameter. I found a bypass by escaping the double-quotes and injecting dangerous JS directly into the script tag. Quotes in the Host part of the URL weren’t escaped, but Path/Query Params were working as intended. A few blacklisted characters were causing issues due to some URL validations, but some JavaScript tricks were enough to bypass them. Once XSS is proved, the same impact can be achieved.

A few weeks later, I found another bypass, surprisingly through the same “javascript” protocol, but using a case-sensitive exploit. I suspect this was a regression of the first vulnerability, since it was tested by me before. Though this issue isn’t reproducible anymore, the H1 report is still open and unrewarded.

During my last review, Cloudflare has changed the whole design pattern and added a new interstitial window before final redirection, thus fixing all of the plausible issues.

Timeline

DateEvent
Aug 27, 2025Initial report submitted - XSS leading to MCP Portal ATO
Aug 27, 2025Escalation submitted - Single App XSS to Org-Wide ATO
Sep 05, 2025Report triaged by HackerOne
Sep 11, 2025Severity downgraded to Low
Sep 12, 2025Cloudflare responds - Low severity due to “phishing requirement”
Sep 15, 2025$450 bounty awarded :)
Oct 24, 2025Bypass found and submitted
Nov 5, 2025Bypass patched and awarded $400 bounty
Nov 24, 2025Regression reported
Dec 11, 2025Disclosure approved with suggestions
Dec 16, 2025Writeup published

Conclusion

What started as a simple observation about a new feature announcement turned into a full org-wide Account Takeover chain. The key was not stopping at the initial finding (XSS with HTTPOnly cookie limitations) but pushing further to find creative ways to escalate the impact.

Oh yeah, I realized that I’ve climbed to top spot recently 😉 test

That’s all for this post. Stay curious ✌️.