Repository: pypa/pip
Issue category: Security Vulnerability

Lazy import allows wheel to execute code on install.

### Description

Versions of pip since 24.1b1 allow someone to run arbitrary code after a specially crafted bdist whl file is installed.

When installing wheel files pip does not constrain the directories the wheel contents are written into, except for checks that ensure traversal is only within the destination directories (e.g, purelib, platlib, data, etc) (see #4625)

This means a wheel is able to place files into existing modules that belong to other packages, such as pip, setuptools, etc.

If the installer lazily imports a module *after* the wheel is installed it is possible for the wheel to overwrite the module with its own code, which is then imported unintentionally by the installer.

For pip, this has been true since 24.1b1 when a change was introduced that dynamically loads the `pip._internal.self_outdated_check` module after running a command to check if pip needs upgrading.

Because this module is loaded *after* a package has been installed, a wheel can overwrite `{purelib}/pip/_internal/self_outdated_check.py` and have the code within it automatically executed when `pip install {wheel}` is run.

> 

### Expected behavior

This behavior is surprising. My understanding is that most Python users expect wheels can't run code during installation.

For example, the recent blog post on [command jacking](https://checkmarx.com/blog/this-new-supply-chain-attack-technique-can-trojanize-all-your-cli-commands/) demonstrates this expectation:

> Python wheels (.whl files) have become increasingly prevalent due to their performance benefits in package installation. However, they present a unique challenge for attackers
>
> While both .tar.gz and .whl files may contain a setup.py file, .whl files don’t execute setup.py during installation. This characteristic has traditionally made it more difficult for attackers to achieve arbitrary code execution during the installation process when using .whl files.

That said, the [wheel spec](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#binary-distribution-format) says nothing about security, or avoiding on-install code execution.




### pip version

24.1b1

### Python version

v3.11 later

### OS

any

### How to Reproduce

1. Download [wheelofdespair-0.0.1-py3-none-any.zip](https://github.com/user-attachments/files/17757896/wheelofdespair-0.0.1-py3-none-any.zip)
2. `mv wheelofdespair-0.0.1-py3-none-any.zip wheelofdespair-0.0.1-py3-none-any.whl`
3. `python3 -m venv env`
4. `. env/bin/activate`
5. `pip install --upgrade pip`
6. `pip install wheelofdespair-0.0.1-py3-none-any.whl`

### Output

```
Collecting wheelofdespair
  Downloading wheelofdespair-0.0.1-py3-none-any.whl.metadata (201 bytes)
Downloading wheelofdespair-0.0.1-py3-none-any.whl (1.5 kB)
Installing collected packages: wheelofdespair
Successfully installed wheelofdespair-0.0.1
PoC: Wheel-of-Despair code execution.
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).
Lazy import allows wheel to execute code on install.

### Description

Versions of pip since 24.1b1 allow someone to run arbitrary code after a specially crafted bdist whl file is installed.

When installing wheel files pip does not constrain the directories the wheel contents are written into, except for checks that ensure traversal is only within the destination directories (e.g, purelib, platlib, data, etc) (see #4625)

This means a wheel is able to place files into existing modules that belong to other packages, such as pip, setuptools, etc.

If the installer lazily imports a module *after* the wheel is installed it is possible for the wheel to overwrite the module with its own code, which is then imported unintentionally by the installer.

For pip, this has been true since 24.1b1 when a change was introduced that dynamically loads the `pip._internal.self_outdated_check` module after running a command to check if pip needs upgrading.

Because this module is loaded *after* a package has been installed, a wheel can overwrite `{purelib}/pip/_internal/self_outdated_check.py` and have the code within it automatically executed when `pip install {wheel}` is run.

> 

### Expected behavior

This behavior is surprising. My understanding is that most Python users expect wheels can't run code during installation.

For example, the recent blog post on [command jacking](https://checkmarx.com/blog/this-new-supply-chain-attack-technique-can-trojanize-all-your-cli-commands/) demonstrates this expectation:

> Python wheels (.whl files) have become increasingly prevalent due to their performance benefits in package installation. However, they present a unique challenge for attackers
>
> While both .tar.gz and .whl files may contain a setup.py file, .whl files don’t execute setup.py during installation. This characteristic has traditionally made it more difficult for attackers to achieve arbitrary code execution during the installation process when using .whl files.

That said, the [wheel spec](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#binary-distribution-format) says nothing about security, or avoiding on-install code execution.




### pip version

24.1b1

### Python version

v3.11 later

### OS

any

### How to Reproduce

1. Download [wheelofdespair-0.0.1-py3-none-any.zip](https://github.com/user-attachments/files/17757896/wheelofdespair-0.0.1-py3-none-any.zip)
2. `mv wheelofdespair-0.0.1-py3-none-any.zip wheelofdespair-0.0.1-py3-none-any.whl`
3. `python3 -m venv env`
4. `. env/bin/activate`
5. `pip install --upgrade pip`
6. `pip install wheelofdespair-0.0.1-py3-none-any.whl`

### Output

```
Collecting wheelofdespair
  Downloading wheelofdespair-0.0.1-py3-none-any.whl.metadata (201 bytes)
Downloading wheelofdespair-0.0.1-py3-none-any.whl (1.5 kB)
Installing collected packages: wheelofdespair
Successfully installed wheelofdespair-0.0.1
PoC: Wheel-of-Despair code execution.
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Functions to edit:
src/pip/_internal/commands/install.py:InstallCommand.run

Patch:
diff --git a/news/13079.bugfix.rst b/news/13079.bugfix.rst
new file mode 100644
index 00000000000..5b297f5a12e
--- /dev/null
+++ b/news/13079.bugfix.rst
@@ -0,0 +1,1 @@
+This change fixes a security bug allowing a wheel to execute code during installation.
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index ad45a2f2a57..70acf202be9 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -10,6 +10,13 @@
 from pip._vendor.packaging.utils import canonicalize_name
 from pip._vendor.rich import print_json
 
+# Eagerly import self_outdated_check to avoid crashes. Otherwise,
+# this module would be imported *after* pip was replaced, resulting
+# in crashes if the new self_outdated_check module was incompatible
+# with the rest of pip that's already imported, or allowing a
+# wheel to execute arbitrary code on install by replacing
+# self_outdated_check.
+import pip._internal.self_outdated_check  # noqa: F401
 from pip._internal.cache import WheelCache
 from pip._internal.cli import cmdoptions
 from pip._internal.cli.cmdoptions import make_target_python
@@ -408,12 +415,6 @@ def run(self, options: Values, args: List[str]) -> int:
                 # If we're not replacing an already installed pip,
                 # we're not modifying it.
                 modifying_pip = pip_req.satisfied_by is None
-                if modifying_pip:
-                    # Eagerly import this module to avoid crashes. Otherwise, this
-                    # module would be imported *after* pip was replaced, resulting in
-                    # crashes if the new self_outdated_check module was incompatible
-                    # with the rest of pip that's already imported.
-                    import pip._internal.self_outdated_check  # noqa: F401
             protect_pip_from_modification_on_windows(modifying_pip=modifying_pip)
 
             reqs_to_build = [