skip to content

Why is it risky to run a Python script from a world-writable directory?

level: juniorimportance: must knowfreq 60%

answer

  1. Ask who else can write there
  2. Import order decides which file wins
  3. The script's own directory is entry zero
  4. Importing a module runs its top-level code
  5. -P and PYTHONSAFEPATH drop that entry

basics

~20 s

Python puts the script's own directory first on sys.path, ahead of the standard library. Anyone who can write there can drop a file named like a module the script imports, and their code runs at import time.

solid answer

~40 s

When you launch `python /some/dir/job.py`, CPython prepends `/some/dir` to `sys.path` as entry 0, so it is searched **before** site-packages and before the standard library. That is convenient for a script's own helper modules, but it also means the directory is a code-execution surface: if another user can create files there, they can add `json.py`, `logging.py` or any name the script imports, and the module body executes during the import statement — before `main()` ever runs, with the script's privileges. So a shared `/tmp` workdir, a downloads folder or a world-writable deploy directory is not a safe place to run from. Since Python 3.11 you can drop that entry with the `-P` flag or `PYTHONSAFEPATH=1` (visible as `sys.flags.safe_path`); the stronger fix is to run from a directory only the owning account can write to.

code

python · 4 lines
python
import sys

print("sys.path[0] =", repr(sys.path[0]))
print("safe_path   =", sys.flags.safe_path)

go deeper

for a junior

Remember the order: the directory holding the script is searched first, before the standard library. Be able to say why a stranger's file in that directory is dangerous — importing it runs its code.

for a middle

Explain the mechanics: how sys.path[0] is chosen at launch, that resolution stops at the first match, that only built-in and frozen modules bypass the path, and that -P or PYTHONSAFEPATH=1 removes the entry (sys.flags.safe_path tells you it worked).

for a senior

Show the operational judgement: audit which directories your jobs actually run from, treat a world-writable working directory as an execution surface, and prefer installed code plus -P or -I for anything a service account runs. Know how to confirm a module's real origin during an incident.

for a principal

Own the policy question: whether every automated Python entry point in the fleet runs isolated by default, what that costs teams whose scripts rely on sibling imports, and how directory permissions, deploy layout and interpreter flags are enforced together rather than one team at a time.

## The entry that causes it Before your first line runs, CPython assembles `sys.path`, the ordered list of directories the import machinery searches. When the interpreter is launched with a script path — `python /srv/jobs/job.py` — it prepends **the directory containing that script** as `sys.path[0]`. (Since 3.11 that entry is the absolute, fully resolved directory; earlier versions inserted it as written.) Everything else follows: any `PYTHONPATH` entries, then the standard library, then the `site-packages` directory of the environment in use. The ordering is the whole story. Import resolution walks `sys.path` front to back and stops at the first match. Only modules that are built into the interpreter or frozen into it — `sys` and the import bootstrap itself — are resolved before the path is consulted at all. Everything else, including `json`, `logging`, `random`, `socket` and `email`, is an ordinary file that the search finds somewhere on the path, and `sys.path[0]` gets first refusal on the name. ## Why 'first' is a security property, not just a gotcha A developer usually meets this ordering as an accident: a helper file next to the script wins over a same-named library module. The security version of the same mechanic asks a different question — **who else can create files in that directory?** An import is not a data read. Importing a module *executes* its top-level code: assignments, decorators, `open()` calls, network calls, anything. So an attacker who can write into `sys.path[0]` does not need to find a bug in your program. They need only to guess one module name your script imports and leave a file with that name. The moment the process reaches the import statement — typically in the first milliseconds, long before argument parsing or any authorization check — their code runs inside your process, as your user, with your environment, your credentials and your file handles. The guessing part is easy. Almost every script imports `os`, `sys`, `json`, `logging`, `re`, `time` or `pathlib`. `sys` is built in and cannot be shadowed this way, but the rest are ordinary files on the path and are all fair game. ## Where this actually happens * A shared scratch directory such as `/tmp/work` that several accounts or several containers write into, where an operations script is dropped and executed. * A downloads directory: you fetch a one-off tool and run it in place, in a folder every other download also lands in. * A deploy or job directory whose permissions drifted to world-writable — the classic case, because the code there is run repeatedly and often by a privileged service account. * A queue or spool directory where one component writes files and another runs a script from the same location. If, say, the rollback step of a log-ingest pipeline is executed from the same folder the pipeline drops rejected batches into, the attack surface and the data surface are the same directory. Note the difference from an environment-variable attack: nothing is injected into the process's configuration here. The path entry is one CPython adds by design; the attacker only supplies a file. ## Reducing it **Fix the directory first.** The real defect is a directory that is both writable by others and used as a working directory for executed code. Run scripts from a location owned by the account that runs them, mode `0755` or tighter, and treat a world-writable directory as untrusted input. **Then drop the entry.** Python 3.11 added a way to say 'do not put my launch directory on the path': the `-P` command-line flag, or the `PYTHONSAFEPATH` environment variable set to a non-empty value. Either one is readable at runtime as `sys.flags.safe_path`. With it set, `sys.path[0]` is no longer the script's directory (and, for `-c` and the REPL, no longer the current directory), so a dropped file beside the script is simply not found. `-I` (isolated mode) implies the same behaviour and additionally ignores `PYTHON*` variables and the user site directory — a good default for a tool run by a service. The cost is that the script can no longer import its own sibling helper modules by bare name, which is exactly why it is opt-in rather than the default. Code that must keep working under `-P` should live in an installed distribution and be imported by its package name, not found by proximity. **Verify what you got.** `importlib.util.find_spec(name).origin` tells you the file an import would actually load, and `module.__file__` tells you where a loaded one came from. In a suspected incident, print those for the modules the script imports rather than assuming the standard library won. ## What this does not cover `-P` says nothing about `PYTHONPATH` (use `-E` or `-I` for that), nothing about a writable `site-packages` in a shared environment, and nothing about a script that changes directory at runtime. It removes exactly one entry — the one that makes 'where the file happens to sit' into a privilege.

  • Which imports cannot be hijacked this way, no matter what file sits beside the script?
    Modules built into the interpreter or frozen into it — `sys` is the everyday example, along with the import bootstrap itself. Those are resolved before `sys.path` is consulted at all, so a file named `sys.py` in the launch directory is never reached. Everything else in the standard library is an ordinary file found by searching the path, so it can be shadowed by an earlier entry.
  • If you set PYTHONSAFEPATH=1, what breaks in a script that used to work?
    Any bare-name import of a helper file that sits next to the script. Without the launch-directory entry, `import helpers` no longer resolves by proximity, so the script fails at import time with `ModuleNotFoundError`. The fix is to install the code as a distribution and import it by its package name, or to run it with `-m` from a directory that is legitimately on the path — not to put the entry back.
  • How would you tell, after the fact, whether a running process imported a shadowing file?
    Look at where each loaded module actually came from: `module.__file__` for an imported module, or `importlib.util.find_spec(name).origin` for one not yet imported. Anything in the standard library whose file resolves into the working or launch directory rather than the interpreter's library directory is the shadow. Comparing that against `sys.path` shows which entry won.

It is like keeping your toolbox in an unlocked hallway cupboard and always reaching in without looking: anyone can leave a tool-shaped object there, and you will use whatever your hand closes on first.

saying these in an interview costs you the question

  • Thinks the standard library is always searched before local files
  • Believes importing a file only reads it, without executing anything
  • Says only PYTHONPATH can add directories to sys.path
  • Claims Python refuses to import files it did not install
  • Thinks the risk starts at main(), not at the import statement
  • Assumes -P also blocks PYTHONPATH and the user site directory

context