# pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph

Crafted pnpm-lock.yaml depPath keys with traversal sequences make pnpm's virtual store linker write package files to arbitrary paths during install.

- Payout: Research
- Severity: high
- Program: pnpm
- Found by: manus-use
- Disclosed: 2026-09-02
- CVE: CVE-2026-82392
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c59q-g84q-2gj5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c59q-g84q-2gj5
- Tags: area:oss domain:oss impact:data-leak impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** pnpm virtual store linker (lockfileToDepGraph.ts) processing pnpm-lock.yaml packages keys
**Root cause:** dp.parse() extracts the package name as a raw substring of the depPath with no validation, and lockfileToDepGraph.ts:233 joins it into the install dir via path.join(modules, pkgName), so traversal sequences escape the virtual store. The safeJoinModulesDir containment helper added for GHSA-fr4h-3cph-29xv was never applied to this linker.
**Steps:**
1. Craft pnpm-lock.yaml with a packages key containing traversal, e.g. `../../../../../../../tmp/pwned@1.0.0`, with a real package integrity in resolution
2. Add a snapshots entry for the same key and an importer dependency whose version points at the traversal depPath
3. Run `pnpm install`
4. Observe package content written to `/tmp/pwned/` instead of the virtual store
**How found:** Noticed the hoisted linker fix (safeJoinModulesDir) from GHSA-fr4h-3cph-29xv was not applied to the virtual store linker, which uses the identical dp.parse().name -> path.join() pattern; traced dp.parse() to a raw substring with no package-name validation and confirmed depPathToFilename only sanitizes dirInVirtualStore, not pkgName.
**Exploitation:** Attacker commits a crafted pnpm-lock.yaml (or ships one via a malicious package); any user running pnpm install writes attacker-chosen real package files to an attacker-chosen path. Targets include .git/hooks/pre-commit for code execution on next git operation, ~/.local/bin for binary hijacking, or project source files for supply chain injection. With dangerouslyAllowAllBuilds: true or the package in allowBuilds, the traversed path is reused in the rebuild phase and the attacker's postinstall executes with victim shell access.
**Escalation:** Arbitrary file write escalates to RCE via .git/hooks/pre-commit or via postinstall execution when dangerouslyAllowAllBuilds/allowBuilds permits builds; PnP linker variant lets .pnp.cjs packageLocation point outside the virtual store.
**Insight:** When a containment helper is added for one code path, grep for every other call site of the same unvalidated primitive (here dp.parse().name -> path.join) — incomplete fixes leave sibling linkers exposed. Also check whether sanitization functions cover all inputs or only one of several derived path components.

## Proof of concept (copied exactly from the original report)

**Crafted pnpm-lock.yaml with traversal depPath keys**

```
lockfileVersion: '9.0'
packages:
  ../../../../../../../tmp/pwned@1.0.0:
    resolution: {integrity: sha512-<real-package-integrity>}
    engines: {node: '>=14'}
snapshots:
  ../../../../../../../tmp/pwned@1.0.0: {}
importers:
  .:
    dependencies:
      legitimate-name:
        specifier: ^1.0.0
        version: ../../../../../../../tmp/pwned@1.0.0
```

