Wheel of Misfortune: Arbitrary File Permission Modification in Python's Wheel
Jan 23, 2026·5 min read·5 visits
Executive Summary (TL;DR)
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.
The Hook: Boring Tools, Exciting Bugs
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 Flaw: A Tale of Two Paths
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.
The Smoking Gun
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)The Exploit: Public Library Mode
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:
- Crafting: The attacker creates a wheel containing a dummy file. They manually edit the ZIP central directory to rename this file to
../../../../../../etc/shadow(or~/.ssh/authorized_keys). - Permissioning: The attacker sets the
external_attrfield in the ZIP header to represent0o777(rwxrwxrwx). - Delivery: The attacker uploads this package to a repository or tricks a user/admin into inspecting it with
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 Fix & Mitigation
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
piporwheelas root unless absolutely required.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:HAffected Systems
Affected Versions Detail
| 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) |
MITRE ATT&CK Mapping
Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') leading to Incorrect Permission Assignment for Critical Resource
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.