Jan 23, 2026·5 min read·44 visits
The `wheel unpack` command contains a vulnerability where it sanitizes file paths during extraction but uses the raw, unsanitized filename when applying file permissions (chmod). An attacker can craft a malicious wheel file that, when unpacked, changes the permissions of critical system files (like `/etc/shadow` or SSH keys) to be world-writable, leading to total system compromise.
A critical logic flaw in the `wheel` packaging tool allows attackers to modify file permissions on the host system via path traversal, potentially leading to privilege escalation.
We often focus our security research on complex web applications or kernel drivers, ignoring the mundane plumbing that holds our ecosystems together. wheel is the reference implementation for Python's binary package format. It is the boring, reliable forklift of the Python world. It moves code from PyPI to your virtual environment.
But in the unpack command—a utility used to inspect wheel contents—developers left a logic gap wide enough to drive a truck through. It is not a memory corruption bug. It is not a complex race condition. It is a simple disagreement between two lines of code about where a file is actually located.
This vulnerability proves that even standard, mature tooling can harbor 'design implementation' flaws that turn a simple archive extraction into a privilege escalation primitive. If you use wheel to inspect untrusted packages in a CI/CD pipeline or on a workstation, you effectively gave that package the ability to chmod any file you own.
The vulnerability stems from a disconnect between extraction and permission restoration. When wheel unpacks an archive, it iterates through the file list. For every file, it needs to do two things: write the data to disk, and set the correct file mode (permissions) as specified in the archive attributes.
Step one relies on Python's built-in zipfile module. The extract() method is paranoid; if it sees a filename like ../../etc/passwd, it sanitizes it. It strips the directory traversal characters and safely writes the file inside the target directory. The zipfile module says: "Nice try, hacker," and neutralizes the path.
But step two is where the logic falls apart. After extraction, the code manually applies permissions. To do this, it constructed the path to the file again. Instead of asking zipfile "Where did you put that file?", it went back to the raw, untrusted ZIP header. It took the original filename (including the ../../) and joined it to the destination path.
This creates a classic split-brain scenario: The content is written safely to destination/etc/passwd, but the permissions are applied to destination/../../etc/passwd (which resolves to /etc/passwd). The code thought it was locking the front door, but it was actually unlocking the bank vault down the street.
Let's look at the code in src/wheel/_commands/unpack.py. This is a textbook example of why you should never trust input twice. Once you sanitize it, use the sanitized version.
Here is the vulnerable code block:
# VULNERABLE LOGIC
for zinfo in wf.filelist:
# 1. Safe extraction (returns the sanitized path, but return value is IGNORED)
wf.extract(zinfo, destination)
# 2. Get permissions from the untrusted ZIP header
permissions = zinfo.external_attr >> 16 & 0o777
# 3. FATAL ERROR: Re-constructing path from untrusted zinfo.filename
destination.joinpath(zinfo.filename).chmod(permissions)Because pathlib's joinpath resolves traversal characters, zinfo.filename breaks out of destination. The fix, implemented in version 0.46.2, is elegantly simple: capture the return value of extract(), which tells us the actual absolute path where the file landed.
# FIXED LOGIC
for zinfo in wf.filelist:
# 1. Capture the authoritative path
target_path = Path(wf.extract(zinfo, destination))
permissions = zinfo.external_attr >> 16 & 0o777
# 2. Apply permissions to the authoritative path
target_path.chmod(permissions)Exploiting this requires creating a valid wheel file that lies about its contents. We don't need to overflow a buffer; we just need to edit some ZIP headers. The goal is to make sensitive system files world-writable.
The Attack Chain:
../../../../../../etc/shadow (or ~/.ssh/authorized_keys).external_attr field in the ZIP header to represent 0o777 (rwxrwxrwx).wheel unpack malicious.whl.The Execution:
When the victim runs the command, the tool extracts the dummy content safely. But milliseconds later, it executes chmod(0o777, '/etc/shadow').
If the victim is running as root (common in Docker containers or bad CI setups), /etc/shadow becomes world-writable. The attacker—now just a lowly local user—can open the shadow file, delete the root password hash, or add their own user with UID 0. Game over.
The remediation is straightforward: Update wheel immediately.
Run the following command in all your environments:
pip install --upgrade wheelVerify you are on version 0.46.2 or higher.
If you cannot update immediately, stop using wheel unpack. If you need to inspect wheel contents, use standard unzip tools (unzip -l file.whl) or Python's zipfile module directly, as standard tools generally do not attempt to replicate permissions blindly across traversal paths.
> [!NOTE]
> This vulnerability highlights the importance of running package management tools with the least privilege necessary. Never run pip or wheel as root unless absolutely required.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
wheel pypa | <= 0.46.1 | 0.46.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-22 & CWE-732 |
| Attack Vector | Local (User-Assisted) |
| CVSS v3.1 | 7.1 (High) |
| Impact | Integrity & Availability |
| Exploit Status | PoC Available |
| Platform | Python (Cross-Platform) |
Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') leading to Incorrect Permission Assignment for Critical Resource