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
.startentry points before they run, silently disabling this new mechanism, and making malicious.pthfiles execute even if they have a matching.startfile. - 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.

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.
Open-Source Security
Python
Supply Chain Security