Saturday, December 16, 2017

How to use PHP Runkit_Sandbox safely

Leave a Comment

I’m building a teaching tool web app that lets users submit php classes as text and then the app will run them. I think Runkit_Sandbox is the tool for this job, but the docs don’t offer much advice as to which configurations to use.

Is there an established list of functions that should be disabled? Or classes? I’m planning to set all the other configurations to be as restrictive as possible (for example turning off url fopen) but I’m not even 100% sure which those are. Any advice is much appreciated.

3 Answers

Answers 1

I’m building a teaching tool web app that lets users submit php classes

If you are building an app, then you are not assuming too much control on your environment. This means that your solution must be PHP based (which makes Runkit attractive), as the app might be hosted anywhere, possibly somewhere you can't install any of the solutions below. You're still limited to those ISPs that provide Runkit or the possibility to install it, but there's more of them than ISPs amenable to letting you install a chroot jail or a second copy of a web server.

But from the other comments it seems to me that you're building an installation. That is, a whole machine (real or virtual) where you can do as you wish. Which makes other, IMHO more efficient, methods possible:

Two web servers (easier)

Install a second web server listening on local host only, with reduced privileges (e.g. running as user nobody with no write access to its own web root). Install a hardened PHP instance there. To do that, start from the old rules with list of functions to disable, then check out HOWTOs and some of the pointers here. This last is more targeted to your customers, but might be useful (and knowing you've thought about security will perhaps cut down on circumventing attempts).

You can even install XDebug on the secondary PHP, and use e.g. PHPUnit's code coverage tools to produce useful information.

Now you can deploy your code by writing to /var/www-secure/website-user123/htdocs from your primary Web install, which can write to /var/www-secure, as well as run restart on the secondary web server via system("sudo...") commands. You can supply "real life" commands to the hosted web app through curl.

Linux allows further strengthening using apparmor/SELinux, or userid-based firewall rules. This means that whatever the hosted app does, it won't be able to communicate outside, receive other commands than yours, or do anything outside the web root -- where you can read anything and check it, e.g. via tripwire.

You might even leave the dangerous function enabled (but blocked by apparmor/iptables) and then inspect the logs to see whether the defenses have been triggered. Not really recommended. You do want to check the logs (and heck, maybe run a tripwire check on the system after running an unknown class), however, in case someone succeeded in overthrowing the first layer of defense in the PHP.INI and got smashed by apparmor.

Chroot jail

This is hanshenrik's answer, and is attractive if you run things through the CLI. Depending on your setup and what the classes need to do, it can be better than both other alternatives (still requires firewalling/apparmor, or at least it could benefit from them), or less powerful.

Virtual machine (more secure)

As above, but this time the "second install" is completely isolated inside a VM. You can probably do this with Docker but it would not be as secure; still, check this out. You send the code inside the VM using FTP (PHP has commands for this). This setup allows better isolation from the main installation. It is less flexible than the other two solutions, as you should really use one VM for each user, and resetting the VM to neutral is more expensive. Running the VM is more expensive. On the other hand, it can be more thorough (i.e. you can redeploy the whole thing more easily) and limit-smashing attacks are impossible, as the rogue class can at most succeed in hogging the virtual CPU.

Answers 2

I think Runkit_Sandbox is the tool for this job - and i don't. assuming you run on a Unix system, may i suggest a chroot jail setup instead?

mkdir /jail chmod 0711 /jail mkdir /jail/etc/ chown -R root:root /jail cp -al /bin /lib /lib64 /usr /jail cp -al /etc/alternatives /jail/etc/ 

(ps, if you can spare the disk space, i guess it's somewhat safer to remove -l from cp, but it will use significantly more disk space (4GB on my system), and is only required if your permissions are seriously messed up and the nobody account actually has write permissions to some of those folders...) and i assume your code is initially received & handled by an unprivileged user, let's call it www-data, if so, you can use sudo to allow www-data to run a specific command with sudo, to do that, add

www-data ALL = (root) NOPASSWD: /usr/bin/php /jail/jailexecutor.php 

to /etc/sudoers

this will allow www-data to run the specific command sudo /usr/bin/php /jail/jailexecutor.php

now for jailexecutor.php, it takes the source code from STDIN, executes it with php chrooted to /jail as user nobody, and echos the STDOUT and STDERR generated by the code back, and terminates it if it runs longer than 5 seconds,

<?php declare(strict_types = 1); const MAX_RUNTIME_SECONDS = 5; if (posix_geteuid () !== 0) {     fprintf ( STDERR, "this script must run as root (only root can chroot)" );     die (); } $code = stream_get_contents ( STDIN ); if (! is_string ( $code )) {     throw new \RuntimeException ( 'failed to read the code from stdin! (stream_get_contents failed)' ); } $file = tempnam ( __DIR__, "unsafe" ); if (! is_string ( $file )) {     throw new \RuntimeException ( 'tempnam failed!' ); } register_shutdown_function ( function () use (&$file) {     if (! unlink ( $file )) {         throw new \RuntimeException ( 'failed to clean up the file! (unlink failed!?)' );     } } ); if (strlen ( $code ) !== file_put_contents ( $file, $code )) {     throw new \RuntimeException ( 'failed to write the code to disk! (out of diskspace?)' ); } if (! chmod ( $file, 0444 )) {     throw new \RuntimeException ( 'failed to chmod!' ); } $starttime = microtime ( true ); $unused = [ ]; $ph = proc_open ( 'chroot --userspec=nobody /jail /usr/bin/php ' . escapeshellarg ( basename ( $file ) ), $unused, $unused ); $terminated = false; while ( ($status = proc_get_status ( $ph )) ['running'] ) {     usleep ( 100 * 1000 ); // 100 ms     if (! $terminated && microtime ( true ) - $starttime > MAX_RUNTIME_SECONDS) {         $terminated = true;         echo 'max runtime reached (' . MAX_RUNTIME_SECONDS . ' seconds), terminating...';         pkilltree ( ( int ) ($status ['pid']) );         // proc_terminate ( $ph, SIGKILL );     } } echo "\nexit status: " . $status ['exitcode']; proc_close ( $ph ); function pkilltree(int $pid) {     system ( "kill -s STOP " . $pid ); // stop it first, so it can't make any more children     $children = shell_exec ( 'pgrep -P ' . $pid );     if (is_string ( $children )) {         $children = trim ( $children );     }     if (! empty ( $children )) {         $children = array_filter ( array_map ( 'trim', explode ( "\n", $children ) ), function ($in) {             return false !== filter_var ( $in, FILTER_VALIDATE_INT ); // shouldn't be necessary, but just to be safe..         } );         foreach ( $children as $child ) {             pkilltree ( ( int ) $child );         }     }     system ( "kill -s KILL " . $pid ); } 

now PHP code can be safely executed from www-data like this:

<?php declare(strict_types = 1); header ( "content-type: text/plain;charset=utf8" ); $unsafeCode = ( string ) ($_POST ['code'] ?? ''); $pipes = [ ]; $proc = proc_open ( "sudo /usr/bin/php /jail/jailexecutor.php", array (         0 => array (                 "pipe",                 "rb"          ),         1 => array (                 "pipe",                 "wb"          ),         2 => array (                 "pipe",                 "wb"          )  ), $pipes ); fwrite ( $pipes [0], $unsafeCode ); fclose ( $pipes [0] ); while ( ($status = proc_get_status ( $proc )) ['running'] ) {     usleep ( 100 * 1000 ); // 100 ms     echo stream_get_contents ( $pipes [2] );     echo stream_get_contents ( $pipes [1] ); } // var_dump($status); echo stream_get_contents ( $pipes [2] ); // just to be safe, it's technically possible that we dont get any cpu time between proc_open, the child finishes, and proc_get_status.. just extremely unlikely. echo stream_get_contents ( $pipes [1] ); proc_close ( $proc ); 

and for a quick test, curl -d code='<?php echo rand()."it works!";' url (you can even add system("rm -rfv --no-preserve-root /"); without any worries)

  • tested on debian 9 stretch

Answers 3

The principal part of the functionality is sandboxes (Runkit_Sandbox class). Using them you can run PHP code in an isolated environment. Each sandbox can be configured with its own PHP security options such as safe_mode, safe_mode_gid, safe_mode_include_dir, open_basedir, allow_url_fopen, disable_functions, disable_classes.

In addition, each sandbox can have individual values for Runkit's INI-settings: own globals and prohibition of overriding built-in functions.

Sandboxes can load PHP files (via include(), include_once(), require(), and require_once()), call inside functions, execute arbitrary PHP code and print contained variables values. Also, you can specify a function to capture and process the output of a sandbox.

Within a sandbox you can create an object of an anti-sandbox class Runkit_Sandbox_Parent which connects a sandbox with its parent environment. Functionality of an anti-sandbox is very similar to the functionality of a sandbox, but for security reasons each type of communications with the outside environment should be explicitly enabled during sandbox creation.

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment