GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-C59Q-G84Q-2GJ5

Vulnerability from github – Published: 2026-09-02 14:37 – Updated: 2026-09-02 14:37
VLAI
Summary
pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph
Details

Summary

The virtual store linker constructs package installation directories using path.join(modules, pkgName) where pkgName is extracted from lockfile packages keys via dp.parse(depPath).name without validation. A crafted pnpm-lock.yaml with traversal sequences in depPath keys (e.g., ../../../tmp/pwned@1.0.0) causes package content to be written to arbitrary filesystem paths during pnpm install.

This is an incomplete fix of GHSA-fr4h-3cph-29xv — the safeJoinModulesDir containment helper was applied to the hoisted linker and symlinkDependency but NOT to the virtual store linker's lockfileToDepGraph.ts:233.

Details

Root Cause

dp.parse() at pnpm11/deps/path/src/index.ts:135 extracts the package name as:

const name = dependencyPath.substring(0, sepIndex)

This is a raw substring operation with zero validation that name is a valid npm package name. A depPath of ../../../tmp/pwned@1.0.0 yields name = '../../../tmp/pwned'.

Vulnerable Code Path

  1. pnpm-lock.yamllockfile.packages['../../../../../../../tmp/pwned@1.0.0'] (attacker-controlled lockfile key)
  2. nameVerFromPkgSnapshot(depPath, pkgSnapshot) at lockfile/utils/src/nameVerFromPkgSnapshot.ts:16 → calls dp.parse(depPath) → returns { name: '../../../../../../../tmp/pwned' }
  3. lockfileToDepGraph.ts:232modules = path.join(dirInVirtualStore, 'node_modules')
  4. lockfileToDepGraph.ts:233dir = path.join(modules, pkgName) → resolves to /tmp/pwned (ESCAPES virtual store)
  5. storeController.importPackage(depNode.dir, ...) → writes package content to the traversed path

Why Existing Defenses Don't Catch It

  • depPathToFilename() — replaces / with + for the dirInVirtualStore path, but pkgName comes SEPARATELY from dp.parse() and is NOT passed through this function
  • verifyLockfileResolutions() — validates dependency map keys (aliases) via isValidDependencyAlias(), but never validates the depPath keys themselves
  • Lockfile parseryaml.load(lockfileRawContent) with no schema validation on packages keys
  • importPackage() — accepts targetDir and passes it directly to cafsStore.importPackage(targetDir, ...) with zero containment check
  • Integrity verification — requires a real fetchable package but does not validate the destination path

Escalation to RCE (non-default config)

When dangerouslyAllowAllBuilds: true is configured (or the traversal package name is in the explicit allowBuilds list), the same traversed path is used in the rebuild phase at after-install/src/index.ts:402,470. The attacker's postinstall script then executes with the victim's shell access. Under default config, allowBuild returns false for unknown packages, limiting impact to arbitrary file write.

Also Affected (PnP linker)

When nodeLinker: pnp is configured, lockfileToPackageRegistry() at lockfile/to-pnp/src/index.ts:105-110 uses the same unvalidated dp.parse().name in packageLocation construction, allowing the .pnp.cjs resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker).

Impact

An attacker who can commit a crafted pnpm-lock.yaml to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs pnpm install. Written content is the actual package files from a real npm package (attacker controls which package and which destination).

Targets for arbitrary file write include: - .git/hooks/pre-commit — code execution on next git operation - ~/.local/bin/ — binary hijacking - Project source files — supply chain injection

Reproduction

Craft a pnpm-lock.yaml:

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

Run pnpm install — package content is written to /tmp/pwned/ instead of the virtual store.

Recommended Fix

Apply safeJoinModulesDir (or equivalent validation) at: - lockfileToDepGraph.ts:233path.join(modules, pkgName) - after-install/src/index.ts:402path.join(pkgModulesDir(depPath), pkgInfo.name) - lockfile/to-pnp/src/index.ts:105-110 — PnP packageLocation

Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via dp.parse().

Relationship to GHSA-fr4h-3cph-29xv

GHSA-fr4h-3cph-29xv fixed the hoisted linker path (lockfileToHoistedDepGraph.ts:222) by adding safeJoinModulesDir. The same fix was NOT applied to the virtual store linker, which uses the identical dp.parse().name → path.join() pattern at lockfileToDepGraph.ts:233.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "pnpm"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "10.34.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "pnpm"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "11.0.0"
            },
            {
              "fixed": "11.11.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-82392"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-02T14:37:13Z",
    "nvd_published_at": "2026-08-31T21:17:54Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe virtual store linker constructs package installation directories using `path.join(modules, pkgName)` where `pkgName` is extracted from lockfile `packages` keys via `dp.parse(depPath).name` without validation. A crafted `pnpm-lock.yaml` with traversal sequences in depPath keys (e.g., `../../../tmp/pwned@1.0.0`) causes package content to be written to arbitrary filesystem paths during `pnpm install`.\n\nThis is an incomplete fix of GHSA-fr4h-3cph-29xv \u2014 the `safeJoinModulesDir` containment helper was applied to the hoisted linker and `symlinkDependency` but NOT to the virtual store linker\u0027s `lockfileToDepGraph.ts:233`.\n\n## Details\n\n### Root Cause\n\n`dp.parse()` at `pnpm11/deps/path/src/index.ts:135` extracts the package name as:\n```typescript\nconst name = dependencyPath.substring(0, sepIndex)\n```\n\nThis is a raw substring operation with zero validation that `name` is a valid npm package name. A depPath of `../../../tmp/pwned@1.0.0` yields `name = \u0027../../../tmp/pwned\u0027`.\n\n### Vulnerable Code Path\n\n1. `pnpm-lock.yaml` \u2192 `lockfile.packages[\u0027../../../../../../../tmp/pwned@1.0.0\u0027]` (attacker-controlled lockfile key)\n2. `nameVerFromPkgSnapshot(depPath, pkgSnapshot)` at `lockfile/utils/src/nameVerFromPkgSnapshot.ts:16` \u2192 calls `dp.parse(depPath)` \u2192 returns `{ name: \u0027../../../../../../../tmp/pwned\u0027 }`\n3. `lockfileToDepGraph.ts:232` \u2192 `modules = path.join(dirInVirtualStore, \u0027node_modules\u0027)`\n4. `lockfileToDepGraph.ts:233` \u2192 `dir = path.join(modules, pkgName)` \u2192 resolves to `/tmp/pwned` (ESCAPES virtual store)\n5. `storeController.importPackage(depNode.dir, ...)` \u2192 writes package content to the traversed path\n\n### Why Existing Defenses Don\u0027t Catch It\n\n- **`depPathToFilename()`** \u2014 replaces `/` with `+` for the `dirInVirtualStore` path, but `pkgName` comes SEPARATELY from `dp.parse()` and is NOT passed through this function\n- **`verifyLockfileResolutions()`** \u2014 validates dependency map keys (aliases) via `isValidDependencyAlias()`, but never validates the depPath keys themselves\n- **Lockfile parser** \u2014 `yaml.load(lockfileRawContent)` with no schema validation on `packages` keys\n- **`importPackage()`** \u2014 accepts `targetDir` and passes it directly to `cafsStore.importPackage(targetDir, ...)` with zero containment check\n- **Integrity verification** \u2014 requires a real fetchable package but does not validate the destination path\n\n### Escalation to RCE (non-default config)\n\nWhen `dangerouslyAllowAllBuilds: true` is configured (or the traversal package name is in the explicit `allowBuilds` list), the same traversed path is used in the rebuild phase at `after-install/src/index.ts:402,470`. The attacker\u0027s `postinstall` script then executes with the victim\u0027s shell access. Under default config, `allowBuild` returns false for unknown packages, limiting impact to arbitrary file write.\n\n### Also Affected (PnP linker)\n\nWhen `nodeLinker: pnp` is configured, `lockfileToPackageRegistry()` at `lockfile/to-pnp/src/index.ts:105-110` uses the same unvalidated `dp.parse().name` in `packageLocation` construction, allowing the `.pnp.cjs` resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker).\n\n## Impact\n\nAn attacker who can commit a crafted `pnpm-lock.yaml` to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs `pnpm install`. Written content is the actual package files from a real npm package (attacker controls which package and which destination).\n\nTargets for arbitrary file write include:\n- `.git/hooks/pre-commit` \u2014 code execution on next git operation\n- `~/.local/bin/` \u2014 binary hijacking\n- Project source files \u2014 supply chain injection\n\n## Reproduction\n\nCraft a `pnpm-lock.yaml`:\n```yaml\nlockfileVersion: \u00279.0\u0027\npackages:\n  ../../../../../../../tmp/pwned@1.0.0:\n    resolution: {integrity: sha512-\u003creal-package-integrity\u003e}\n    engines: {node: \u0027\u003e=14\u0027}\nsnapshots:\n  ../../../../../../../tmp/pwned@1.0.0: {}\nimporters:\n  .:\n    dependencies:\n      legitimate-name:\n        specifier: ^1.0.0\n        version: ../../../../../../../tmp/pwned@1.0.0\n```\n\nRun `pnpm install` \u2014 package content is written to `/tmp/pwned/` instead of the virtual store.\n\n## Recommended Fix\n\nApply `safeJoinModulesDir` (or equivalent validation) at:\n- `lockfileToDepGraph.ts:233` \u2014 `path.join(modules, pkgName)`\n- `after-install/src/index.ts:402` \u2014 `path.join(pkgModulesDir(depPath), pkgInfo.name)`\n- `lockfile/to-pnp/src/index.ts:105-110` \u2014 PnP `packageLocation`\n\nAlternatively, validate depPath keys during lockfile parsing to reject any that don\u0027t produce valid npm package names via `dp.parse()`.\n\n## Relationship to GHSA-fr4h-3cph-29xv\n\nGHSA-fr4h-3cph-29xv fixed the hoisted linker path (`lockfileToHoistedDepGraph.ts:222`) by adding `safeJoinModulesDir`. The same fix was NOT applied to the virtual store linker, which uses the identical `dp.parse().name \u2192 path.join()` pattern at `lockfileToDepGraph.ts:233`.",
  "id": "GHSA-c59q-g84q-2gj5",
  "modified": "2026-09-02T14:37:13Z",
  "published": "2026-09-02T14:37:13Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/security/advisories/GHSA-c59q-g84q-2gj5"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-82392"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/pull/12872"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/pull/12890"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/pnpm/pnpm"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/releases/tag/v10.34.5"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pnpm/pnpm/releases/tag/v11.11.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…