Metasploit CTF 2021 Challenge Writeups

Writeups and solutions for nearly all of the 2021 Metasploit Capture the Flag.

Metasploit CTF 2021 Challenge Writeups

Hello to all my wonderful friends from lands afar. Today I'll be demonstrating the solves that I managed during the recent Metasploit Capture the Flag 2021 in association with Rapid7. It was, as per usual, an absolute blast. This year's challenges felt slightly harder than last year, but they made some fantastic changes to improve the competition. I'll go over the format, how you could easily attack the target via your Kali jump box and then delve into the solves.

If you're just interested in the flags, here is the hyperlinks specific solves.

Looking for a challenge that isn't listed here? My fantastic teammates have been posting their writeups too! Check out the links below.

  • Four of Diamonds (Port 10010) - Taspresso's Writeup (TBC)
  • Four of Clubs (Port 15010) - techn0vert's Writeup
  • Jack of Hearts (Port 20022) - Taspresso's Writeup (TBC)
  • Nine of Spades (Port 20055) - techn0vert's Writeup

Thanks to TheChosenNum, Philevs, techn0vert, Ultirian, Taspresso, m0zz4 and SigmaMucker for teaming up, and of course, a shoutout to my dear friend rushi for his effervescent advice.

Competition Format, Port Forwarding and Access

As per usual with Metasploit CTF, there was no team size limit which is great for people playing casually with friends. I teamed up with some people from my old university and some from my current one. It was awesome playing with new people and getting fresh ideas. Teamwork makes the dream work, right?!

There was a total of 18 flags with an equal point value (100) and the competition lasted 4 days, Friday 5 pm - Monday 5 pm UK time. I believe 4 teams managed all of the flags and obtained the full 1800 points, whilst we managed 15 / 18, finishing in 15th place.

The format for this capture the flag is a little different: You are given a "Jump Box" which takes the form of a Kali machine with lots of functionality loaded onto it. This sits on the internal network, which can then be used to reach the "target machine". I saw lots of chat in the Slack channel with people asking how they were meant to get to a GUI through the jump box, so I thought I'd give a quick overview of that. When you're given SSH access to a target on an internal network, it's possible to use a technique called port forwarding to get access to the target machine on your personal machine. In essence, we're using the jump box as a route to the target for our traffic. SSH comes pre-built with a local port forwarding switch, which I'll demonstrate below. Before doing this, we need to know what ports we need to forward. On the Kali jump box, as I'm sure many people did, we'd perform a Nmap scan of the target machine. It returned the following ports:

80/tcp    open  http
443/tcp   open  https
8080/tcp  open  http-proxy
10010/tcp open  rxapi
11111/tcp open  vce
12380/tcp open  unknown
15000/tcp open  hydap
15010/tcp open  unknown
15122/tcp open  unknown
20000/tcp open  dnp
20001/tcp open  microsan
20011/tcp open  unknown
20022/tcp open  unknown
20055/tcp open  unknown
20123/tcp open  unknown
30033/tcp open  unknown
30034/tcp open  unknown
33337/tcp open  unknown
35000/tcp open  heathview
Open Ports on the Target

Now that we knew what ports to forward, we could use SSH to "tunnel" through the jump box to the target. Specifying the -L flag is to locally port forward, meaning those ports would now be accessible on our machine at localhost. Thus, to access challenge 443 on our machines browser, the traffic we request at would go through the SSH session on the jump box, to the target, then return back to our browser window. The script to set up the port forward is below, where we specify the SSH key provided by Metasploit, the ports to forward locally and the IP:Port of the target ( Finally, we provide the username and IP of the jump box. (kali@


#Run with sudo ./
ssh -i metasploit_ctf_kali_ssh_key.pem \
    -L 80: \
    -L 443: \
    -L 8080: \
    -L 10010: \
    -L 11111: \
    -L 12380: \
    -L 15000: \
    -L 15010: \
    -L 15122: \
    -L 20000: \
    -L 20001: \
    -L 20022: \
    -L 20055: \
    -L 20123: \
    -L 30033: \
    -L 30034: \
    -L 33337: \
    -L 35000: \
Port Forwarding Script

After running this, all challenges are available at<port>. I think it was a great idea that this year they attempted to order the flags in terms of difficulty incrementing by port number!

I hope this helps anyone who was struggling to get the forwarding working.


Four of Hearts (Port 80)

The four of hearts was just designed to be the first introductory flag. It was just sat in the browser at port 80.

Four of Hearts

Two of Spades (Port 443)

Note: This writeup is directly from the main man himself, m0zz4.

The initial Nmap scan reveals a .git repository on the web server.

Nmap Scan Output

Trying to access it gives a 403.

403 Forbidden

However, we can use a tool such as git-dumper to dump the repository.

Dumping with git-dumper

Running git log --stat shows that there was an .env file that was deleted.

Change History in Git

With the name of the commit, we can revert the folder to the status before the deletion.

Using an Old Commit

From here, the .env file is in the current working directory.

.env File Contents

It is then trivial to go to the specific location and get the flag.

Two of Spades

Nine of Diamonds (Port 8080)

Note: This writeup is directly from the main man himself, m0zz4.

Initial website was obsessed with cookies, and had sign up, sign in and admin only links.

Landing Page

After creating an account, a cookie is created made-an-account: true.

Creating an Account

Then going to the sign-in page, the cookie persists. After signing in, two new cookies are generated: authenticated-user: true and admin: false.

Signed In Cookies

Changing the admin cookie from false, to true, allows you to enter the /admin endpoint and obtain the flag!

Nine of Diamonds

Five of Diamonds (Port 11111)

A simple SQL injection challenge. We're provided with a login page that can be bypassed with the classic true evaluation.

' or 1=1--
Simple Bypass

Nothing much happens, however, which suggests the required user is not the first one in the database (This is what will get logged in as using the previous bypass). However, trying to login with the following username and password takes us to a page with the admin panel, and thus, the flag.

username -> admin
password -> ' or 1=1-- 
Logging in as Admin
Five of Diamonds Flag

Ten of Clubs (Port 12380)

The page is just a blank page without much interaction possible, but looking at the headers reveals that the Apache server being used is 2.4.49. Performing a quick Google search results in the following exploit being discovered.

Apache HTTP Server 2.4.49 - Path Traversal & Remote Code Execution (RCE)
Apache HTTP Server 2.4.49 - Path Traversal & Remote Code Execution (RCE). CVE-2021-41773 . webapps exploit for Multiple platform

Running the script shows that we're www-data.

PoC Output

Exploring the file system, there's a flag.png file at /secret/safe/flag.png. I'll use the RCE to cat the flag and base64 encode it, to ensure it doesn't get damaged whilst being copied to my host, then pipe it to xclip.

./ targets.txt /bin/sh 'cat /secret/safe/flag.png | base64' | xclip

xclip -o > flag

cat flag | base64 -d > xflag.png 
Clean Transfer of the Flag

Voila, a flag! Eyes on the headers boys, little details matter.

Ten of Clubs

Five of Clubs (Port 15000)

We connect with nc 15000 and see that there is an option to create, view, update or delete school records.

If we create a record, it then gets stored on the server and is available for viewing later. An example is given below where I create a record and then use the view function to see what the record looks like.

Program Running - Creating a Record and Searching For It

It appears to create a file that concatenates the first and last name with a _ and appends .txt.

Looking at option 4, where we can delete a student. it's possible to enter the first name and last name and see that the record then gets removed.

Deleting a Student

Note: Everytime I enter a special character, the program loops back to ask me the input again.

Invalid Special Characters

However, the one place that this does not happen is if you enter it in the second name part of the deletion function.

Program Continues with Special Characters

It attempts to delete a file that does not exist, and thus, errors out. So what I'll be thinking in my mind is: What's happening here? What's it trying to do?

  • It uses the first name / last name to check if a file exists related to that user.
  • If it finds a file, it then tries to run a del command on the related file.
  • If there's extra after the last name, it fails to run the command.

Straight away, I'm thinking command injection, because if we can force it to end that command and start an arbitrary one, we have code execution. But just closing out with a ; causes a failure as it appends .txt to the end of the command.

Attempt #1 at Command Injection

However, if we close out, place the command we want to run, then give it a filename after to append .txt to, it will successfully run. I've put an example here of getting a reverse shell. It runs the delete and then hangs, while the connection to the shell is active.

Successful Shell

With access to the target, I just went through and got the md5sum.

Five of Clubs

Two of Clubs (Port 20000, 20001)

I realised very quickly when just provided with a downloadable file that the program clickracer was the one running on port 20001, as when we ran it, it popped up the following GUI.

Clickracer GUI

The game was to click the red bubbles as they appeared, but obviously when doing the "Easy Challenge" they appeared faster than a normal human could click! I first tried to write a scapy program to sniff the traffic and see what was being sent.

#!/usr/bin/env python3 

from signal import signal, SIGINT
from sys import exit
from scapy.all import *
from scapy.layers.inet import IP, TCP
import random
import itertools

def handler(signal_received, frame):
    # Handle any cleanup here
    print('SIGINT or CTRL-C detected. Exiting gracefully')

def printer(packet):
    if packet.haslayer(Raw):

if __name__ == '__main__':
    #Tell Python to run the handler() function when SIGINT is recieved
    signal(SIGINT, handler)

    print('Running. Press CTRL-C to exit.')
    print("[+] Sniff started")
    while True:
        sniff(store=0, filter="host and port 20001", prn=printer, iface="lo")
Scapy Sniffer

This allowed me to see the data that was being sent. It was simply receiving a "StartGame" cue, and then sending targets and registering and clicks or hits.

I used pwntools to pwn the easy one, with a horrific script after seeing the cleanliness of other peoples!

from pwn import *

p = remote('',20001)

#Start the game

#Possible Responses
targCreated = b'TargetCreated'
targHit = b'TargetHit'
clientBeat = b'ClientHeartBeat'
clientClick = b'ClientClick'
GameEnded = b'GameEnded'
GameStart = b'StartGame'

#receive the message
while True:
    #receive the line
    targ = p.recvuntil(b'\n')

    #If we're given a target, send a shot to the correct x,y
    if targCreated in targ:
        #extract x
        x1 = targ.split(b'"x":')
        x2 = x1[1].split(b',"y":')
        x = int(x2[0])
        #extract y
        y1 = targ.split(b'"y":')
        y2 = y1[1].split(b'}')
        y = int(y2[0])
        #hit the target and send a heartbeat

    elif clientClick in targ:

    elif targHit in targ:

    elif clientBeat in targ:

    #End on GameEnded string
    elif GameEnded in targ:
Easy Challenge Solve Script
Two of Clubs

Black Joker (Port 20000, 20001)

The Black Joker was actually on the same port, but required the completion of the "Hard Challenge". This seemed to be the same as the easy challenge, but the dots in the GUI were invisible and the reading I got from scapy was all in some sort of raw byte format. I later was told it was some sort of TLV protocol. However, all it took was enough samples and I could analyse what message being sent meant what, by spamming clicks, restarting the game loads, waiting for client heartbeats and making assumptions at 4am! I came up with this list of data that I correlated to mean certain things.

//Start Game
b'\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x03\x00\x00\x00\x0c\x00\x02N\x84\x00\x00\x00\x04

//Send heartbeat
//3 bytes up from \x020L is the target ID
//x = Two bytes up from \x02OM
//y = Two bytes at the end, 2 bytes up from \x020N

//Target 0 Created

id = 0
x = 408
y = 282

//Target 1 Created

id = 1
x = 737
y = 168

//Target 2 Created

//Target 3 Created

//Client Click
Received Raw Data

Armed with this, I set to work trying to weaponize it. Eventually, I got the dirtiest solve script together. Why didn't I think of converting it to a much nicer format? Who knows. Maybe the time, maybe I just suck at programming. Either way, here's the finished script and the flag. This challenge gave me so much satisfaction to finish!

from pwn import *

p = remote('',20001)

Possible Responses
Byte Constants 
Took these from just running the program and working out what is meant to be
printed and where, then extracted which pieces of information changes each time
to work out which byte refers to ID, x and y.
With the previous task, I could receive lines and swap over x and y because
of the fact every line terminated with a \n, which made it far easier.
Here, I instead took the theory that the first 4 bytes of each "response" from
the server are constants and read in the first 4 bytes to identify what type of response it was.
Then I'd take the length of that response, plus the 4 already read in, and read the rest, 
before starting over.
Extract the x and y from the known TargetCreated byte array at offsets [40:44] and [52:56]
Put together a custom clientClick payload using these (Who knew bytes were immutable)
Send it, send a heartbeat to keep the server happy, then repeat!
Game ended string was also constant so I could just check to see if there had been that message.
There is surely an easier way to do this!!!

#startHardPractice = b'\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x03\x00\x00\x00\x0c\x00\x02N\x84\x00\x00\x00\x04'
startHard = b'\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x03\x00\x00\x00\x0c\x00\x02N\x84\x00\x00\x00\x03'

#Client Heartbeat Constant
clientBeat = b'\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x01'
clientBeatLength = len(clientBeat)

#TargetCreated constant first 4 bytes
targCreated = b'\x00\x00\x008'
#Example full targetCreated stream below to get the constant length
targCreatedLength = len(b'\x00\x00\x008\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\t\x00\x00\x00\x0c\x00\x02OL\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02OM\x00\x00\x01\x98\x00\x00\x00\x0c\x00\x02ON\x00\x00\x01\x1a')

#ClientClick constant first 4 bytes
clientClick = b'\x00\x00\x00,'
#Example full ClientClick stream below to get the constant length
clientClickLength = len(b'\x00\x00\x00,\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x07\x00\x00\x00\x0c\x00\x02N\xe8\x00\x00\x02w\x00\x00\x00\x0c\x00\x02N\xe9\x00\x00\x01X')

#GameEnded constant first 4 bytes
GameEnded = b'\x00\x00\x00I'

#Start the Game

#receive the message
while True:
    #Receive the first 4 bytes and categorize command
    const = p.recv(4)
    #If we're given a target, send a shot to the correct x,y
    if targCreated == const:
        quantity = targCreatedLength - len(const)
        read_more = p.recv(quantity) 
        targ = const + read_more
        x = targ[40:44]
        y = targ[52:56]
        #Hit the target and send a heartbeat to keep server alive
        payload = b''
        payload += b'\x00\x00\x00,\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x07\x00\x00\x00\x0c\x00\x02N\xe8'
        payload += x
        payload += b'\x00\x00\x00\x0c\x00\x02N\xe9'
        payload += y

    elif clientClick == const:
        quantity = clientClickLength - len(const)
        read_more = p.recv(quantity) 

    elif clientBeat == const:
        quantity = clientBeatLength - len(const)
        read_more = p.recv(quantity) 

    elif startHard in targ:
        print("Game Start String Detected")

    #End on GameEnded string
    elif GameEnded in targ:
Hard Challenge Solve
Flag URL

You can just about see the game finished string and flag URL in the output above!

Black Joker

Ace of Hearts (Port 20011)

The homepage is a gallery with 4 users galleries available. Each contains random photos. Johns is "private", how suspicious 👀!

There is also a link to /admin in the top corner, but obviously, we can't access it.
There's a box to request galleries that have not been added yet. Entering anything takes you to:

This is likely an SSRF vulnerability. First, access Then, looking at the source, we can tick or untick users to allow their galleries to be private / unprivate. Simply untick John and then enter his gallery to get that oh so b-e-a-u-t-i-f-u-l Ace of Hearts.

Ace of Hearts

Eight of Clubs (Port 20123)

Note: This challenge was solved by the maths king SigmaMucker.

We are given an SSH login with root:root and when logging in there is a Crypto challenge. The python script can be seen below.

import argparse
import random
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
DEBUG = False

def get_salt(seed=1337):  # Need a seed so the salt stays the same
        generator = random.Random(seed)
        if DEBUG:
        return generator.randbytes(32)
        return UNKNOWN_ERROR

def get_token():
        generator = random.SystemRandom()
        if DEBUG:
        return generator.randbytes(32)
        return UNKNOWN_ERROR

def encrypt_flag(file):
    kdf = PBKDF2HMAC(

    key = base64.urlsafe_b64encode(kdf.derive(bytes(get_token())))
    # Fernet uses the time and an IV so it never produces the same output twice even with the same key and data
    fernet = Fernet(key)
    return fernet.encrypt(file)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Encrypt a file and save the output')

    parser.add_argument('--debug', action="store_true")
    args = parser.parse_args()
    if args.debug:
        DEBUG = True

    with open(args.input_file, "rb") as f:
        encrypted_file = encrypt_flag(

    with open(args.output_file, "wb") as f:
Python Challenge

There is also an encrypted_flag file which was obviously the output of this script running. Looking in the ~/.ash_history, when the script ran, it was run with the --debug flag. The trick here was to run the program a few times and observe that the key remained constant by adding a few debug print statements. But why?

def get_token():
        generator = random.SystemRandom()
        if DEBUG:
        return generator.randbytes(32)
        return UNKNOWN_ERROR
Vulnerable Code

When this token gets generated, the generator is using random.SystemRandom(), which we notice uses the underlying os.urandom() implementation. However, the documentation reads that using getstate() will raise a NotImplementedErro. Since the program was run with the debug flag, then it tries to print the generator.getstate() command, resulting in an error and thus, the program returns an UNKNOWN_ERROR of 1001 each time. Thus, simply changing fernet.encrypt(file) to fernet.decrypt(file) and passing the encrypted_flag as an input with the --debug flag was enough to decrypt it and output a valid flag.

python encrypted_flag 8_of_clubs.png --debug
Decrypt Command
Eight of Clubs

Three of Hearts (Port 33337)

This challenge required that I add to my /etc/hosts folder pointing to, as that's where our local machine would see the challenge. Then the challenge page was available at

Challenge Homepage

Googling ATS 7.1.1, which is in the response headers, will reveal a few blog posts on HTTP smuggling and CVE-2018-8004.

Discovering ATS 7.1.1 Server

From there I spent AGES trying to get a valid smuggled request. I believe it's a CL-TE vulnerability whereby the proxy uses the Transfer-Encoding header instead of using the `Content-Length` header to determine where the request ends, thus allowing a second request to be "smuggled" afterwards.

Successful Smuggle

As can be seen above, the request is made to the homepage but the response comes from private.php, suggesting the second request was indeed used. This is because the ATS is using Content-Length and therefore sends the full request through, but when it arrives at the backend of Nginx, it tries to use Transfer-Encoding and assumes the first request was not complete. Therefore, it will wait, and when an admin visits, their request will be joined onto the incomplete request and be processed as one. I initially thought it would allow access to the private.php page which is listed on the homepage, but it kept returning access denied. Then I noticed that whenever I sent data to save.php, it included my PHPSESSID and an X-Access header. Thus, if an incomplete request was made and an admin then visited and was coerced with the save.php incomplete request, their PHPSESSID would be in the log file.

Smuggling a Request

After performing the above and checking the logs, there's a new entry.


Swapping the cookies over and bobs your uncle, a flag!

Three of Hearts

Closing Thoughts

Yet again, Metasploit have provided a super high quality and enjoyable CTF that I learnt lots from. Hopefully, next year we'll be challenging even harder for the top spots! 'Till next time folks.