VYPR
High severity8.4GHSA Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

PDM: Project-Controlled `.pdm-plugins` Content Executes Before CLI Parsing

CVE-2026-47781

Description

PDM automatically loads project-local plugin paths from .pdm-plugins, allowing arbitrary code execution via malicious .pth files before CLI handling.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

PDM automatically loads project-local plugin paths from .pdm-plugins, allowing arbitrary code execution via malicious .pth files before CLI handling.

Vulnerability

The vulnerability resides in the Core.__init__() method, specifically in load_plugins() and _add_project_plugins_library() at src/pdm/core.py:74-82 and 310-352 [1][2]. During initialization, PDM adds the project-local .pdm-plugins directory to the Python path using site.addsitedir(). This function processes .pth files, and lines starting with import are executed immediately [1]. Any project containing a crafted .pth file in .pdm-plugins will trigger arbitrary code execution when any PDM command (e.g., pdm --version) is run from that directory [1][2].

Exploitation

An attacker needs only to create a malicious .pth file inside the .pdm-plugins directory of a repository. No special authentication or user interaction beyond running a PDM command is required [1]. Even low-impact commands like pdm --version are sufficient to trigger the code execution [1]. The .pth file's import statement executes arbitrary Python code with the privileges of the user running PDM [2].

Impact

Successful exploitation results in arbitrary code execution as the invoking user [1][2]. This can lead to credential theft, persistence, workspace tampering, and potential privilege escalation if PDM is executed via sudo, in root-owned CI jobs, or by privileged service accounts [1][2].

Mitigation

The issue is fixed in PDM version 2.27.0, released on 2026-06-11 [3]. The fix moves project plugin installations from .pdm-plugins under the project root to an isolated cache directory, preventing untrusted .pth file processing [3]. Users should upgrade to 2.27.0 or later. As a workaround, avoid running PDM in untrusted directories until the fix is applied [1].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

2
16bab5cd8886

fix: update plugin installation path to use project_plugins_dir (#3790)

https://github.com/pdm-project/pdmFrost MingMay 21, 2026Fixed in 2.27.0via ghsa-release-walk
8 files changed · +101 13
  • news/3790.feature.md+1 0 added
    @@ -0,0 +1 @@
    +Move project plugin installations from `.pdm-plugins` under the project root to an isolated cache directory, and add a fixer to migrate existing plugin directories.
    
  • src/pdm/cli/commands/fix/fixers.py+32 0 modified
    @@ -1,5 +1,7 @@
     import abc
     import re
    +import shutil
    +from pathlib import Path
     
     from pdm.project import Config, Project
     from pdm.project.lockfile import FLAG_CROSS_PLATFORM
    @@ -70,6 +72,36 @@ def check(self) -> bool:
             return self.project.root.joinpath(".pdm.toml").exists()
     
     
    +class ProjectPluginFixer(BaseFixer):
    +    """Move project plugins to the cache directory."""
    +
    +    identifier = "project-plugins"
    +
    +    @property
    +    def old_plugin_dir(self) -> Path:
    +        return self.project.root / ".pdm-plugins"
    +
    +    def get_message(self) -> str:
    +        return "Project plugins need to be moved from [info].pdm-plugins[/] to the PDM cache directory."
    +
    +    def check(self) -> bool:
    +        return self.old_plugin_dir.is_dir()
    +
    +    def fix(self) -> None:
    +        plugin_root = self.project.project_plugins_dir
    +        self.log(
    +            f"Moving .pdm-plugins to {plugin_root.as_posix()}...",
    +            verbosity=Verbosity.DETAIL,
    +        )
    +        plugin_root.parent.mkdir(parents=True, exist_ok=True)
    +        if plugin_root.exists():
    +            if plugin_root.is_dir():
    +                shutil.rmtree(plugin_root)
    +            else:
    +                plugin_root.unlink()
    +        shutil.move(self.old_plugin_dir, plugin_root)
    +
    +
     class PackageTypeFixer(BaseFixer):  # pragma: no cover
         identifier = "package-type"
     
    
  • src/pdm/cli/commands/fix/__init__.py+13 2 modified
    @@ -3,7 +3,13 @@
     import argparse
     
     from pdm.cli.commands.base import BaseCommand
    -from pdm.cli.commands.fix.fixers import BaseFixer, LockStrategyFixer, PackageTypeFixer, ProjectConfigFixer
    +from pdm.cli.commands.fix.fixers import (
    +    BaseFixer,
    +    LockStrategyFixer,
    +    PackageTypeFixer,
    +    ProjectConfigFixer,
    +    ProjectPluginFixer,
    +)
     from pdm.exceptions import PdmUsageError
     from pdm.project import Project
     from pdm.termui import Emoji
    @@ -49,7 +55,12 @@ def check_problems(project: Project, strict: bool = True) -> None:
         @staticmethod
         def get_fixers(project: Project) -> list[BaseFixer]:
             """Return a list of fixers to check, the order matters"""
    -        return [ProjectConfigFixer(project), PackageTypeFixer(project), LockStrategyFixer(project)]
    +        return [
    +            ProjectConfigFixer(project),
    +            ProjectPluginFixer(project),
    +            PackageTypeFixer(project),
    +            LockStrategyFixer(project),
    +        ]
     
         def handle(self, project: Project, options: argparse.Namespace) -> None:
             if options.dry_run:
    
  • src/pdm/cli/commands/install.py+2 5 modified
    @@ -51,7 +51,7 @@ def install_plugins(self, project: Project) -> None:
             plugins = [parse_line(r) for r in project.pyproject.plugins]
             if not plugins:
                 return
    -        plugin_root = project.root / ".pdm-plugins"
    +        plugin_root = project.project_plugins_dir
             extra_paths = list({sysconfig.get_path("purelib"), sysconfig.get_path("platlib")})
             environment = PythonEnvironment(
                 project, python=sys.executable, prefix=str(plugin_root), extra_paths=extra_paths
    @@ -61,10 +61,7 @@ def install_plugins(self, project: Project) -> None:
                     install_requirements(
                         plugins, environment, clean=True, use_install_cache=project.config["install.cache"], allow_uv=False
                     )
    -            if not plugin_root.joinpath(".gitignore").exists():
    -                plugin_root.mkdir(exist_ok=True)
    -                plugin_root.joinpath(".gitignore").write_text("*\n")
    -        project.core.ui.echo("Plugins are installed successfully into [primary].pdm-plugins[/].")
    +        project.core.ui.echo(f"Plugins are installed successfully into [primary]{plugin_root}[/].")
     
         def handle(self, project: Project, options: argparse.Namespace) -> None:
             if not project.pyproject.is_valid and termui.is_interactive():
    
  • src/pdm/core.py+4 3 modified
    @@ -308,14 +308,15 @@ def add_config(name: str, config_item: ConfigItem) -> None:
             Config.add_config(name, config_item)
     
         def _add_project_plugins_library(self) -> None:
    -        project = self.create_project(is_global=False)
    -        if project.is_global or not project.root.joinpath(".pdm-plugins").exists():
    +        project = self.create_project(is_global=False, global_config=os.getenv("PDM_CONFIG_FILE"))
    +        plugin_root = project.project_plugins_dir
    +        if project.is_global or not plugin_root.exists():
                 return
     
             import site
             import sysconfig
     
    -        base = str(project.root / ".pdm-plugins")
    +        base = str(plugin_root)
             replace_vars = {"base": base, "platbase": base}
     
             scheme_names = sysconfig.get_scheme_names()
    
  • src/pdm/project/core.py+6 0 modified
    @@ -119,6 +119,12 @@ def __repr__(self) -> str:
         def cache_dir(self) -> Path:
             return Path(self.config.get("cache_dir", "")).expanduser()
     
    +    @cached_property
    +    def project_plugins_dir(self) -> Path:
    +        name = self.root.name or "project"
    +        root_hash = hashlib.sha224(os.path.normcase(str(self.root)).encode("utf-8")).hexdigest()
    +        return self.cache_dir / "plugins" / f"{name}-{root_hash[:12]}"
    +
         @cached_property
         def pyproject(self) -> PyProject:
             return PyProject(self.root / self.PYPROJECT_FILENAME, ui=self.core.ui)
    
  • tests/cli/test_fix.py+25 0 modified
    @@ -46,3 +46,28 @@ def test_fix_project_config(project, pdm):
         assert not old_config.exists()
         assert project.root.joinpath("pdm.toml").read_text() == "[python]\nuse_pyenv = false\n"
         assert project.root.joinpath(".pdm-python").read_text().strip() == Path(sys.executable).as_posix()
    +
    +
    +def test_fix_project_plugins(project, pdm):
    +    old_plugin_dir = project.root / ".pdm-plugins"
    +    old_plugin_dir.mkdir()
    +    old_plugin_dir.joinpath("plugin.py").write_text("value = 1\n")
    +
    +    pdm(["fix", "project-plugins"], obj=project, strict=True)
    +
    +    assert not old_plugin_dir.exists()
    +    assert project.project_plugins_dir.joinpath("plugin.py").read_text() == "value = 1\n"
    +
    +
    +def test_fix_project_plugins_replaces_existing_cache_dir(project, pdm):
    +    old_plugin_dir = project.root / ".pdm-plugins"
    +    old_plugin_dir.mkdir()
    +    old_plugin_dir.joinpath("plugin.py").write_text("value = 1\n")
    +    project.project_plugins_dir.mkdir(parents=True)
    +    project.project_plugins_dir.joinpath("stale.py").write_text("value = 0\n")
    +
    +    pdm(["fix", "project-plugins"], obj=project, strict=True)
    +
    +    assert not old_plugin_dir.exists()
    +    assert not project.project_plugins_dir.joinpath("stale.py").exists()
    +    assert project.project_plugins_dir.joinpath("plugin.py").read_text() == "value = 1\n"
    
  • tests/test_plugin.py+18 3 modified
    @@ -110,9 +110,12 @@ def get_entry_points(group):
     @pytest.mark.usefixtures("local_finder")
     def test_project_plugin_library(pdm, project, core, monkeypatch):
         monkeypatch.setattr(sys, "path", sys.path[:])
    +    monkeypatch.setenv("PDM_CONFIG_FILE", project.global_config.config_file.as_posix())
         project.pyproject.settings["plugins"] = ["pdm-hello"]
         pdm(["install", "--plugins"], obj=project, strict=True)
    -    assert project.root.joinpath(".pdm-plugins").exists()
    +    assert project.project_plugins_dir.exists()
    +    assert not project.root.joinpath(".pdm-plugins").exists()
    +    assert project.project_plugins_dir.parent == project.cache_dir / "plugins"
         assert "pdm-hello" not in project.environment.get_working_set()
         with cd(project.root):
             core.load_plugins()
    @@ -128,11 +131,12 @@ def test_project_plugin_library(pdm, project, core, monkeypatch):
         ],
     )
     @pytest.mark.usefixtures("local_finder")
    -def test_install_local_plugin_without_name(pdm, project, core, req_str):
    +def test_install_local_plugin_without_name(pdm, project, core, monkeypatch, req_str):
         import shutil
     
         from . import FIXTURES
     
    +    monkeypatch.setenv("PDM_CONFIG_FILE", project.global_config.config_file.as_posix())
         test_plugin_path = FIXTURES / "projects" / "test-plugin-pdm"
         project.root.joinpath("plugins").mkdir(exist_ok=True)
         shutil.copytree(test_plugin_path, project.root / "plugins" / "test-plugin-pdm", dirs_exist_ok=True)
    @@ -142,7 +146,18 @@ def test_install_local_plugin_without_name(pdm, project, core, req_str):
         with cd(project.root):
             result = pdm(["install", "--plugins", "-vv"], obj=project, strict=True)
     
    -        assert project.root.joinpath(".pdm-plugins").exists()
    +        assert project.project_plugins_dir.exists()
    +        assert not project.root.joinpath(".pdm-plugins").exists()
             core.load_plugins()
             result = pdm(["hello", "--name", "Frost"], strict=True)
         assert result.stdout.strip() == "Hello, Frost"
    +
    +
    +def test_project_plugin_directory_is_isolated_by_project_root(project, tmp_path):
    +    other_project = project.core.create_project(
    +        tmp_path / project.root.name,
    +        global_config=project.global_config.config_file,
    +    )
    +
    +    assert project.project_plugins_dir != other_project.project_plugins_dir
    +    assert project.project_plugins_dir.parent == other_project.project_plugins_dir.parent
    
2cf992eccaf3

Merge commit from fork

https://github.com/pdm-project/pdmXueMian (ICT.RUN)May 20, 2026Fixed in 2.27.0via ghsa-release-walk
8 files changed · +157 8
  • news/3788.bugfix.md+1 0 added
    @@ -0,0 +1 @@
    +Refuse to write project-local config and state files (`pdm.toml`, `.pdm-python`, `.python-version`) when the destination is a symlink, preventing an untrusted repository from clobbering files outside the project root.
    
  • src/pdm/cli/commands/use.py+2 2 modified
    @@ -11,7 +11,7 @@
     from pdm.models.python import PythonInfo
     from pdm.models.venv import get_venv_python
     from pdm.project import Project
    -from pdm.utils import is_conda_base_python
    +from pdm.utils import is_conda_base_python, open_for_write_no_symlink
     
     
     class Command(BaseCommand):
    @@ -185,7 +185,7 @@ def do_use(
             )
             project.python = selected_python
             if version_file and project.config["python.use_python_version"]:
    -            with project.root.joinpath(".python-version").open("w") as f:
    +            with open_for_write_no_symlink(project.root.joinpath(".python-version")) as f:
                     f.write(f"{selected_python.major}.{selected_python.minor}\n")
             if project.environment.is_local:
                 assert isinstance(project.environment, PythonLocalEnvironment)
    
  • src/pdm/project/config.py+14 3 modified
    @@ -14,6 +14,7 @@
     from pdm._types import RepositoryConfig
     from pdm.compat import tomllib
     from pdm.exceptions import NoConfigError, PdmUsageError
    +from pdm.utils import open_for_write_no_symlink
     
     REPOSITORY = "repository"
     SOURCE = "pypi"
    @@ -302,7 +303,9 @@ def add_config(cls, name: str, item: ConfigItem) -> None:
     
         def __init__(self, config_file: Path, is_global: bool = False):
             self.is_global = is_global
    -        self.config_file = config_file.resolve()
    +        # Keep the path as given (only made absolute) instead of resolving symlinks,
    +        # so a symlinked destination can still be detected and refused on write.
    +        self.config_file = config_file.absolute()
             self.deprecated = {v.replace: k for k, v in self._config_map.items() if v.replace}
             self._file_data = load_config(self.config_file)
             self._data = collections.ChainMap(
    @@ -332,9 +335,9 @@ def iter_sources(self) -> Iterator[RepositoryConfig]:
                     yield RepositoryConfig(**data, name=name[len(SOURCE) + 1 :], config_prefix=SOURCE)
     
         def _save_config(self) -> None:
    +        """Save the changes to the config file."""
             import tomlkit
     
    -        """Save the changed to config file."""
             self.config_file.parent.mkdir(parents=True, exist_ok=True)
             toml_data: dict[str, Any] = {}
             for key, value in self._file_data.items():
    @@ -346,7 +349,15 @@ def _save_config(self) -> None:
                     temp = temp[part]
                 temp[last] = value
     
    -        with self.config_file.open("w", encoding="utf-8") as fp:
    +        # A project-local pdm.toml may be planted as a symlink by an untrusted
    +        # repository to clobber a file outside the project (GHSA-ghq2-5c67-fprm).
    +        # The global config lives in the user's own config directory and is not
    +        # attacker-controlled, so a user-managed symlink there is honored.
    +        if self.is_global:
    +            writer: Any = self.config_file.open("w", encoding="utf-8")
    +        else:
    +            writer = open_for_write_no_symlink(self.config_file)
    +        with writer as fp:
                 tomlkit.dump(toml_data, fp)
     
         def __getitem__(self, key: str) -> Any:
    
  • src/pdm/project/core.py+3 1 modified
    @@ -40,6 +40,7 @@
         is_conda_base_python,
         is_path_relative_to,
         normalize_name,
    +    open_for_write_no_symlink,
     )
     
     if TYPE_CHECKING:
    @@ -214,7 +215,8 @@ def _saved_python(self, value: str | None) -> None:
                 with contextlib.suppress(FileNotFoundError):
                     python_file.unlink()
                 return
    -        python_file.write_text(value, "utf-8")
    +        with open_for_write_no_symlink(python_file) as fp:
    +            fp.write(value)
     
         def resolve_interpreter(self) -> PythonInfo:
             """Get the Python interpreter path."""
    
  • src/pdm/utils.py+30 1 modified
    @@ -6,6 +6,7 @@
     
     import atexit
     import contextlib
    +import errno
     import functools
     import inspect
     import json
    @@ -27,7 +28,7 @@
     from packaging.version import Version
     
     from pdm.compat import importlib_metadata
    -from pdm.exceptions import PDMDeprecationWarning, PdmException
    +from pdm.exceptions import PDMDeprecationWarning, PdmException, PdmUsageError
     
     if TYPE_CHECKING:
         from re import Match
    @@ -170,6 +171,34 @@ def atomic_open_for_write(filename: str | Path, *, mode: str = "w", encoding: st
             os.unlink(name)
     
     
    +@contextlib.contextmanager
    +def open_for_write_no_symlink(filename: str | Path, *, encoding: str = "utf-8") -> Iterator[IO[str]]:
    +    """Open *filename* for writing text, refusing to follow a symlink.
    +
    +    Project-local state and config files (``pdm.toml``, ``.pdm-python``,
    +    ``.python-version``) may be planted as symlinks by an untrusted repository to
    +    redirect the write onto a file outside the project root. This helper rejects
    +    such writes: on POSIX it passes ``O_NOFOLLOW`` so the kernel rejects the open
    +    atomically (no TOCTOU window); on platforms lacking ``O_NOFOLLOW`` it falls
    +    back to a best-effort ``lstat`` pre-check.
    +    """
    +    path = Path(filename)
    +    nofollow = getattr(os, "O_NOFOLLOW", 0)
    +    if not nofollow and path.is_symlink():
    +        raise PdmUsageError(f"Refusing to write to {path} because it is a symlink.")
    +    try:
    +        fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | nofollow, 0o666)
    +    except OSError as exc:
    +        if exc.errno in (errno.ELOOP, errno.EMLINK):
    +            raise PdmUsageError(f"Refusing to write to {path} because it is a symlink.") from exc
    +        raise
    +    fp = open(fd, "w", encoding=encoding)
    +    try:
    +        yield fp
    +    finally:
    +        fp.close()
    +
    +
     @contextlib.contextmanager
     def cd(path: str | Path) -> Iterator:
         _old_cwd = os.getcwd()
    
  • tests/cli/test_config.py+54 0 modified
    @@ -1,6 +1,7 @@
     import pytest
     
     from pdm.exceptions import PdmUsageError
    +from pdm.project.config import Config
     from pdm.utils import cd
     
     
    @@ -231,3 +232,56 @@ def test_keyring_operation_error_disables_itself(project, keyring, mocker):
         assert not keyring.enabled
         assert keyring.get_auth_info("pdm-pypi-extra", "foo") is None
         assert keyring.get_auth_info("pdm-repository-pypi", None) is None
    +
    +
    +def _symlink_or_skip(link, target):
    +    try:
    +        link.symlink_to(target)
    +    except OSError as e:
    +        pytest.skip(f"symlink is not supported: {e}")
    +
    +
    +def test_config_keeps_symlink_path(tmp_path):
    +    target = tmp_path / "target.toml"
    +    target.write_text("python.use_pyenv = false\n")
    +    link = tmp_path / "config.toml"
    +    _symlink_or_skip(link, target)
    +
    +    config = Config(link)
    +
    +    # The symlink path is kept (only made absolute), not resolved to the target.
    +    assert config.config_file == link.absolute()
    +    assert config.config_file.is_symlink()
    +    assert config["python.use_pyenv"] is False
    +
    +
    +def test_config_refuses_to_write_symlinked_file(tmp_path):
    +    target = tmp_path / "target.toml"
    +    target.write_text("python.use_pyenv = true\n")
    +    link = tmp_path / "config.toml"
    +    _symlink_or_skip(link, target)
    +
    +    config = Config(link)
    +
    +    with pytest.raises(PdmUsageError, match="symlink"):
    +        config["python.use_pyenv"] = False
    +
    +    # The symlink target outside the intended file is left untouched.
    +    assert target.read_text() == "python.use_pyenv = true\n"
    +    assert link.is_symlink()
    +
    +
    +def test_global_config_writes_through_symlink(tmp_path):
    +    target = tmp_path / "target.toml"
    +    target.write_text("python.use_pyenv = true\n")
    +    link = tmp_path / "config.toml"
    +    _symlink_or_skip(link, target)
    +
    +    # The global config is the user's own file; a user-managed symlink (e.g. a
    +    # dotfiles-managed config) is honored rather than refused.
    +    config = Config(link, is_global=True)
    +    config["python.use_pyenv"] = False
    +
    +    assert link.is_symlink()
    +    assert config["python.use_pyenv"] is False
    +    assert "use_pyenv = false" in target.read_text()
    
  • tests/test_project.py+19 1 modified
    @@ -11,7 +11,7 @@
     from pytest_httpserver import HTTPServer
     
     from pdm.environments import PythonEnvironment
    -from pdm.exceptions import PdmException, ProjectError
    +from pdm.exceptions import PdmException, PdmUsageError, ProjectError
     from pdm.models.requirements import parse_requirement
     from pdm.models.specifiers import PySpecSet
     from pdm.models.venv import get_venv_python
    @@ -593,3 +593,21 @@ def test_select_lockfile_format(project, pdm, capsys):
             pdm(["export", "-f", "pylock", "-o", "pylock.toml"], strict=True)
         project._lockfile = None
         assert project.lockfile._path.name == "pylock.toml"
    +
    +
    +def test_saved_python_refuses_symlinked_pdm_python(project):
    +    real = project.root / "real-state.txt"
    +    real.write_text("untouched")
    +    link = project.root / ".pdm-python"
    +    link.unlink(missing_ok=True)
    +    try:
    +        link.symlink_to(real)
    +    except OSError as e:
    +        pytest.skip(f"symlink is not supported: {e}")
    +
    +    with pytest.raises(PdmUsageError, match="symlink"):
    +        project._saved_python = "/some/python"
    +
    +    # The symlink target outside .pdm-python is left untouched.
    +    assert real.read_text() == "untouched"
    +    assert link.is_symlink()
    
  • tests/test_utils.py+34 0 modified
    @@ -554,3 +554,37 @@ def now(cls, tz=None):
         monkeypatch.setattr(utils, "datetime", FrozenDateTime)
     
         assert utils.convert_to_datetime(value) == expected_datetime
    +
    +
    +def test_open_for_write_no_symlink_writes_regular_file(tmp_path):
    +    target = tmp_path / "file.txt"
    +    with utils.open_for_write_no_symlink(target) as fp:
    +        fp.write("hello")
    +    assert target.read_text() == "hello"
    +    assert not target.is_symlink()
    +
    +
    +def test_open_for_write_no_symlink_truncates_existing_file(tmp_path):
    +    target = tmp_path / "file.txt"
    +    target.write_text("stale content")
    +    with utils.open_for_write_no_symlink(target) as fp:
    +        fp.write("new")
    +    assert target.read_text() == "new"
    +
    +
    +def test_open_for_write_no_symlink_refuses_symlinked_target(tmp_path):
    +    real = tmp_path / "real.txt"
    +    real.write_text("untouched")
    +    link = tmp_path / "link.txt"
    +    try:
    +        link.symlink_to(real)
    +    except OSError as e:
    +        pytest.skip(f"symlink is not supported: {e}")
    +
    +    with pytest.raises(PdmUsageError, match="symlink"):
    +        with utils.open_for_write_no_symlink(link):
    +            pass
    +
    +    # The symlink and its target are both left intact.
    +    assert link.is_symlink()
    +    assert real.read_text() == "untouched"
    

Vulnerability mechanics

Root cause

"Project-local plugin paths are implicitly trusted and loaded via `site.addsitedir()`, which executes attacker-controlled `.pth` files before CLI handling begins."

Attack vector

An attacker places a malicious `.pth` file inside the `.pdm-plugins` directory of a repository. When a victim runs any `pdm` command (e.g., `pdm --version`) from that checkout, `site.addsitedir()` processes the `.pth` file and executes its `import` lines immediately, before CLI parsing occurs [ref_id=1][ref_id=2]. This yields arbitrary code execution with the victim's privileges, requiring only that the victim runs `pdm` in the attacker-controlled repository.

Affected code

The vulnerability resides in `src/pdm/core.py` at lines 74–82, 310–333, and 335–352. `Core.__init__()` calls `load_plugins()`, which invokes `_add_project_plugins_library()` to add the `.pdm-plugins` directory via `site.addsitedir()`.

What the fix does

The patch [patch_id=5595011] removes the use of `site.addsitedir()` for project-local plugin paths, preventing `.pth` file processing. The advisory recommends either not auto-loading `.pdm-plugins` by default or requiring explicit opt-in (e.g., `--enable-project-plugins`) to close the trust-boundary break [ref_id=1][ref_id=2].

Preconditions

  • inputVictim must run any `pdm` command inside a repository containing a `.pdm-plugins` directory with a malicious `.pth` file.
  • authNo special privileges on the victim host are required; the attacker only needs to control the repository content.
  • networkThe attack is local to the system where `pdm` is executed.

Reproduction

The bundle includes a full PoC: create a temporary project with a `pyproject.toml`, compute the `.pdm-plugins` purelib path via `sysconfig`, place an `evil.pth` file containing `import pathlib; pathlib.Path(...).write_text(...)`, and run `pdm --version`. The marker file is created, confirming code execution [ref_id=1][ref_id=2].

Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.