skip to content

Inherited Environment Trust

Treating the inherited environment as attacker-controlled input: a writable search-path entry sending your subprocess to the wrong binary, variables leaking into children, and passing a scrubbed set.

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

questions

4

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

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

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