Your Location is: Home > Php

How to use php://fd/ wrapper?

From: Uganda View: 2768 kazza 

Question

I have been trying to understand how to use the php://fd/<n> wrapper. The documentation states the following:

php://fd allows direct access to the given file descriptor. For example, php://fd/3 refers to file descriptor 3.

In my head, this means that the php://fd wrapper provides access to the underlying file descriptors as understood within the context of the process to the operating system. E.g., I would expect the following correspondence to hold:

  • In php fread(fopen('php://fd/3'), 10) maps to read(3, buf, 10) in C code.

However, in tests I am unable to actually get this wrapper to work. Consider the following test php code which opens the /etc/passwd file and seeks 500 bytes. It also has some utility code that is used to list a directory and include the contents of a file.

<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$f = fopen("/etc/passwd", "r");
fread($f, 500);
echo "$f\n";
function listdir($d) {
    foreach (scandir($d) as $f) {
        if (file_exists("$d/$f")) {
            $s=lstat("$d/$f");
            $l=($s[2] & 0120000) == 0120000 ? readlink("$d/$f") : '';
            printf("%-25s %6d %6d %6o %8d    %s  %s\n", $f, $s[4], $s[5], $s[2], $s[7], strftime('%F %T', $s[9]), $l);
        }
    }
}
if (isset($_GET["dir"])) {
    listdir($_GET["dir"]);
}
if (isset($_GET["file"])) {
    include_once($_GET["file"]);
} elseif (isset($_GET["content"])) {
    echo file_get_contents($_GET["content"]);
} elseif (isset($_GET["fopen"])) {
    echo fread(fopen($_GET["fopen"], "r"), 1024);
}
?>

As the above code leaves an open file descriptor at position 500 in the /etc/passwd file, I would have expected that its possible to use the php://fd wrapper to continue reading the file from that position. However, this does not seem possible.

In the first example, it prints the contents of the /proc/self/fd directory (where it can be seen that the open fd is 12) and then tries to open this fd using the php://fd/12 syntax. Note the fields listed of the directory are: name, uid, gid, mode, size, modify date and readlink.

$ curl '192.168.56.47?dir=/proc/self/fd&fopen=php://fd/12'
Resource id #2
.                             33     33  40500        0    2020-09-24 09:57:46  
..                            33     33  40555        0    2020-09-24 09:53:22  
0                             33     33 120500       64    2020-09-24 10:09:08  /dev/null
1                             33     33 120300       64    2020-09-24 10:09:08  /dev/null
10                            33     33 120700       64    2020-09-24 10:09:08  anon_inode:[eventpoll]
11                            33     33 120700       64    2020-09-24 10:27:43  socket:[50335]
12                            33     33 120500       64    2020-09-24 14:16:50  /etc/passwd
2                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/error.log
3                             33     33 120700       64    2020-09-24 10:09:08  socket:[46732]
4                             33     33 120700       64    2020-09-24 10:09:08  socket:[46733]
5                             33     33 120500       64    2020-09-24 10:09:08  pipe:[47544]
6                             33     33 120300       64    2020-09-24 10:09:08  pipe:[47544]
7                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/other_vhosts_access.log
8                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/access.log
9                             33     33 120700       64    2020-09-24 10:09:08  /tmp/.ZendSem.C011w0 (deleted)
<br />
<b>Warning</b>:  fopen(php://fd/12): failed to open stream: operation failed in <b>/var/www/html/index.php</b> on line <b>25</b><br />
<br />
<b>Warning</b>:  fread() expects parameter 1 to be resource, bool given in <b>/var/www/html/index.php</b> on line <b>25</b><br />

The result is the same if using file_get_contents in place of the fread(fopen(....

As a second example, I wondered if the documentation meant a php resource number instead of an operating system file descriptor, so changed it to use number 2 (as printed out on the first line of output) instead of 12. But again, the result is the same:

$ curl '192.168.56.47?dir=/proc/self/fd&fopen=php://fd/2'
Resource id #2
.                             33     33  40500        0    2020-09-24 09:54:03  
..                            33     33  40555        0    2020-09-24 09:53:22  
0                             33     33 120500       64    2020-09-24 10:09:08  /dev/null
1                             33     33 120300       64    2020-09-24 10:09:08  /dev/null
10                            33     33 120700       64    2020-09-24 10:09:08  anon_inode:[eventpoll]
11                            33     33 120700       64    2020-09-24 10:27:58  socket:[50337]
12                            33     33 120500       64    2020-09-24 14:13:27  /etc/passwd
2                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/error.log
3                             33     33 120700       64    2020-09-24 10:09:08  socket:[46732]
4                             33     33 120700       64    2020-09-24 10:09:08  socket:[46733]
5                             33     33 120500       64    2020-09-24 10:09:08  pipe:[47544]
6                             33     33 120300       64    2020-09-24 10:09:08  pipe:[47544]
7                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/other_vhosts_access.log
8                             33     33 120300       64    2020-09-24 10:09:08  /var/log/apache2/access.log
9                             33     33 120700       64    2020-09-24 10:09:08  /tmp/.ZendSem.C011w0 (deleted)
<br />
<b>Warning</b>:  fopen(php://fd/2): failed to open stream: operation failed in <b>/var/www/html/index.php</b> on line <b>25</b><br />
<br />
<b>Warning</b>:  fread() expects parameter 1 to be resource, bool given in <b>/var/www/html/index.php</b> on line <b>25</b><br />

However, as the contents of /proc/self/fd are symlinks, the following is possible but not what I am looking to understand as it opens a new file descriptor and not reuse an existing one:

$ curl '192.168.56.47?fopen=/proc/self/fd/12'
Resource id #2
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
...snip...

I have tried searching on the internet for examples of how this wrapper is intended to be used but did not find any working examples. Any help would be appreciated.

Also, I am not really trying to solve a specific problem but more trying to understand how it works.

The above tests were conducted on Apache 2.4.41 with php 7.4.3.

Update: Answer

After testing with the following file in the cli interpreter I noticed the php://fd/ wrapper was behaving as expected:

<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);

// below taken from: https://stackoverflow.com/a/7033247/5660642
function fd($realpath) {
  $dir = '/proc/self/fd/';
  if ($dh = opendir($dir)) {
      while (($file = readdir($dh)) !== false) {
          $filename = $dir . $file;
          if (filetype($filename) == 'link' && realpath($filename) == $realpath) {
            closedir($dh);
            return $file;
          }
      }
      closedir($dh);
  }
  return FALSE;
}

$f = fopen('/etc/passwd', 'r');
$fd = fd('/etc/passwd');
echo "opened fd: $fd\n";
stream_set_read_buffer($f, 0); // disable buffering before reading
echo fread($f, 10)."\n";
$g = fopen("php://fd/$fd", 'r');
echo ftell($f)."\n";
echo ftell($g)."\n";

This gives the following output:

$ php script.php
opened fd: 3
root:x:0:0
10
10

However, when run from the Apache module it gives the following:

$ curl '192.168.56.47/script.php'
opened fd: 12
root:x:0:0
<br />
<b>Warning</b>:  fopen(php://fd/12): failed to open stream: operation failed in <b>/var/www/html/script.php</b> on line <b>25</b><br />
10
<br />
<b>Warning</b>:  ftell() expects parameter 1 to be resource, bool given in <b>/var/www/html/script.php</b> on line <b>27</b><br />

Hard coded

After comparing configuration between the cli and Apache module I ended up checking the source code. It appears this behaviour is hard coded in the php source code:

    } else if (!strncasecmp(path, "fd/", 3)) {
        const char *start;
        char       *end;
        zend_long  fildes_ori;
        int        dtablesize;

        if (strcmp(sapi_module.name, "cli")) {
            if (options & REPORT_ERRORS) {
                php_error_docref(NULL, E_WARNING, "Direct access to file descriptors is only available from command-line PHP");
            }
            return NULL;
        }

The commit this was implemented in was back in 2012: https://github.com/php/php-src/commit/df2a38e7f8603f51afa4c2257b3369067817d818

However, I dont see this restriction mentioned in the documentation, but it is in the changelog. But nonetheless, its good to know.

Best answer

Not a 100% answer to what it's doing, but hopefully some insight.

Had a bit of a play, and ended up opening the second file separately so I could use ftell() to see what the file pointer was. So

$f = fopen("/etc/passwd", "r");
fread($f, 500);
echo "$f\n";
echo ftell($f).PHP_EOL;

gives

Resource id #86
500

and the second part

} elseif (isset($_GET["fopen"])) {
    $f1 = fopen($_GET["fopen"], "r");
    echo ftell($f1).PHP_EOL;
    echo ">".fread($f1, 10)."<".PHP_EOL;
}

on my machine gives...

3087
><

Which leads me to believe that the system has in fact buffered the reading and the file pointer (in this instance) is currently at the end of the file - hence no content.

So next thing is to try a large file (5.1MB, not sure where I downloaded it from)...

$f = fopen("annual-enterprise-survey-2019-financial-year-provisional-csv.csv", "r");
fread($f, 500);
echo "$f\n";
echo ftell($f).PHP_EOL;

again fives...

Resource id #86
500

and the second part gives...

8192
>),H10,Indi<

So it must be buffering in 8K chunks.