skip to content

A PHP endpoint passes a request parameter straight to file_get_contents(); besides path traversal, what can an attacker reach through stream wrappers, and how do you close it?

level: seniorimportance: should knowfreq 30%

answer

  1. the scheme picks the wrapper
  2. http/https/data gated by allow_url_fopen
  3. php://filter, compress.zlib://, phar:// are not
  4. a fixed absolute prefix disables selection
  5. stream_wrapper_unregister() for unused wrappers

basics

~20 s

A value that starts with a scheme picks the wrapper: it can make the server fetch internal URLs, return attacker-supplied data: bytes, or read files through php://filter, compress.zlib:// or phar://. Map IDs to paths or prefix a fixed directory, and disable unused wrappers.

solid answer

~50 s

Any string reaching a PHP file function picks its wrapper from its own start, so the attacker chooses the protocol, not only the file. With `allow_url_fopen` On, `http://` and `https://` turn the endpoint into a proxy for internal services (SSRF), and `data:text/plain;base64,…` makes it return attacker-chosen bytes. Local wrappers are not gated by that setting: `php://filter/read=convert.base64-encode/resource=…` reads any readable file through a transformation that can slip past checks on the output, `compress.zlib://` wraps any path, and `phar://` reads inside archives. Since PHP 8.0 `phar://` no longer unserializes archive metadata automatically, which closed the older object-injection route. The fixes, strongest first: map an opaque ID to a path from an allowlist; otherwise build the path from a fixed absolute directory so the string starts with `/` and cannot select a wrapper; set `allow_url_fopen=Off`; and `stream_wrapper_unregister()` wrappers the application never uses.

go deeper

for a junior

Recall that the scheme at the start of a path picks the wrapper, and that allow_url_fopen gates only URL wrappers.

for a middle

Explain which wrappers stay available with allow_url_fopen Off, and how php://filter and data: change what a file read returns.

for a senior

Show a ranked fix: opaque IDs, a fixed absolute prefix, allow_url_fopen Off, unregistering unused wrappers, and open_basedir as containment.

for a principal

Frame it as a platform baseline: which wrappers the runtime exposes by default, and how code review and static analysis catch request data reaching file functions.

## Why the wrapper is part of the attack surface PHP decides which stream wrapper handles a path by looking at the **start of the string**: a run of letters, digits, `+`, `-` or `.` followed by `://` (or the special `data:` prefix). If user input *is* the path, the user picks the wrapper as well as the file. Path traversal, climbing out of a directory with `../`, is only one of the consequences, and it is treated separately; this question is about the protocol choice. ## What each wrapper gives an attacker | Input starts with | Gated by `allow_url_fopen`? | What the attacker gets | |---|---|---| | `http://`, `https://` | yes | the server requests internal URLs for them (SSRF): metadata endpoints, admin panels, internal APIs | | `ftp://` | yes | outbound connections to hosts they choose | | `data:` | yes | a "file" whose content they supplied, fed into whatever trusts the file | | `php://filter/…/resource=…` | **no** | any readable file, passed through filters such as `convert.base64-encode`, which can hide content from output checks | | `compress.zlib://` | **no** | any readable file or path through the zlib wrapper | | `phar://` | **no** | members of a PHP archive; before PHP 8.0, any file operation on `phar://` also unserialized the archive's metadata | | `glob://` | **no** | pattern-based listing when the value reaches a directory function | `php://`, `compress.zlib://` and `phar://` are local wrappers in php-src, so turning `allow_url_fopen` off does **not** remove them. ## The PHP 8.0 phar change Up to PHP 7.4, merely calling `file_exists('phar://uploads/avatar.jpg/x')` on an attacker-uploaded polyglot file unserialized the archive's metadata, triggering object-injection gadgets without any `unserialize()` call in the code. PHP 8.0 stopped unserializing phar metadata automatically; it now happens only when code asks for it. The wrapper still exposes archive contents, so it remains worth disabling where unused. ## Closing it, strongest first 1. **Do not accept paths at all.** Accept an opaque ID (`report=42`) and map it to a path your code owns, from a database row or an allowlist array. Nothing the user sends ever reaches a file function. 2. **Fix the prefix.** If a name must come from input, build `'/var/app/reports/' . $name`. Because the string now starts with `/`, the streams layer treats it as a plain file and no wrapper can be selected. An explicit `'file://' . $base . '/' . $name` has the same effect. Validate `$name` against a strict pattern too; traversal inside the directory is a separate defence. 3. **Turn off URL wrappers** with `allow_url_fopen = Off` in php.ini or the FPM pool when the application never fetches URLs through file functions. It is `PHP_INI_SYSTEM`, so a script cannot turn it back on. 4. **Unregister unused wrappers** early in bootstrap: ```php <?php declare(strict_types=1); foreach (['phar', 'data', 'ftp', 'glob', 'compress.zlib'] as $scheme) { if (in_array($scheme, stream_get_wrappers(), true)) { stream_wrapper_unregister($scheme); } } ``` `stream_wrapper_unregister()` removes a wrapper for the rest of the request, and `stream_wrapper_restore()` brings a built-in one back. `php://` usually has to stay, because the application itself uses `php://temp` or `php://output`. 5. **Contain the damage.** `open_basedir` restricts plain-file access to listed directories and stops `php://filter/resource=/etc/…` from reaching outside them, as defence in depth rather than the primary control. ## Testing the fix Write tests that feed the endpoint the wrapper payloads, not only `../` strings: - `https://127.0.0.1/` and `http://localhost:1/` must not cause an outbound connection. - `data:text/plain,hello` must not return `hello`. - `php://filter/read=convert.base64-encode/resource=index.php` must not return Base64 source. - `phar://…` and `compress.zlib://…` paths must be rejected or resolve to missing local files. Each case should end in the same "not found" or validation error as an unknown name, so the response does not reveal which wrappers exist. ## Reviewing code for this - Search for file functions whose argument contains request data: `file_get_contents`, `fopen`, `file`, `readfile`, `copy`, `file_exists`, `is_file`, `getimagesize`, `SplFileObject`, `simplexml_load_file` and similar loaders. - For each, ask two questions: can the string's first characters be controlled, and can its directory be escaped? The first is this wrapper problem; the second is path traversal. - Treat "we check the extension" as no defence: `php://filter/resource=config.php` and `data:text/plain,x.pdf` both end however the attacker likes.

  • Does prefixing the input with a directory really stop wrapper selection?
    Yes, when the prefix is an absolute path. php-src only recognises a scheme when the string starts with scheme characters followed by `://` (or `data:`), so `/var/app/reports/php://filter/...` is a plain local path that simply does not exist. A relative prefix such as `reports/` works the same way, but the absolute form also documents where files may live. Traversal inside that directory still needs its own validation.
  • Why does allow_url_fopen = Off not make file_get_contents($userInput) safe?
    It only gates wrappers flagged as URLs: HTTP, HTTPS, FTP and `data:`. `php://filter`, `compress.zlib://`, `phar://` and `glob://` are local wrappers and still work, so an attacker can still read files through filters or archives. The input itself must be prevented from choosing the protocol.
  • What did PHP 8.0 change about phar:// and deserialization?
    Before 8.0, opening or stat-ing a `phar://` path parsed the archive and unserialized its metadata automatically, so an uploaded file disguised as an image could trigger object-injection gadgets through a plain `file_exists()` call. Since 8.0, metadata is unserialized only when code explicitly reads it. The wrapper still reads archive members, so disabling it where unused remains good practice.

saying these in an interview costs you the question

  • Turning allow_url_fopen off blocks every wrapper except file://.
  • Checking that the value ends in .pdf is enough to keep wrappers out.
  • php://filter only matters for include, not for file_get_contents().
  • Wrappers are only selected when the value contains http.
  • In PHP 8.5, phar:// still unserializes metadata on file_exists().