← Back to Blog

How we hijacked a browser through Claude's Chrome extension

2026-03-30 · Matt Hand

At Origin, we build endpoint observability for autonomous AI systems. Part of that work is understanding how AI agents interact with the systems they run on, and where the trust boundaries actually are. Earlier this month, I was reviewing IPC endpoints on a workstation running Claude's Chrome extension and noticed something I wasn't expecting.

There was a named pipe on the machine, accessible to the Everyone group: \\.\pipe\claude-mcp-browser-bridge-matt.

I didn't know what it did. The name suggested it was related to Claude, but there was no documentation on the protocol it spoke or what was on the other end.

Pipe restrictions

I pulled the pipe's security descriptor, which listed the following permissions:

Claude for Chrome named pipe's DACL

Most interesting/useful for an attacker is that any process running as the owner, SYSTEM, or an administrator gets full read/write permissions over the named pipe. This means that those roles can issue commands to and read repsonses from the named pipe.

The pipe name follows the pattern claude-mcp-browser-bridge-{USERNAME}, making it trivially discoverable. The native host accepts up to five concurrent clients, so a malicious process can coexist alongside the legitimate Claude Code session without disrupting it. There's also no client authentication, so the pipe accepts any connection that the Windows ACL permits. Importantly, the pipe does not accept remote connections, meaning that only local clients can interact.

What's on the other end

I traced the pipe handle to chrome-native-host.exe, a Rust binary living in Claude's local AppData directory. Running strings against the binary turned up a few things that told me what I was dealing with:

struct ToolRequest with 2 elements
methodparams
Forwarding tool request from MCP client
Failed to parse tool request from MCP client

The struct ToolRequest with 2 elements followed by methodparams is serde (Rust's serialization library) telling me the expected JSON shape: an object with method and params fields. I also found mcp_conn, mcp_disconnected, and a version string of 0.1.0. This was a Chrome Native Messaging host, acting as a bridge between local MCP clients (like Claude Code) and the Claude browser extension.

The architecture looks something like this:

Claude for Chrome's high-level architecture

Speaking the protocol

The binary strings gave me the message structure, but I still needed the wire format. The log output included Invalid message length, which told me messages are length-prefixed. I guessed 4-byte little-endian (standard for Windows IPC), and luckily enough the first attempt worked.

To figure out which methods the server accepted, I started sending messages and reading the errors. A tools/list request came back with Unknown method: tools/list, which told me the server parses the method field and rejects anything it doesn't recognize. I tried the method names I'd seen in the binary strings and the extension's JavaScript. Only one worked: execute_tool.

The execute_tool method takes a tool name and an arguments object. For the tool names themselves, I looked again at the extension's service worker JavaScript, specifically the mcpPermissions file, which defines all the tool handlers and their schemas. One of them, tabs_context_mcp, looked like a safe first probe since it's designed to return context about available tabs. I sent it with createIfEmpty: True, which resulted in a new Chrome window popping up on screen with a tab group labeled "Claude (MCP)", and the pipe handed back a the tab ID.

What the extension can do

The mcpPermissions file describes each of the available tools, but in summary:

ToolCapability
javascript_toolExecute arbitrary JS in any browser tab
computerScreenshots, mouse clicks, keyboard input, drag and drop
navigateNavigate any tab to any URL
read_pageRead the full DOM and accessibility tree
get_page_textExtract all text from a page
form_inputFill form fields
read_network_requestsMonitor HTTP requests from the page
tabs_context_mcpCreate and manage tab groups
tabs_create_mcpCreate new tabs
tabs_close_mcpClose tabs
gif_creatorRecord browser sessions as GIF
upload_imageUpload screenshots to file inputs
read_console_messagesRead the browser console
findSearch for elements on the page
resize_windowResize the browser window

Each of these tools supports some productivity (and adversarial) use case, but there are two that immediately jumped out to me. javascript_tool is a full code injection primitive, allowing an attacker to run arbitrary JavaScript code in the page context with the user's session cookies. computer provides complete GUI automation, including screenshots, mouse movement, clicks, and keyboard input. Between the two of them, there isn't much a human could do in a browser that an attacker couldn't replicate through the pipe.

To add to this, the LevelDB contained a full Anthropic API key under accessToken and an OAuth refresh token under refreshToken - no encryption and no OS keychain integration. Any standard user-level process with filesystem access can read them. The API key alone is enough to make authenticated Anthropic API calls billed to the account owner, and is a prime target for info stealer malware campaigns.

Hitting the permission wall

My first attempt at running javascript_tool failed. The newly created tab was on edge://newtab, a restricted page where extensions can't execute scripts. I navigated to a real site and tried again. This time the extension popped a permission prompt in the browser, asking whether to allow Claude to act on that domain.

Claude's browser extension prompting for permission

This is the extension's permission model. When Claude tries to interact with a website for the first time, the user sees a dialog with options for one-time or permanent access. If Claude asks to navigate to your bank, this prompt is what stands between you and an autonomous agent operating on your accounts.

I granted the permission and the JavaScript executed. document.title came back with the page title. Screenshots, DOM reads, form filling, navigation - everything worked. But I wanted to know where that permission grant was stored, and whether it could be tampered with so that a user would never be prompted before sending my JavaScript.

Injecting permissions

The permission state is stored in chrome.storage.local, which under the hood is a LevelDB database on disk at a predictable path. In Edge, that's:

%LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Local Extension Settings\fcoeoabgfenejglbffodgkkbkcdhcgfn

I wanted to inspect it, so I needed a simple LevelDB reader. LevelDB's on-disk format is well-documented: .log files contain the write-ahead log, .ldb/.sst files contain sorted key-value data. Parsing them doesn't require the full LevelDB library, just an understanding of varint encoding, record framing, and block structure. Claude Code made quick work of this.

The permission allow list is stored in the LevelDB under permissionStorage:

{
  "permissions": [
    {
      "action": "allow",
      "duration": "always",
      "scope": { "netloc": "example.com", "type": "netloc" },
      "id": "47589731-05f2-45b1-9129-f1999b5e90b0",
      "createdAt": 1772557264040
    }
  ]
}

Permissions are per-domain, not per-tool. Once you allow example.com, all tools (JS execution, screenshots, navigation, form filling) are permitted on that domain. And there's no HMAC, no signature, and nothing binding these entries to the user's actual consent. The extension reads this JSON on startup and trusts it completely.

This lack of validation means that any process running as the current user can close the browser (or copy the LevelDB directory), write a new permission entry for any domain it wants, and relaunch. The extension treats the injected domain as user-approved. Claude operates on that site without ever showing a consent prompt.

I tested this by adding an entry for a domain I hadn't manually approved. After relaunching the browser, javascript_tool executed on that domain without any prompt. The permission wall I'd hit earlier was gone.

Putting it all into practice

With the permission bypass working, I wrote a small proof of concept that does the following:

  1. Injects "always allow" permissions into the extension's LevelDB storage, bypassing the permission prompt for the target domain
  2. Reads the browser's session restore files to discover active tab IDs
  3. Connects to the unauthenticated named pipe and scans for a tab matching the target domain
  4. Executes arbitrary JavaScript on the hijacked tab and exfiltrates cookies for the target site without opening any new tabs or windows

The named pipe gives any local process direct, unauthenticated browser automation right now. The LevelDB gives any local process the ability to silently pre-approve domains for future Claude interactions, plus steal API credentials for use outside the extension entirely. Neither requires administrator privileges or a browser exploit. An attacker who has access to the endpoint has the ability to inject arbitrary code, read cookies, and record the browser window of the compromised user, all without their knowledge.

What about macOS?

This functionality is also implemented on macOS. Rather than a named pipe, a Unix domain socket is used to facilitate IPC.

  • IPC: /tmp/claude-mcp-browser-bridge-{USERNAME}/*.sock
  • LevelDB: ~/Library/Application Support/Chrome/{PROFILE}/Local Extension Settings/fcoeoabgfenejglbffodgkkbkcdhcgfn/
  • Native host binary: /Users/{USERNAME}/.claude/chrome/chrome-native-host

All of these have the same security policy, allowing the user of the system to read and write to them.

What should change

There are three main issues in my eyes.

The first is that there is no client verification on the named pipe server. This means that any application on the endpoint can connect to the named pipe and make requests. If Anthropic only intends for this pipe to be used by Claude, then adding some checks around that would be appropriate, however this becomes tricky when supporting Claude embedded into another application, like VS Code. A per-session token that MCP clients must present before execute_tool calls are processed may work.

On the storage side, API keys and OAuth tokens belong in the OS credential manager (DPAPI on Windows, Keychain on macOS, libsecret on Linux), not plaintext LevelDB.

The larger issue is that there is no signing or verification when a new site permission is granted in the LevelDB. Permission entries should carry an HMAC or signature so the extension can tell if they've been tampered with. Ideally, permission grants would also be validated server-side rather than trusted purely from a local file. The security boundary here is human approval, and the PoC presented in this blog shows that it can by trivially bypassed.

Disclosure

These findings were submitted to Anthropic via their HackerOne bug bounty program, where they were closed due to the local code execution requirement.