Same digest, different files: ZIP parser differentials in uv and pip
- Identifier
- CVE-2025-54368
- Software
- uv / PyPI
- Affected
- uv 0.8.5 and earlier
- Fixed in
- 0.8.6; second round in 0.9.6
- Reported by
- Caleb Brown (Google) and Tim Hatch (Netflix)
- Disclosed
- 07 August 2025
Same package name, same file on the index, same bytes on the wire. Two terminal sessions from Caleb Brown's write-up:
$ pip install cbwheeldiff2
...
$ python3
>>> import cbwheeldiff2
I was installed using Python's zipfile.$ uv pip install cbwheeldiff2
...
$ python3
>>> import cbwheeldiff2
I was installed with UV. It's so fast!!A Python wheel is a ZIP file with a naming convention. Two installers downloaded the same ZIP file and put different __init__.py on disk. Neither of them did anything wrong by its own reading of the format. If you're the sort of person who pins hashes in a lockfile and feels safe, that's the demo aimed at you. The digest of the archive is the same in both runs.
That demo is round two, from January 2026. Round one is CVE-2025-54368 (GHSA-8qf3-x8v5-2pj8), disclosed on 7 August 2025. William Woodruff of Astral, who maintain uv, wrote it up. It was reported independently by two people: Caleb Brown at Google, and Tim Hatch at Netflix.
The ZIP layout#
You need the layout to follow any of this. The layout is strange, because it dates from 1989.
A ZIP file starts with its contents. There is one local file entry per file. Each has a small header giving the name, the compression scheme and the compressed size, followed by the compressed bytes. After all of those comes the thing ZIP calls the central directory. That holds one entry per file, repeating the name and sizes and adding metadata like permissions. Each of those entries carries an offset pointing back at the local entry it describes. Last of all is the end of central directory record, or EOCDR. It holds the offset where the central directory starts.
So the header is a footer. Woodruff's footnote suggests why. ZIP supported splitting an archive across multiple floppy disks, and a trailing directory lets you write the archive in one pass. You can amend it later without rewriting everything.
That layout invites two completely different ways to read a ZIP, and the format blesses both. You can scan backwards, find the EOCDR, and jump to the central directory. That gives you the authoritative list of what is in the archive. Or you can stream forwards from byte zero, reading local entries as they arrive. Then the central directory at the end is redundant confirmation you don't really need.
uv streams. That's a deliberate performance choice, and a good one. Resolving dependencies means fetching metadata for a lot of candidate versions you may end up discarding. Streaming lets uv pull the metadata out of an archive without downloading the whole thing. The advisory's account of the flaw is one sentence: "remote ZIP archives were handled in a streamwise fashion, and file entries were not reconciled against the archive's central directory".
pip reads the same archives the other way round. pip is Python, and Python's zipfile works from the central directory at the end. PyPI's upload validation goes through zipfile too, taking entry sizes from the central directory.
The differentials#
The first differential is dangling local file entries. A local entry with no matching central directory entry is invalid per the spec. As the write-up puts it, "streaming parsers (like the one used in uv) typically ignore this requirement". So you write two entries for pkg/__init__.py, and list only one of them in the central directory. The two parsers hand you different files.
Woodruff notes this is a general problem rather than a uv quirk. Both sections can contain duplicate names, and "the ZIP specification does not require any particular conflict resolution strategy".
The second differential is the spec's fault outright. The EOCDR contains an offset to the start of the central directory. The spec never says whether that offset is measured from the start of the file or from the EOCDR itself. Python's zipfile reads it as relative. uv read it as absolute. Put two central directories in one file and the two readers each find a different one, both convinced they have the archive's table of contents.
The advisory's summary of what this buys an attacker: "the outcome is that an attacker can produce a ZIP with a consistent digest that expands differently with different installers".
That's the whole security story. No code execution, no memory bug. A signature or a hash covers the bytes of the archive, and everyone downstream assumes that covering the bytes means covering the contents. It doesn't, if the contents are a matter of opinion. The malware scanner reading with one parser sees an innocent package. The installer reading with the other writes something else to disk.
The advisory bounds the practical impact in a few ways worth stating. The victim has to install the attacker's package. With a wheel, installing it isn't enough on its own, since something still has to import the code. A ZIP source distribution is worse. Malicious code from one can be executed during resolution or installation. PyPI's backend, Warehouse, was fixed in coordination with uv to reject archives that exhibit these differentials, and "a review of Warehouse revealed no evidence of exploitation".
uv 0.8.6 shipped three changes. Every local file entry is now reconciled against the central directory, and a mismatch in either direction is refused. The central directory and the EOCDR are consumed fully while streaming, so trailing data can't hide a second archive. And a list of things that merely look wrong is now rejected outright: declared sizes that don't match reality, bad CRC32s, an EOCDR comment field containing what appears to be another EOCDR.
Anyone whose builds broke could set UV_INSECURE_NO_ZIP_VALIDATION=1. Few would have needed to. A review of the top 15,000 packages on PyPI turned up three distributions with ZIP encoding errors, all innocent.
The second advisory#
Caleb Brown went back and audited the fixes. GHSA-w97x-xxj5-gpjx was published on 22 January 2026. It affects uv up to and including 0.9.5, and it is fixed in 0.9.6. It has no CVE. Its opening line is unexcited: "It is still possible (albeit with significantly more effort) to upload a specially crafted Wheel file".
The cbwheeldiff2 package at the top of this post is that advisory's proof of concept. It is six smaller disagreements stacked together.
Central directory entries can carry a per-entry comment field. uv parses the filename and the extras but not the comment, so content can be made visible to uv and hidden from Python. Filenames containing a NUL byte get truncated at the NUL by Python's zipfile, while the uv-extract crate skips the central directory entry entirely. If a local entry has a data descriptor, uv reads the file data until it ends and ignores the declared compressed size. PyPI's checker believes the size in the central directory, so the two disagree about where the next entry starts.
PyPI's validation also makes sure no two entries share a filename. The advisory's answer to that: "there are effectively an infinite number of ways to specify a path to the same file". file.txt, ./file.txt and a/../file.txt are three different strings to the validator and one file to the filesystem.
The advisory takes every structure in the file in turn and records what uv sees, what zipfile sees, and what PyPI's validator sees. Some structures are visible to one reader and invisible to another. The result is that uv and pip end up with different pkg/__init__.py and different dist/RECORD.
Woodruff had already sketched the shape of it back in August. The ZIP specification is "many-versioned and legally encumbered", which is why Astral's post links to it rather than quoting it. And the differentials are "not unique to stream parsing". They are "more obvious in a streaming context", nothing more. A seeking parser can disagree with another seeking parser perfectly well.