Unix process inheritance: environment variables and file descriptors
Trace what survives fork and exec, and close capabilities a child should not inherit.
On this page
Key takeaways
- Environment strings and open descriptors are inherited process state with different security properties.
- Use explicit environment construction and close-on-exec descriptors to limit child authority.
First principles
A process launch is a boundary where accidental state can become authority. In the traditional Unix process model, fork() creates a child with a copy-on-write view of much of the parent's address space, including its environment and descriptor table. An exec() replaces the process image but normally preserves the environment strings and every open file descriptor that is not marked close-on-exec. The new program therefore starts with more than a command name: it receives configuration and capabilities from its launcher.
An environment variable is a name-value string made available to the process. It is convenient for configuration, but it is not a secret store. Environment values may be visible to the process itself, crash dumps, diagnostic tooling, child processes, and in some environments other users with sufficient permissions. Exact visibility depends on operating system, process ownership, namespaces, and access controls. A child can also pass inherited values to its own children. If a credential is needed by only one operation, a short-lived file descriptor or dedicated credential API may reduce accidental propagation, though it still requires careful ownership.
File descriptors are more than numbers. They refer to open kernel objects such as files, sockets, pipes, and devices. Passing a connected socket to a child grants it the ability to use that connection. Passing a writable descriptor grants a write capability regardless of the pathname permissions that would apply to a fresh open. This can be intentional for standard input/output or socket activation, but accidental inheritance can keep files busy, keep pipes from reaching EOF, or let an unexpected subprocess access a privileged resource.
Close-on-exec is a descriptor flag: when an exec() succeeds, the kernel closes that descriptor in the new process. Prefer atomic close-on-exec creation flags where available, because creating a descriptor and setting the flag in a separate step can race with another thread launching a child. Explicitly pass only the descriptors a child needs. Likewise, build an explicit environment for untrusted tools instead of forwarding the entire parent environment by default.
Runnable example
This shell example launches a command with a deliberately small environment. It prints only the variables that were supplied. Run it in a disposable terminal; it does not inspect or reveal your existing environment.
env -i PATH="/usr/bin:/bin" LANG="C" /usr/bin/env | sort
The expected output contains LANG=C and the restricted PATH, not variables from the interactive shell. In a program, pass an explicit environment map to the process-launch API and an explicit descriptor allowlist where the API supports it. Preserve required runtime variables intentionally; an empty environment can also break legitimate behavior such as locale handling or executable lookup.
To inspect descriptor inheritance safely, create a small test child that prints the descriptors it receives in a temporary environment. Avoid dumping production process environments or descriptor targets into shared logs: command arguments, file paths, socket endpoints, and inherited values may reveal secrets or customer data. The test should assert that a designated descriptor is present when explicitly passed and absent otherwise.
Contextual failure case
A service receives a secret from its orchestrator and stores it in the process environment. It launches a third-party image converter with the parent's entire environment. The converter needs only a filename, but it now receives database credentials and cloud configuration. A bug in the converter's diagnostic output or a malicious plugin can expose values unrelated to image processing. The failure was not that environment configuration is inherently unsafe; it was that the child crossed the trust boundary with unnecessary authority.
A descriptor leak can be less visible. A parent opens a pipe, launches a helper, and waits for EOF from the helper's output. A second unrelated descriptor for the pipe's write end is inherited by a nested child. The parent closes its own writer, but EOF never arrives because the child still holds a writer reference. The parent appears to hang even though no one is actively writing. Similarly, an inherited listening socket may let a child accept traffic after the parent believes it has stopped.
Do not solve every inheritance issue by closing all descriptors blindly. A child may rely on intentionally mapped descriptors such as stdin, stdout, stderr, or an activation socket. Define an allowlist at the launcher boundary, account for libraries that create descriptors concurrently, and use supported close-on-exec defaults. Treat a subprocess's environment and descriptor table as an explicit API contract.
Verification
Test the actual launch path, not only the helper that builds command arguments. Start a child that reports selected environment names and descriptor numbers, then assert that required values exist and forbidden values do not. Include a child that starts a grandchild to catch accidental re-propagation. For pipes, verify the reader reaches EOF after the intended writers close. For sockets, confirm that shutdown prevents a child from accepting new work.
During an incident, tools such as ps, /proc, lsof, or strace may help reveal process state, but access and output vary by platform and permissions. Protect diagnostic output. Check whether a service manager or runtime injects variables independently of application code. Child-process cleanup explains the separate responsibility to reap and terminate child processes; correct shutdown includes both process lifecycle and inherited-resource ownership.
Exercise
Write a launcher for a helper that needs one input file, a result pipe, and a locale, but must not inherit cloud credentials or the server's listening socket. List the permitted descriptors and environment keys. Add a test where the helper creates a grandchild, then verify that the result pipe still reaches EOF when both helpers exit. Explain the race avoided by setting close-on-exec atomically rather than after descriptor creation.
Safety and caveats
Environment visibility and descriptor APIs differ across Unix-like systems and runtime libraries. Containerization can change what process metadata is visible but does not make inherited capabilities disappear. Never place long-lived secrets in command-line arguments or diagnostic output. Test descriptor allowlists against the exact subprocess library and platform used in production, especially when multiple threads can launch children concurrently.