---
title: "PEP 829 Makes Python Startup Safer, But Risk Remains"
date: "2026-10-15T10:01:00+00:00"
url: "https://checkmarx.com/zero-post/pep-829-makes-python-startup-safer-but-risk-remains/"
description: "Python 3.15’s PEP 829 introduces .start files to replace unsafe startup behavior in .pth files, making automatic code execution more explicit and auditable. But does this change actually prevent malicious packages from executing code? We examine the new mechanism, demonstrate how attackers can manipulate Python’s startup process to bypass its protections, and explore why this improvement still leaves significant software supply chain risks."
---

# PEP 829 Makes Python Startup Safer, But Risk Remains

---

Python 3.15 introduces a new structure and file format to replace the old .pth files, complying with PEP-829. These files tell Python where to discover additional installed libraries (by provided paths to search outside the normal list) but they can also be abused to run arbitrary code. How does this replacement format work? And how does it really improve over the old .pth approach?

## Introduction

Starting at version 3.15, [Python’s PEP 829](https://peps.python.org/pep-0829 "https://peps.python.org/pep-0829"), authored by Barry Warsaw, introduces a new feature to deprecate the use of `.pth` files. These files were known to be dangerous because they allowed arbitrary code execution if a line in the file started with import.

This new specification is meant to replace `.pth` files with `.start` files and change the way packages perform code execution and initialization at startup.

PEP 829 is sometimes compared to [npm v12’s new lifecycle script limits](https://checkmarx.com/zero-post/npm-v12-lifecycle-script-limits-a-real-malicious-package-risk-reduction-or-just-moving-risk-around/) that restrict an intentional feature that had legitimate use cases but got widely abused. PEP 829’s purpose, though, is different: it’s meant to replace something that was never really supposed to execute code in the first place. However, the takeaway is the same: this is a much-needed improvement, but it doesn’t really do much to prevent malware. Code execution remains possible; adversaries just need a different way to achieve it.

## What are .pth files

Path Configuration Files (`.pth`) can be used to tell Python about additional directories where modules and packages are located.

When a `.pth` file is placed in a directory like site-packages or dist-packages, Python processes these files during interpreter startup. Each line in the file can either add a directory to the module search path, or execute Python code, if a line starts with `import` followed by a space or tab. This works because the line content is fed directly into `exec()` (see [Lib/site.py#L213](https://github.com/python/cpython/blob/3.14/Lib/site.py#L213)).

The problem with this is that `.pth` files can be used to achieve persistence on a compromised system and perform supply chain attacks when a user installs a malicious package.

## How adversaries can use .pth files

Instead of relying on the old `setup.py` file, that automatically executes upon package installation, adversaries can focus on `.pth` files to automatically execute their malicious code during Python startup.

Take as an example, `demo.pth` placed inside your `/site-packages` or `/dist-packages` directory:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
import os; os.system("whoami")
```

You can find the path of your site-packages or dist-packages directories by running python -m site

Running any pythoninvocation shows that the code gets automatically executed:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```plaintext
% python3 -c "print('this is cool!')"
brunodias
this is cool!
```

Note that it doesn’t matter where you run python from.

You can run Python from essentially anywhere, and the .pth files will always execute as long as they are located inside the /site-packages or /dist-packages directories. PATH only matters for finding the Python binary itself, which decides which interpreter runs (so in your case you may need to do something like /usr/bin/python3 instead). The site-packages directories, however, are determined by the interpreter itself based on where it’s installed, not by your current working directory.

The above is a benign and loud example, simply printing the current username before Python continues execution normally. But an attacker can rely on this to execute any malicious code on your machine, effectively creating a persistence mechanism that silently executes every time you start Python.

## What the new PEP proposes

The new PEP introduces `.start` files to deprecate `import` lines in `.pth` files.

If a package ships both a `.pth` file and a matching `.start` file with the same name (e.g., `demo.pth` and `demo.start`), Python still adds the paths listed in the `.pth` file, but skips the `import` lines and runs only the entry points listed in the `.start` file, which should make `.pth` files safer for developers and end users.

This `.start` file doesn’t accept arbitrary code, and `exec()` is no longer directly available. You can only name an existing callable function, from any module on Python’s import path (`sys.path`). The point of this is to guarantee that what is invoked is always an explicit, named function, rather than arbitrary code. However, any top-level code present in the imported module still runs.

## Structure of .start files

A `.start` file is just a text file containing a list of entry points. In other words, `package.module:callable`specifications naming functions that get resolved and automatically executed when the Python interpreter starts.

Take `demo.start` as an example:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```plaintext
my_demo_project:entry
```

This loads the entry point from `my_demo_project.py`:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
def entry():
    print("This function gets executed by demo.start")

print("This top-level code is also automatically executed")
```

When Python starts, it effectively runs:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
from my_demo_project import entry

entry()
```

## What this new PEP is not

While this new format avoids many mistakes and closes a particularly easy malicious code pathway, it doesn’t eliminate the ability to execute arbitrary code at startup. A malicious package can still cause arbitrary code execution via entry points, but this new mechanism is more structured and easier to audit, both by humans and automated scanners (as stated in the [PEP’s Rationale](https://peps.python.org/pep-0829/#rationale)).

The `.start` file format is a poor choice for an attack vector, as it names a specific module and function that can more easily be identified and scanned by human reviewers or automated tools. But being “more auditable” doesn’t mean it’s safe. **Before Python can call the function(s) named in the** `.start`**file, it has to import the module(s) that contain them, and importing a module automatically executes all of its top-level code.** This means an attacker doesn’t necessarily need the entry point function to do anything malicious. They can hide the malicious payload at the top level of the module and point the `.start` file at a harmless-looking function, and the malicious code will run on every Python startup.

The core risk that `.pth`files introduced is still there—it just moved. Once installed, a malicious package can still ship a `.start` file that points to a module that exfiltrates credentials, installs a backdoor, or does something else malicious as soon as Python starts. So, the entry point still runs automatically, with no user interaction. The path to entry has changed, but the end result is the same. And of course, these files are far from the only available vector for an attacker to use.

It’s good that we now have a clearer place to look—that’s a great improvement for auditing packages—but if a package is never reviewed in the first place, this doesn’t help. This new behavior is a much-needed improvement, but supply-chain attacks can still keep relying on these automatic startup features to spread and infect users in the ecosystem.

## Evading PEP 829

I tested this behavior against **Python 3.15.0rc2**. The goal was to check whether the new `.start` mechanism works as expected, and whether this new execution model introduces any risks.

### .start file(s) entry points are shared, and can be manipulated

The `.start` entry points collected during startup live in a single dict on `self`, shared across the entire startup sequence. If we check the underlying Python code, in `main()`([Lib/site.py#L1062](https://github.com/python/cpython/blob/v3.15.0rc2/Lib/site.py#L1062)), one `StartupState` is created and reused across user site-packages, the active venv, and every global site-packages directory, with `process()` only called once at the end. So every `.pth` and `.start` file discovered anywhere in that sequence share the same dict before anything executes.

And since `.pth` files are still valid for now, they’re still a useful vector for adversaries, even when combined with `.start`files.

This dict is easy to reach. Before `_exec_imports()` calls `exec(line)`, `self` is present in the local scope of that method, so it’s visible via `sys._getframe(1).f_locals` inside the exec’d line. Because of this, we can access `self`, and everything on it, including the `_entrypoints`dict which tracks the `.start` files discovered during startup.

So, if we, for example, clear `_entrypoints`, the check at [Lib/site.py#L525](https://github.com/python/cpython/blob/v3.15.0rc2/Lib/site.py#L525) no longer matches anything for any `.pth`/`.start` pair processed afterward, and the `.pth` files’ import content is no longer suppressed and gets passed to `exec()` at [Lib/site.py#L539](https://github.com/python/cpython/blob/v3.15.0rc2/Lib/site.py#L539).

This can have several consequences:

- We can wipe out `.start`entry points before they run, silently disabling this new mechanism, and making malicious `.pth` files execute even if they have a matching `.start` file.
- We can add our own entry points, silently executing code at a given location.

#### Working example

This requires at least two working sets of startup files. Naming and sort order matters for execution, so make sure they will be processed in the correct order.

The first is a `.pth`file, whose sole purpose is to reach the shared state and clear every declared `.start` entry point:

- demo.pth

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
import sys; sys._getframe(1).f_locals['self']._entrypoints.clear(); print("ENTRYPOINTS DISRUPTED!!")
```

The second is a normal, fully working `.pth` / `.start` pair, which under this new PEP should have its import line suppressed.

- demo2.pth

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
import os; print("Executed .pth (NOT expected!)");
```

- demo2.start

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```plaintext
demo2:entry
```

- demo2.py

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```python
def entry():
    print("Executed .start (as expected)")
```

Running this shows `demo2.start` entry point never firing, and `demo2.pth`executing anyway:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```plaintext
% python3 -c "print('something')"
ENTRYPOINTS DISRUPTED!!
Executed .pth (NOT expected!)
something
```

Conversely, if we didn’t have `demo.pth` doing its disruption, the `demo2.start` file would execute as expected:

Language:  Auto-detect Plain Text Bash CSS Dockerfile Go HTML Java JavaScript JSON Kotlin Markdown PHP Python Ruby Rust SQL Swift TypeScript XML YAML

```plaintext
% python3 -c "print('something')"
EXECUTED .start (as expected)
something
```

## Possible mitigations for devs / security teams

The only solution to completely avoid code execution is to use python -S ([docs](https://docs.python.org/3/using/cmdline.html#cmdoption-S)). This prevents Python from importing the site module during interpreter startup, which stops both `.pth` and `.start` files from executing. However, this also disables normal site-packages discovery, so anything depending on installed packages would need `sys.path` to be reconstructed manually. Thus, this is more suited for sandboxing or hardened invocations.

![](https://checkmarx.com/wp-content/uploads/2026/10/cxzero_2026-10-cxzero-pep-829-changes_image01.webp)

Figure 1 – Example of how `python -S` effectively stops code execution but also disables automatic imports

Additionally, you should always audit what is already installed. The good thing about these startup files is that they are easy to spot. So, grep your site-packages / dist-packages directories for `.pth` files containing `import` lines, and for `.start` files containing entry points that resolve to modules you’re not aware of. But keep in mind that it’s not enough to check the named function, because any top-level code in its module automatically runs on import too.

Another potential solution: use `sys.addaudithook()`—Python’s audit hook mechanism ([PEP 578](https://peps.python.org/pep-0578/))—to monitor `exec`-related and `import`-related events. Devs or security teams can set up their own `.pth` file to serve as an early hook that blocks unexpected `exec()` or `import` calls happening during Python startup (this, however, doesn’t work everytime because execution ordering isn’t guaranteed. This only works if your audit script executes first, so it catches the malicious pth files that are executed afterwards.

## Conclusion

PEP 829 is a good improvement over the old `.pth` files. Replacing the ability to hide `import` lines that silently execute arbitrary code with explicitly named entry points fixes a functionality that was never the intended purpose of `.pth` files. However, it’s worth being precise about what kind of improvement this brings: `.start` files don’t remove code execution from Python’s startup, they just make it more visible, and since importing an entry point’s module still runs its top-level code, auditing what gets loaded at startup remains as important as ever.

[linkedin-app Share on LinkedIn](https://linkedin.com/sharing/share-offsite/?url={url}) [Share on Bluesky](https://bsky.app/intent/compose?text=I%20just%20read%20%22{title}%22%20from%20Checkmarx%20Zero%20{url})

Follow [Checkmarx Zero](https://checkmarx.com/zero/): [linkedin-app](https://linkedin.com/showcase/checkmarx-zero) [](https://bsky.app/profile/checkmarxzero.bsky.social) [](https://x.com/CheckmarxZero)
