VAITP Dataset

← Back to the dataset

CVE-2026-47763

PDM before 2.27.0 allows arbitrary file overwrites via malicious symlinks.

  • CVSS 6.8
  • 61
  • Input Validation and Sanitization
  • Local

pdm is a Python package and dependency manager supporting the latest PEP standards. In versions prior to 2.27.0, pdm writes several project-local state or configuration files without symlink protection. If a malicious repository places those files as symlinks, local PDM operations can overwrite the symlink targets. This creates an arbitrary file clobber primitive relative to the privileges of the invoking user. Config.__init__() resolves the project-local pdm.toml path and _save_config() writes to the resolved target. If PROJECT_ROOT/pdm.toml is a symlink to another file, pdm config -l … updates the target file instead of refusing the write. The same general problem exists for other project-local persistence paths that are written directly with no lstat / O_NOFOLLOW protection. For the pdm.toml PoC specifically, the target file must already contain parseable TOML. Otherwise the load step fails before the write path is reached. That parser constraint does not apply to the .pdm-python or .python-version sinks. This issue has been fixed in version 2.27.0.

CWE
61
CVSS base score
6.8
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
Time-of-Check to Time-of-Use
Accessibility scope
Local
Impact
Privilege Escalation
Affected component
pdm
Fixed by upgrading
Yes

Solution

Upgrade pdm to version 2.27.0 or later.

Vulnerable code sample

from __future__ import annotations

from pathlib import Path
import tomlkit

class Config:
    """A class to manage the project's configuration."""

    def __init__(self, project_root: Path) -> None:
        self.path = project_root / "pdm.toml"
        self._data = self._load_config()

    def _load_config(self) -> tomlkit.TOMLDocument:
        try:
            return tomlkit.parse(self.path.read_text("utf-8"))
        except FileNotFoundError:
            return tomlkit.document()

    def _save_config(self) -> None:
        """Saves the config to the config file."""
        # VULNERABLE: The file is written to without checking if the path is a symlink.
        with self.path.open("w", encoding="utf-8") as f:
            f.write(tomlkit.dumps(self._data))

    def set_item(self, key: str, value: str) -> None:
        """A dummy method that would trigger a save."""
        if "project" not in self._data:
            self._data["project"] = tomlkit.table()
        self._data["project"][key] = value
        self._save_config()

Patched code sample

from __future__ import annotations

from pathlib import Path
import tomlkit

class Config:
    """A class to manage the project's configuration."""

    def __init__(self, project_root: Path) -> None:
        self.path = project_root / "pdm.toml"
        self._data = self._load_config()

    def _load_config(self) -> tomlkit.TOMLDocument:
        try:
            return tomlkit.parse(self.path.read_text("utf-8"))
        except FileNotFoundError:
            return tomlkit.document()

    def _save_config(self) -> None:
        """Saves the config to the config file."""
        # FIX: Check if the path is a symlink before writing to prevent file clobber.
        if self.path.is_symlink():
            raise IOError(f"Refusing to write to a symlink: {self.path}")
        with self.path.open("w", encoding="utf-8") as f:
            f.write(tomlkit.dumps(self._data))

    def set_item(self, key: str, value: str) -> None:
        """A dummy method that would trigger a save."""
        if "project" not in self._data:
            self._data["project"] = tomlkit.table()
        self._data["project"][key] = value
        self._save_config()

Payload

# Setup a malicious project directory
mkdir malicious-pdm-project
cd malicious-pdm-project

# Create a symlink from a local PDM file to a sensitive target file.
# The .python-version file is chosen as it has no parsing constraints.
# The target is ~/.bashrc, which will execute commands on the next shell login.
ln -s ~/.bashrc .python-version

# Trigger the vulnerability by running a pdm command that writes to the symlink.
# The "version" string is a shell command that will be written into ~/.bashrc.
pdm use 'echo "Arbitrary command executed by CVE-2026-47763" > ~/pwned; #'

Cite this entry

@misc{vaitp:cve202647763,
  title        = {{PDM before 2.27.0 allows arbitrary file overwrites via malicious symlinks.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-47763},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-47763/}}
}
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 ::