skip to content

Host Attack Surfaces

The host resources a careless process hands an attacker: the command shell, a user-supplied filesystem path, the shared temp directory, the inherited environment and the module search path.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

19

Where do the entries in Python's os.environ come from, and who controls them?

level: juniorimportance: must knowfreq 55%

answer

  1. Someone else chose these values
  2. Handed over at process launch
  3. Copied once, when os is imported
  4. Writes go out through putenv
  5. Children get what you leave behind

basics

~20 s

The process that launched yours supplies the environment block, and Python copies it into os.environ when the os module is first imported. Whoever controls the launch - a shell, a container spec, a job scheduler - controls the values.

solid answer

~40 s

`os.environ` is not a live view of the operating system's copy. The `os` module builds it once, at interpreter startup, from the `NAME=value` block the parent process handed over when it executed your program. That makes every value in it input from outside your code: a login shell, a service-manager unit, a container manifest, a CI runner or another team's wrapper script chose it. Writes travel the other way - assigning `os.environ["SCHEDULE_TZ"] = "UTC"` also calls `os.putenv`, which changes the real process environment, so processes you start afterwards inherit it, and `del` calls `os.unsetenv`. Because the snapshot is taken once, a variable set by C code inside the process after startup never appears in `os.environ`, and `os.getenv` will not see it either since it is just `os.environ.get`.

code

python · 8 lines
python
import os, subprocess, sys

os.environ["SCHEDULE_TZ"] = "UTC"          # goes through os.putenv
child = subprocess.run(
    [sys.executable, "-c", "import os; print(os.environ['SCHEDULE_TZ'])"],
    capture_output=True, text=True, check=True,
)
print(child.stdout.strip())                # UTC: the child inherited the write

go deeper

for a junior

Be ready to say where the values come from - the process that launched yours - and that Python copies them once at startup into os.environ. Know os.environ.get with a default, and that every value is a string you must convert yourself.

for a middle

Explain the mechanics: the snapshot at import, assignment calling os.putenv so later children inherit, deletion calling os.unsetenv, and os.getenv being os.environ.get. Be able to say why a change made by C code after startup is invisible.

for a senior

Show the trust argument in production terms: enumerate who can write the launch path of your service, read and validate the environment once at startup, and prefer passing env= to a child over mutating the parent's environment from library code.

for a principal

Own the policy question - where configuration is allowed to come from at all, which variables a platform is permitted to inject into your processes, and how you keep a fleet's launch path reviewable so the environment stops being an unaudited input channel.

## The environment is a block your parent chose On POSIX a new process is created with `fork` and `exec`, and the `exec` call carries three things: the program, the argument vector, and an array of `NAME=value` byte strings - the environment block. The kernel copies that block into the new process image. Nothing about it is signed, versioned or filtered; it is whatever the launching process decided to pass. Windows differs in mechanics but not in trust: the creating process supplies the environment. ## Python takes one snapshot The first time `os` is imported, it builds `os.environ` from that block. `os.environ` is a mutable mapping over the process's real environment, not a plain `dict`, and everything you read afterwards comes from that snapshot - including `os.getenv`, which is literally `os.environ.get`. If a C extension or a library you call into sets a variable with the C `setenv` after startup, `os.environ` will not show it. That is a real source of confusion when a native library "configures itself" from the environment and your Python code cannot see the change. ## Writes go the other way `os.environ["SCHEDULE_TZ"] = "UTC"` updates the snapshot *and* calls `os.putenv`, which mutates the process's real environment; `del os.environ["X"]` calls `os.unsetenv`. Three consequences follow. First, every child process you spawn afterwards inherits the change, which is why the write appears to "work". Second, it is a process-global side effect: two threads writing environment variables are stepping on shared state, and a library that mutates the environment at import time silently reshapes every subprocess your program will ever start. Third, it is a blunt way to configure a child, because it also changes the parent; passing `env=` to `subprocess.run` affects only the child. ## Why "inherited" means "untrusted" List everyone on the launch path of a service - say a flight-schedule differ sitting in a 17-service dependency graph. A login shell and its startup files. A service-manager unit or container image with its own `ENV` lines. An orchestrator that injects more. A CI runner. A cron entry. A wrapper script another team owns. That is a dozen artefacts owned by several teams, none of them reviewed as part of your service, all of them executing before your `main()` runs. If any of them is writable by someone you would not hand root to, that person is choosing values your program reads. The values that matter fall into three families: - **Values you read yourself** - a time zone, a feature flag, an endpoint, a file path. Whatever you would do with a field from a request body, do with these: parse, range-check, reject unknown values, fail closed rather than silently defaulting. - **Values the C library and the operating system read** - `PATH`, `TZ`, the locale variables, `TMPDIR`, the dynamic loader's library-preload variable. These change what a *child program* does even though your Python code never mentions them. - **Values the interpreter reads at startup** - the `PYTHON*` family. These matter most when *you* start a child interpreter, because that child reads them out of whatever environment you hand it. ## Practical reading rules Read the environment once, at startup, into a validated configuration object, rather than calling `os.environ.get` from a dozen places: scattered reads hide the provenance of a value and let a mid-run mutation change behaviour. Use `os.environ[name]` when absence should abort startup and `os.environ.get(name, default)` when it should not - and remember every value is a `str`, so numbers, booleans and lists need explicit parsing; both `"0"` and `"false"` are truthy strings. Names are case-sensitive on POSIX. On Windows the operating system treats them case-insensitively and `os.environ` upper-cases its keys, so code that indexes with a lower-case name behaves differently across platforms. Underneath, the block is bytes on POSIX: `os.environb` exposes the raw bytes when `os.supports_bytes_environ` is true, and `os.environ` shows you a decoded view of the same data. ## The framing an interviewer is listening for The interesting answer is not "environment variables can hold secrets". It is that the environment is the one input channel your program never opened: it arrives before your first line of code, and it is wide enough to steer the C library, the dynamic loader and every child you start. Treating it as configuration you wrote is the mistake; treating it as input you parse, validate, and then deliberately rebuild for children is the discipline.

  • If a C extension sets a variable with setenv after startup, will os.environ or os.getenv show it?
    No. `os.environ` is built once, when `os` is first imported, and `os.getenv` is just `os.environ.get`, so both read the same stale snapshot. Python only learns about later changes it makes itself, through `os.putenv` and `os.unsetenv` behind assignment and deletion. If you must see the live value you have to call the C `getenv` yourself through a foreign-function interface, which is almost always a sign the library should expose a proper configuration call instead.
  • Why is mutating os.environ a poor way to configure one child process?
    Because it is process-global and permanent: it changes the parent, every thread, and every child started from that point on, not just the one you meant. Pass `env=` to `subprocess.run` instead - it applies to that child only and leaves the parent's environment untouched. It also makes the intent visible at the call site, rather than leaving a mutation somewhere at import time that a reader has to find.
  • A value is present but empty, like SCHEDULE_TZ=''. What should the code do?
    Decide explicitly, because `os.environ.get("SCHEDULE_TZ", "UTC")` returns the empty string, not the default - the key exists. Empty usually means "someone set this from an unset shell variable", so most services should treat empty as absent, or as a startup error if the setting is required. The rule is the same as for any other untrusted input: validate the value, do not just check for the key.

The environment is a note pinned to your program by whoever started it. You cannot see who wrote the note, you cannot tell whether it was edited on the way, and your child processes get a photocopy unless you write a fresh one.

saying these in an interview costs you the question

  • Says os.environ is read fresh from the OS on every access
  • Treats environment values as trusted because only ops set them
  • Thinks assigning to os.environ affects the Python process only
  • Expects os.getenv to see a setenv done by C code after startup
  • Assumes variable names are case-sensitive on every platform
  • Uses a value straight from os.environ without parsing or validating

context

open as a page

In subprocess.run, what changes between passing a list and passing shell=True?

level: juniorimportance: must knowfreq 75%

basics

~20 s

With a list, CPython execs the program directly and each element becomes exactly one argv entry, so nothing tokenises it. With shell=True the whole string goes to /bin/sh -c, where ; | $() and word splitting are live syntax.

open as a page

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

level: juniorimportance: must knowfreq 60%

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.

open as a page

Why does os.path.join('/srv/uploads', name) not guarantee a path inside /srv/uploads?

level: juniorimportance: must knowfreq 55%

basics

~20 s

os.path.join throws away everything to the left of a component that is already absolute, so joining '/etc/passwd' onto a base returns '/etc/passwd'. A relative name holding '..' also climbs out. Joining concatenates; it never confines.

open as a page

Why is tempfile.mktemp unsafe where tempfile.mkstemp is not?

level: middleimportance: must knowfreq 52%

basics

~20 s

tempfile.mktemp only returns a name. Between that return and your own open() an attacker can create that path, usually as a symlink. tempfile.mkstemp creates and opens the file itself in one exclusive step, so there is no window to exploit.

open as a page

How do you verify with pathlib that a user-supplied path stays inside a base directory?

level: middleimportance: must knowfreq 65%

basics

~20 s

Join the user segment onto an absolute base, call Path.resolve on the result, and test it with PurePath.is_relative_to against the base you resolved once at start-up. Resolve first, compare second, and never compare raw strings.

open as a page

How can a hostile PATH entry make subprocess.run(['helper']) launch the wrong binary?

level: seniorimportance: must knowfreq 45%

basics

~20 s

A program name with no directory separator is looked up in the PATH directories in order, and the first executable match wins. If an earlier directory is attacker-writable, or empty or relative (meaning the working directory), they choose the code you run.

open as a page

What does tempfile.NamedTemporaryFile give you that open('/tmp/report.csv','w') does not?

level: juniorimportance: should knowfreq 38%

basics

~20 s

tempfile.NamedTemporaryFile creates a uniquely named file atomically, with owner-only 0o600 permissions, and removes it when closed. A fixed /tmp path is guessable, may already exist as somebody else's symlink, and inherits a looser mode from the umask.

open as a page

What exactly does passing env= to subprocess.run change for the child process?

level: middleimportance: should knowfreq 40%

basics

~20 s

It replaces the child's environment wholesale instead of adding to it: the child sees exactly the mapping you pass and nothing else. To add one variable, copy os.environ first; to scrub, pass a deliberate allow-list.

open as a page

Where does shlex.quote's guarantee stop when building a shell command string?

level: middleimportance: should knowfreq 45%

basics

~20 s

shlex.quote makes a value one word for one POSIX shell. It says nothing about cmd.exe, nothing about a second shell that re-parses the string, and nothing about a value the invoked program reads as an option.

open as a page

How does CPython choose sys.path[0] for a script, -m, -c and the REPL?

level: middleimportance: should knowfreq 45%

basics

~20 s

CPython prepends the script's own directory for python app.py, the absolute working directory for -m, and the empty string for -c, piped stdin and the interactive REPL. The empty string means the working directory at import time.

open as a page

Can PYTHONPATH shadow a standard-library module, and does python -P stop it?

level: middleimportance: should knowfreq 38%

basics

~20 s

Yes to the first, no to the second. PYTHONPATH entries are inserted after the implicit first entry but ahead of the standard library, so a matching .py there wins. -P suppresses only the implicit entry and leaves PYTHONPATH in force.

open as a page

How do os.path.normpath, os.path.abspath and os.path.realpath differ when confining a user path?

level: middleimportance: should knowfreq 45%

basics

~20 s

normpath collapses '.' and '..' purely textually with no filesystem access; abspath is normpath anchored to the current working directory; only realpath walks the filesystem and resolves symlinks. A containment check built on the first two can approve a symlinked escape.

open as a page

In subprocess on Windows, why is an argument list a weaker boundary than on POSIX?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Windows creates processes from a single command-line string, so subprocess joins your list with subprocess.list2cmdline and the child re-parses it. Safety then depends on the child using the same parsing convention, and batch files do not.

open as a page

Why can an empty sys.path[0] plus os.chdir hijack a log-ingest worker's imports?

level: seniorimportance: should knowfreq 35%

basics

~20 s

The empty first entry means the working directory at import time, not where the process started. After an os.chdir into a writable spool directory, any module not already in sys.modules can be supplied by a file an attacker dropped there.

open as a page

tempfile.gettempdir() picks a directory from the environment — how do you harden a job that trusts it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

tempfile.gettempdir() honours TMPDIR, TEMP and TMP from the inherited environment before falling back to /tmp. For sensitive staging, pass an explicit dir= you created yourself with mode 0o700 rather than trusting whatever the caller exported.

open as a page

Your job closes the fd from tempfile.mkstemp, then reopens the file by its path — what protection is lost?

level: seniorimportance: should knowfreq 22%

basics

~10 s

The exclusive-creation guarantee covers creation only. Once the descriptor is closed the path is just a name in a shared directory, and can be unlinked or replaced by a symlink before you reopen it.

open as a page

How does the gap between validating a path with Path.resolve and opening it allow an escape?

level: seniorimportance: should knowfreq 32%

basics

~20 s

The check and the open are two independent name lookups. Between them an attacker can replace a component with a symlink or rename a directory, so the second lookup reaches a different file than the one that was validated. Close it by opening through a directory file descriptor.

open as a page

Why can a value read from Python's os.environ contain a lone surrogate character?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

On POSIX the environment is bytes. os.environ decodes it with the filesystem encoding and the surrogateescape error handler, so any byte that is not valid UTF-8 survives as a lone surrogate instead of raising. os.environb shows the original bytes.

open as a page