PHP's open_basedir is not a security feature

What is PHP’s open_basedir?

open_basedir is a directive of the php.ini file that takes paths as values. Quoting PHP’s manual:

open_basedir string Limit the files that can be accessed by PHP to the specified directory-tree, including the file itself. This directive is NOT affected by whether Safe Mode is turned On or Off.

When a script tries to access the filesystem, for example using include, or fopen(), the location of the file is checked. When the file is outside the specified directory-tree, PHP will refuse to access it. All symbolic links are resolved, so it’s not possible to avoid this restriction with a symlink. If the file doesn’t exist then the symlink couldn’t be resolved and the filename is compared to (a resolved) open_basedir.

So, in short, it is a defense preventing against wrong file access. E.g. if open_basedir is set as such:

open_basedir = /var/www/html/restricted

Calls to functions taking paths as arguments such as fopen() should be limited to files inside /var/www/html/restricted.

So if you try to do:

<?php
$handle = fopen("/etc/passwd", "r");
?>

It results in:

Warning: fopen(): open_basedir restriction in effect. File(/etc/passwd) is not within the allowed path(s): (/var/www/html/restricted) in /var/www/html/restricted/test.php on line X

Bypassing open_basedir

Note that all the below was done with PHP 8.0.7, it is likely that it will not work anymore.

Looking into PHP’s source code, we can see that most of the functions have an interesting “guard function” (it is not always named the same in the entirety of the source code):

if (php_check_open_basedir(jpeg_file)) {
		RETURN_FALSE;
	}

Function is defined in php-src/main/fopen_wrappers.c:

PHPAPI int php_check_open_basedir(const char *path)
{
	return php_check_open_basedir_ex(path, 1);
}

And is just a wrapper around php_check_open_basedir_ex:

PHPAPI int php_check_open_basedir_ex(const char *path, int warn)
{
	/* Only check when open_basedir is available */
	if (PG(open_basedir) && *PG(open_basedir)) {
		char *pathbuf;
		char *ptr;
		char *end;

		/* Check if the path is too long so we can give a more useful error
		* message. */
		if (strlen(path) > (MAXPATHLEN - 1)) {
			php_error_docref(NULL, E_WARNING, "File name is longer than the maximum allowed path length on this platform (%d): %s", MAXPATHLEN, path);
			errno = EINVAL;
			return -1;
		}

		pathbuf = estrdup(PG(open_basedir));

		ptr = pathbuf;

		while (ptr && *ptr) {                                       // Looping through the "paths" given in the php.ini file
			end = strchr(ptr, DEFAULT_DIR_SEPARATOR);
			if (end != NULL) {
				*end = '\0';
				end++;
			}

			if (php_check_specific_open_basedir(ptr, path) == 0) {  // Unitary path check is done in that call
				efree(pathbuf);
				return 0;
			}

			ptr = end;
		}
		if (warn) {                                                 // and we find our previous error below if something goes wrong
			php_error_docref(NULL, E_WARNING, "open_basedir restriction in effect. File(%s) is not within the allowed path(s): (%s)", path, PG(open_basedir));
		}
		efree(pathbuf);
		errno = EPERM; /* we deny permission to open it */
		return -1;
	}

	/* Nothing to check... */
	return 0;
}

This function is itself just a loop around php_check_specific_open_basedir that does quite a bit of checks:

/* {{{ php_check_specific_open_basedir
	When open_basedir is not NULL, check if the given filename is located in
	open_basedir. Returns -1 if error or not in the open_basedir, else 0.
	When open_basedir is NULL, always return 0.
*/
PHPAPI int php_check_specific_open_basedir(const char *basedir, const char *path)
{
	...
}

The understanding of the above code is interesting, we can deduce that if a function taking a path as arguments lacks a call to php_check_open_basedir() with that path, then it can access files outside of the open_basedir defined hierarchies.

I found two functions that does not enforce that check.

bindtextdomain()

bindtextdomain(string $domain, ?string $directory): string|false

The bindtextdomain() function sets or gets the path for a domain.

The function is defined in php-src/ext/gettext/gettext.c:

/* {{{ Bind to the text domain domain_name, looking for translations in dir. Returns the current domain */
PHP_FUNCTION(bindtextdomain)
{

The $directory argument was not checked, allowing one to test for file existence and thus turning the function into an oracle:

<?php
echo bindtextdomain("test", "../test.txt");
?>

And obtain the below if the file exists:

/var/www/html/test.txt

Instead of:

Warning: bindtextdomain(): open_basedir restriction in effect. File(/var/www/html/test.txt) is not within the allowed path(s): (/var/www/html/restricted) in /var/www/html/restricted/test.php on line X

It was reported to the maintainers on 2021-07-12.

opcache_invalidate()

opcache_invalidate(string $filename, bool $force = false): bool

filename The path to the script being invalidated.

Same idea here, the $filename argument is not checked, hence executing the below:

<?php
echo opcache_invalidate("../test.txt");
?>

results in:

1

instead of:

Warning: opcache_invalidate(): open_basedir restriction in effect. File(/var/www/html/test.txt) is not within the allowed path(s): (/var/www/html/restricted) in /var/www/html/restricted/test.php on line X

It was also reported.

Other bypasses

The above is only partially bypassing open_basedir because it gives file-checking capability, others have found interesting bypasses of the feature to read or write files outside of the limited hierarchies defined:

“open_basedir bypasses are no security issues”

The fact of such feature being a security defense has been controversial for years, I found two discussions of the PHP maintainers stating that “open_basedir bypasses are no security issues” and also questionning the existence of the feature, the latest likely being re-asked because of the submitted bug reports according to the timestamps of the thread:

As a consequence, it seems that the maintainers also changed the documentation to make it clearer:

Caution open_basedir is just an extra safety net, that is in no way comprehensive, and can therefore not be relied upon when security is needed.

So now you know, do not rely on that mechanism believing it will protect your web file hierarchy from being searched and accessed.

Related Articles