Announcement Checkmarx Joins the Claude Marketplace Learn More
Latest Innovations
Checkmarx for Developers
Partners
Blog
Research

PEP 829 Makes Python Startup Safer, But Risk Remains

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.

A dark, grunge-style illustration showing a Python logo on a blood-splattered box, from which files like 'malicious.pth' and red, skull-filled smoke emerge, flowing towards a glowing green Python portal in a dystopian city.

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, 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 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).

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:

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 python invocation shows that the code gets automatically executed:

% 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:

my_demo_project:entry

This loads the entry point from my_demo_project.py:

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:

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).

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), 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 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.

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
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
import os; print("Executed .pth (NOT expected!)");
  • demo2.start
demo2:entry
  • demo2.py
def entry(): 
    print("Executed .start (as expected)")

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

% 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:

% 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). 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.

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)—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.

Tags:

Open-Source Security

Python

Supply Chain Security