skip to content

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

level: seniorimportance: must knowfreq 45%

answer

  1. Someone else wrote the lookup table
  2. A name is not a file yet
  3. First match in the list wins
  4. Empty and relative entries mean cwd
  5. Absolute path skips the search

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.

solid answer

~50 s

No shell is involved: an argument list is already safe from shell metacharacters, but `subprocess` still has to turn the name `helper` into a file. On POSIX it builds the candidate list with `os.get_exec_path(env)` - the `PATH` value split on `os.pathsep` - and execs the first candidate the kernel accepts. So `PATH` is a lookup table supplied by whoever launched the process. A group-writable directory placed early in `PATH` by a base image, an empty entry (`"/usr/bin:"`) or a relative entry, which both resolve in the child's working directory, each let someone drop a file named `helper` and have your service run it as your service's user. The fix is to stop letting inherited state decide: hard-code an absolute path, or resolve once at startup with `shutil.which(name, path=TRUSTED_PATH)` and pass `env=` with that same pinned `PATH`.

code

python · 6 lines
python
import os, shutil

print(os.get_exec_path())                          # taken from os.environ["PATH"]
print(os.get_exec_path({"PATH": "/opt/tools:/usr/bin"}))
print(os.get_exec_path({"PATH": "/usr/bin:"}))     # '' means the working directory
print(shutil.which("uname", path="/usr/bin:/bin"))

go deeper

for a junior

Know that a bare program name is looked up in the directories listed in PATH, in order, and that giving an absolute path avoids the search. Be able to show the search list with shutil.which.

for a middle

Explain the mechanics: no directory separator means a PATH search built from the environment being passed to the child, first executable match wins, and an empty or relative entry resolves in the working directory. Show how env= pins the value.

for a senior

Demonstrate the production judgement: enumerate who can write each PATH directory on the hosts, resolve helpers once at startup against a pinned path, assert the result's prefix and permissions, and never accept a search path from a request or job payload.

for a principal

Own the fleet-level answer - where helper binaries live, who may add directories to the launch environment across services, and whether the platform should hand services absolute tool paths so an inherited variable never chooses executable code again.

## What actually happens between the call and the exec `subprocess.run(["schedhelper", "--diff"])` does not hand a string to a shell - that is a different bug class and a different discussion. It still has to resolve the name. On POSIX, if `args[0]` contains a directory separator it is used as a path directly. If it does not, `subprocess` builds a candidate list by joining the name onto each directory returned by `os.get_exec_path(env)`, which is simply the `PATH` value from the environment being passed to the child, split on `os.pathsep`. The candidates are tried in order and the first one the kernel will execute wins. `shutil.which` implements the same search in Python so you can inspect the answer, with a small twist: when `PATH` is unset entirely it falls back to `os.confstr("CS_PATH")` and then `os.defpath`, and on Windows it also expands the name through `PATHEXT`. The security consequence is direct: **`PATH` is an attacker-influenced lookup table that decides which file your process executes.** ## The three shapes of a hijack 1. **A writable directory earlier in the list.** If `PATH` reads `/usr/local/bin:/usr/bin` and `/usr/local/bin` is group-writable - a common outcome of a base image plus a package manager plus a "let the build user install things" decision - anyone in that group can create `schedhelper` there and win the lookup ahead of the real one in `/usr/bin`. 2. **An empty entry.** `PATH="/usr/bin:"` contains a trailing empty string, and joining a name onto the empty directory yields a relative path, which resolves in the child's working directory. Empty entries appear all the time from shell string-building like `PATH="$PATH:"` where a variable was unset. 3. **A relative entry**, including a literal `.`. Same mechanism, and it makes the answer depend on `cwd`. A batch worker that changes directory into an upload folder before shelling out is then searching a directory strangers can write into. ## A concrete failure A flight-schedule differ runs inside a 17-service dependency graph and calls a small helper binary by bare name to render each diff. All 17 services build from one shared base image, and that image adds a tools directory near the front of `PATH` so the platform team can drop utilities in without rebuilding. The differ also runs as a batch job that changes directory into a per-tenant upload folder before comparing files. Two independent hijacks are now live: anyone who can write to the shared tools directory owns code execution in all 17 services, and any tenant who uploads a file named after the helper owns it in the batch path if `PATH` carries an empty or relative entry. Neither requires a shell, a metacharacter or a single line of untrusted data reaching a command line. What the attacker gets is not "a weird binary ran". It is arbitrary code as your service's user, inside your network position, with your process's whole environment - including whatever credentials the launcher put there - handed to the child. ## Defences, strongest first - **Name the file, not the command.** An absolute path in `args[0]` skips the search entirely. This is the only defence that removes the lookup rather than constraining it. - **Resolve once, at startup, against a `PATH` you wrote.** `shutil.which("schedhelper", path="/usr/bin:/bin")` gives you an absolute path or `None`; refuse to start when it is `None`, and log the resolved path so the deployment is auditable. Resolving at startup also means a directory that becomes writable later cannot change the answer mid-run. - **Pass `env=` with that same pinned `PATH`.** Otherwise a child that itself shells out re-enters the same lookup with the inherited value. - **Constrain the directories.** Assert that the resolved path sits under an allow-listed prefix, and that the directory is not group- or world-writable. `os.stat` on the parent directory answers this in two lines and turns "we assume the image is fine" into a startup check. - **Never accept `PATH` from a request, a job payload or a tenant-supplied configuration.** If a workload genuinely needs different tools, map an identifier to a fixed absolute path in your own table. ## The things people get wrong Three claims come up in interviews and all three are false. "An argument list is safe" is only true about shell parsing; the lookup is still inherited. "`shutil.which` makes it safe" is only true if you pass an explicit `path=`, because by default it reads the same untrusted `PATH`. And "only root can write to those directories" is an assumption to verify on the host, not a property of `PATH`. Windows adds its own wrinkles - `PATHEXT` decides which extensions count as executable, and the platform's search rules around the current directory have their own history - which is another argument for handing the operating system a path you computed instead of a name it has to interpret.

  • Does using an argument list instead of a single command string protect you here?
    No. An argument list removes shell parsing, so metacharacters in arguments stop mattering, but the program name in position zero is still resolved against the inherited `PATH`. The two defects are independent: one is about how a string is split, the other about which file a name maps to. You need both an argument list and a path you control.
  • Is shutil.which enough on its own to make the call safe?
    Only with an explicit `path=` argument. Called with just a name it reads the same untrusted `PATH` and faithfully returns the attacker's binary, with an absolute path that now looks reassuring. Used properly - a pinned search path at startup, a `None` result treated as a fatal misconfiguration, and a check that the result sits under an allow-listed prefix - it becomes a real control.
  • Your service must run a helper whose location genuinely varies per host. How do you keep it safe?
    Resolve it once at startup against a search path your deployment owns, not one the login environment supplies, and validate the result: absolute, under an approved prefix, owned by root or the service account, not group- or world-writable. Record the resolved path in the startup log so an operator can see what was chosen, and fail to start rather than falling back to a bare-name call.

Calling a helper by bare name is like telling a courier "take this to the office on the corner" when several corners qualify. An absolute path is a street address: nobody else gets to decide which door opens.

saying these in an interview costs you the question

  • Says an argument list makes the call safe because no shell runs
  • Calls shutil.which with no path argument and calls it hardened
  • Believes only root can write to directories on PATH
  • Misses that an empty or relative PATH entry means the working directory
  • Thinks the search happens relative to the cwd passed to subprocess
  • Treats this as shell injection instead of a lookup problem

context