Skip to content

Arbitrary file write outside the extraction directory via a multi-hop dangling symlink chain (bypass of the CVE-2026-24884 / CVE-2026-40931 fix)

Moderate
fengmk2 published GHSA-ghrx-wjp4-xcwj Aug 7, 2026

Package

npm compressing (npm)

Affected versions

>=2.0.0,<= 2.1.1
<= 1.10.5

Patched versions

2.1.2
1.10.6

Description

Summary

compressing's extraction (compressing.{tar,tgz,zip}.uncompress, all via the shared makeUncompressFn in lib/utils.js) validates each entry's destination with isRealPathSafe() — a recursive lstat walk that follows pre-existing symlinks on disk and rejects any that escape the extraction root (the CVE-2026-40931 fix). The walk's dangling-symlink branch validates only the first hop's textual target and returns, so it never follows a multi-hop chain whose first hop stays inside the root but a later hop escapes.

With a pre-existing chain dest/a → dest/b (inside root) and dest/b → /outside (target missing), isRealPathSafe('dest/a') returns true (it only checks dest/a's immediate target dest/b), the check passes, and the library then writes a top-level file entry named a via createWriteStream('dest/a'), which follows the full chain and O_CREATs a file at /outside (whose parent directory exists) — outside the extraction directory.

Root cause

lib/utils.js, isRealPathSafe() (lines 33-72), the dangling branch (51-55):

const stat = await fs.promises.lstat(current);
if (stat.isSymbolicLink()) {
  let resolved;
  try {
    resolved = await fs.promises.realpath(current);   // follows the WHOLE chain
  } catch (e) {
    if (e.code === 'ENOENT') {
      // Dangling symlink - check textual target
      const linkTarget = await fs.promises.readlink(current);          // only the FIRST hop
      const absTarget = path.resolve(path.dirname(current), linkTarget);
      return isWithinParent(absTarget);                                 // ← returns; never follows further
    }
    return false;
  }
  if (!isWithinParent(resolved)) return false;
  current = resolved;
}

When the chain is fully resolvable, realpath follows it and the result is checked (so a resolvable escape is caught — the CONTROL case below also shows a simple dangling 1-hop escape is caught because its textual target is outside). But when the chain is dangling (final target missing), the branch reads only the immediate readlink target and returns based on that single hop. A chain whose first hop is inside the root therefore passes, even though a subsequent hop points outside. The write path (fs.createWriteStreamopen(O_CREAT)) then follows the full chain and creates the file at the outside target.

Proof of concept

repro/ drives the real compressing@2.1.1. node repro/poc.cjs (exit 0):

CONTROL  1-hop  dest/x -> /outside     (entry "x")
  lib caught it (warning): YES | escaped: no   [fix works for the simple case]
ATTACK   2-hop  dest/a -> dest/b -> /outside   (entry "a")
  lib caught it (warning): no | escaped: YES
  -> file CREATED OUTSIDE dest: .../OUTSIDE_DEST   content: OWNED-by-attack_2hop

The CONTROL confirms the 2.1.1 fix catches simple single-hop directory poisoning; the ATTACK shows the 2-hop dangling chain passes the check (no "Skipping" warning) and a file is created outside the extraction directory. tar, tgz, and zip all route through utils.makeUncompressFn (lib/{tar,tgz,zip}/index.js:13), so all three are affected.

Impact

An attacker who can place pre-existing symlinks in the directory an application extracts an archive into — the supply-chain-via-git clone vector established by CVE-2026-40931 (git preserves committed symlinks) — can cause compressing.{tar,tgz,zip}.uncompress to create attacker-content files outside the extraction root. Creating files at attacker-chosen locations (e.g. shell rc files, cron entries, config/autoload files in watched directories) outside the intended sandbox is an arbitrary-file-write primitive that commonly leads to code execution.

Scope / honest limitations

  • Creation, not overwrite. The bypass requires the final target to not exist at validation time (so realpath throws ENOENT → the dangling branch runs). An existing target is resolved by realpath and correctly rejected. So this writes new files outside the root; it cannot overwrite existing ones via this path.
  • Top-level file entry. The malicious entry must be a file at the chain head (e.g. a); a nested entry (a/evil) fails earlier on mkdir through the dangling chain.
  • Requires the pre-existing 2-hop chain (directory poisoning) — consistent with the accepted CVE-2026-40931 threat model.

Suggested fix

In the dangling-symlink branch, do not return on the immediate target: resolve and validate the entire symlink chain (e.g. iteratively readlink + re-validate each hop until a non-symlink or a confirmed-outside target), or simply fail closed when any path component is a symlink that does not fully resolve inside the root. Additionally, perform the write with O_NOFOLLOW (open the final component without following a symlink) / lstat-then-write so the physical write cannot follow a chain the validation didn't, eliminating the validate-vs-write divergence entirely.

Disclosure

GitHub private vulnerability reporting on node-modules/compressing (the project files its advisories there; no email is published in SECURITY.md).

References

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Improper Link Resolution Before File Access ('Link Following')

The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource. Learn more on MITRE.

Credits