{"id":"PYSEC-2026-2863","summary":"PDM: Project-Controlled `.pdm-plugins` Content Executes Before CLI Parsing","details":"## Summary\n\nPDM automatically loads project-local plugin paths from `.pdm-plugins` during `Core` initialization. Because this path is added via `site.addsitedir()`, attacker-controlled `.pth` files inside the project plugin directory are processed and can execute Python code before normal CLI handling begins.\n\nThis allows arbitrary code execution with the privileges of the user running `pdm` from an untrusted repository checkout.\n\n## Affected Behavior\n\n- Trigger does not require `pdm install --plugins`\n- A low-impact command such as `pdm --version` is sufficient\n- Impact is strongest in CI, privileged shells, and automation contexts\n\n## Affected Code\n\n- `src/pdm/core.py:74-82`\n- `src/pdm/core.py:310-333`\n- `src/pdm/core.py:335-352`\n\n## Technical Details\n\n`Core.__init__()` calls `load_plugins()` before ordinary command execution. `load_plugins()` calls `_add_project_plugins_library()`, which derives the project-local `.pdm-plugins` library path and adds it through `site.addsitedir()`.\n\nOn CPython, `site.addsitedir()` processes `.pth` files found in the added directory. `.pth` lines beginning with `import ` are executed immediately. This creates a trust-boundary break: project-controlled files execute before the user explicitly opts into plugin installation or plugin loading.\n\n## Impact\n\n- Arbitrary code execution as the invoking user\n- Potential credential theft, persistence, or workspace tampering\n- Potential privilege escalation when `pdm` is run via `sudo`, root-owned CI jobs, or privileged service accounts\n\n## Reproduction\n\nPoC:\n\n```bash\n# Replace this with a Python interpreter that can run `python -m pdm`.\nPDM_PY=/path/to/python-with-pdm\ntmpdir=$(mktemp -d)\n\ncat \u003e \"$tmpdir/pyproject.toml\" \u003c\u003c'EOF'\n[project]\nname = \"plugin-autoload-demo\"\nversion = \"0.0.1\"\nEOF\n\npurelib=$(TMPDIR_ROOT=\"$tmpdir/.pdm-plugins\" \"$PDM_PY\" - \u003c\u003c'PY'\nimport os\nimport sys\nimport sysconfig\n\nbase = os.environ[\"TMPDIR_ROOT\"]\nscheme_names = sysconfig.get_scheme_names()\nif (sys.platform == \"darwin\" and \"osx_framework_library\" in scheme_names) or sys.platform == \"linux\":\n    scheme = \"posix_prefix\"\nelif sys.version_info \u003c (3, 10):\n    scheme = \"nt\" if os.name == \"nt\" else \"posix_prefix\"\nelse:\n    scheme = sysconfig.get_default_scheme()\nreplace_vars = {\"base\": base, \"platbase\": base}\nprint(sysconfig.get_path(\"purelib\", scheme, replace_vars))\nPY\n)\n\nmkdir -p \"$purelib\"\nmarker=\"$tmpdir/plugin-autoload-marker.txt\"\nprintf '%s\\n' \"import pathlib; pathlib.Path(r'$marker').write_text('project plugin autoload executed', encoding='utf-8')\" \u003e \"$purelib/evil.pth\"\n\n(\n  cd \"$tmpdir\" &&\n  \"$PDM_PY\" -m pdm --version\n)\n\ncat \"$marker\"\n```\n\nExpected result:\n\n- A temporary project is created\n- An `evil.pth` file is placed under `.pdm-plugins`\n- Running `pdm --version` creates a marker file before CLI exit\n\nObserved output from local validation:\n\n```text\nPDM, version 2.26.9\n\n--- marker ---\nproject plugin autoload executed\n```\n\n## Severity\n\nHigh\n\n## CVSS v4.0\n\n- Base score: `8.4` (`High`)\n- Vector: `CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N`\n\nRationale:\n\n- `AV:L`: exploitation occurs through local execution of `pdm` against attacker-controlled repository content\n- `AC:L`: no special bypass or race is required\n- `AT:N`: no external precondition beyond the vulnerable workflow is required\n- `PR:N`: the attacker does not need privileges on the victim host\n- `UI:A`: the victim must actively run a `pdm` command in the malicious checkout\n- `VC:H/VI:H/VA:H`: successful exploitation yields arbitrary code execution as the invoking user\n- `SC:N/SI:N/SA:N`: the score is kept to same-system impact only\n\n## Root Cause\n\nProject-local plugin paths are implicitly trusted and loaded too early, and `.pth` processing is inherited from `site.addsitedir()`.\n\n## Recommended Remediation\n\n- Do not auto-load project-local `.pdm-plugins` by default\n- Avoid `site.addsitedir()` for project-controlled plugin paths\n- If project plugins must be supported, require explicit opt-in such as `--enable-project-plugins`\n- Explicitly prevent `.pth` execution when loading project plugin paths\n\n## Disclosure Notes\n\nThis issue is a strong standalone CVE candidate because it yields direct code execution from repository-controlled files without requiring the victim to run a project script explicitly.","aliases":["CVE-2026-47781","GHSA-qq6c-99pv-prvf"],"modified":"2026-07-13T16:32:55.809295075Z","published":"2026-07-13T15:46:16.159273Z","references":[{"type":"WEB","url":"https://github.com/pdm-project/pdm/security/advisories/GHSA-qq6c-99pv-prvf"},{"type":"PACKAGE","url":"https://github.com/pdm-project/pdm"},{"type":"WEB","url":"https://github.com/pdm-project/pdm/releases/tag/2.27.0"},{"type":"PACKAGE","url":"https://pypi.org/project/pdm"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-qq6c-99pv-prvf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47781"}],"affected":[{"package":{"name":"pdm","ecosystem":"PyPI","purl":"pkg:pypi/pdm"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.27.0"}]}],"versions":["0.0.0","0.0.1","0.0.3","0.0.4","0.0.5","0.0.6","0.1.0","0.1.1","0.1.2","0.10.0","0.10.1","0.10.2","0.11.0","0.12.0","0.12.1","0.12.2","0.12.3","0.2.0","0.2.1","0.2.2","0.2.3","0.2.4","0.2.5","0.2.6","0.3.0","0.3.1","0.3.2","0.4.0","0.4.1","0.4.2","0.5.0","0.6.0","0.6.1","0.6.2","0.6.3","0.6.4","0.6.5","0.7.0","0.7.1","0.8.0","0.8.1","0.8.2","0.8.3","0.8.4","0.8.5","0.8.6","0.8.7","0.9.0","0.9.1","0.9.2","1.0.0","1.0.0b0","1.0.0b2","1.1.0","1.10.0","1.10.1","1.10.2","1.10.3","1.11.0","1.11.1","1.11.2","1.11.3","1.12.0","1.12.1","1.12.2","1.12.3","1.12.4","1.12.5","1.12.6","1.12.7","1.12.8","1.13.0","1.13.0.post0","1.13.1","1.13.2","1.13.3","1.13.4","1.13.5","1.13.6","1.14.0","1.14.1","1.15.0","1.15.1","1.15.2","1.15.3","1.15.4","1.15.5","1.2.0","1.2.0.post1","1.3.0","1.3.1","1.3.2","1.3.3","1.3.4","1.4.0","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.5.0","1.5.0b0","1.5.0b1","1.5.1","1.5.2","1.5.3","1.6.0","1.6.1","1.6.2","1.6.3","1.6.4","1.7.0","1.7.1","1.7.2","1.8.0","1.8.1","1.8.2","1.8.3","1.8.4","1.8.5","1.9.0","2.0.0","2.0.0a1","2.0.0b1","2.0.0b2","2.0.1","2.0.2","2.0.3","2.1.0","2.1.1","2.1.2","2.1.3","2.1.4","2.1.5","2.10.0","2.10.1","2.10.2","2.10.3","2.10.4","2.11.0","2.11.1","2.11.2","2.12.0","2.12.1","2.12.2","2.12.3","2.12.4","2.13.0","2.13.1","2.13.2","2.13.3","2.14.0","2.15.0","2.15.1","2.15.2","2.15.3","2.15.4","2.16.0","2.16.1","2.17.0","2.17.1","2.17.2","2.17.3","2.18.0","2.18.1","2.18.2","2.19.0","2.19.0a0","2.19.1","2.19.2","2.19.3","2.2.0","2.2.1","2.20.0","2.20.0.post1","2.20.1","2.21.0","2.22.0","2.22.1","2.22.2","2.22.3","2.22.4","2.23.0","2.23.1","2.24.0","2.24.1","2.24.2","2.25.0","2.25.1","2.25.2","2.25.3","2.25.4","2.25.5","2.25.6","2.25.7","2.25.8","2.25.9","2.26.0","2.26.1","2.26.2","2.26.3","2.26.4","2.26.5","2.26.6","2.26.7","2.26.8","2.26.9","2.3.0","2.3.1","2.3.2","2.3.3","2.3.4","2.4.0","2.4.1","2.4.2","2.4.3","2.4.4","2.4.5","2.4.6","2.4.7","2.4.8","2.4.9","2.5.0","2.5.0b0","2.5.1","2.5.2","2.5.3","2.5.4","2.5.5","2.5.6","2.6.0","2.6.1","2.7.0","2.7.1","2.7.2","2.7.3","2.7.4","2.8.0","2.8.0a0","2.8.0a1","2.8.0a2","2.8.1","2.8.2","2.9.0","2.9.1","2.9.2","2.9.3"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/pdm/PYSEC-2026-2863.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"}]}