Metasploit CTF 2021 Challenge Writeups
Writeups and solutions for nearly all of the 2021 Metasploit Capture the Flag.
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.
- Four of Hearts (Port 80)
- Two of Spades (Port 443 - Solved by m0zz4)
- Nine of Diamonds (Port 8080 - Solved by m0zz4)
- Five of Diamonds (Port 11111)
- Ten of Clubs (Port 12380)
- Five of Clubs (Port 15000)
- Two of Clubs (Port 20000, 20001)
- Black Joker (Port 20000, 20001)
- Ace of Hearts (Port 20011)
- Eight of Clubs (Port 20123 - Solved by SigmaMucker)
- Three of Hearts (Port 33337)
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:
PORT STATE SERVICE
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
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 127.0.0.1:443 would go through the SSH session on the jump box, to the target, then return back to our 127.0.0.1:443 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 (172.17.4.213). Finally, we provide the username and IP of the jump box. (kali@3.80.227.231).
#!/bin/bash
#Run with sudo ./brumbrum.sh
#VRRRRRRoooooooooooMMMMMMM
ssh -i metasploit_ctf_kali_ssh_key.pem \
-L 80:172.17.4.213:80 \
-L 443:172.17.4.213:443 \
-L 8080:172.17.4.213:8080 \
-L 10010:172.17.4.213:10010 \
-L 11111:172.17.4.213:11111 \
-L 12380:172.17.4.213:12380 \
-L 15000:172.17.4.213:15000 \
-L 15010:172.17.4.213:15010 \
-L 15122:172.17.4.213:15122 \
-L 20000:172.17.4.213:20000 \
-L 20001:172.17.4.213:20001 \
-L 20022:172.17.4.213:20022 \
-L 20055:172.17.4.213:20055 \
-L 20123:172.17.4.213:20123 \
-L 30033:172.17.4.213:30033 \
-L 30034:172.17.4.213:30034 \
-L 33337:172.17.4.213:33337 \
-L 35000:172.17.4.213:35000 \
kali@3.80.227.231
After running this, all challenges are available at 127.0.0.1:<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.
Challenges
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.
![](https://heartburn.dev/content/images/2021/12/image-3.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-12.png)
Trying to access it gives a 403.
![](https://heartburn.dev/content/images/2021/12/image-13.png)
However, we can use a tool such as git-dumper to dump the repository.
![](https://heartburn.dev/content/images/2021/12/image-14.png)
Running git log --stat
shows that there was an .env
file that was deleted.
![](https://heartburn.dev/content/images/2021/12/image-15.png)
With the name of the commit, we can revert the folder to the status before the deletion.
![](https://heartburn.dev/content/images/2021/12/image-16.png)
From here, the .env
file is in the current working directory.
![](https://heartburn.dev/content/images/2021/12/image-17.png)
It is then trivial to go to the specific location and get the flag.
![](https://heartburn.dev/content/images/2021/12/image-18.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-8.png)
After creating an account, a cookie is created made-an-account: true
.
![](https://heartburn.dev/content/images/2021/12/image-9.png)
Then going to the sign-in page, the cookie persists. After signing in, two new cookies are generated: authenticated-user: true
and admin: false
.
![](https://heartburn.dev/content/images/2021/12/image-10.png)
Changing the admin cookie from false, to true, allows you to enter the /admin endpoint and obtain the flag!
![](https://heartburn.dev/content/images/2021/12/image-11.png)
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--
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--
![](https://heartburn.dev/content/images/2021/12/image-6.png)
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.
![](https://www.exploit-db.com/images/spider-orange.png)
Running the script shows that we're www-data.
![](https://heartburn.dev/content/images/2021/12/image-4.png)
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.
./PoC.sh targets.txt /bin/sh 'cat /secret/safe/flag.png | base64' | xclip
xclip -o > flag
cat flag | base64 -d > xflag.png
Voila, a flag! Eyes on the headers boys, little details matter.
![](https://heartburn.dev/content/images/2021/12/image-5.png)
Five of Clubs (Port 15000)
We connect with nc 172.17.4.213 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.
![](https://heartburn.dev/content/images/2021/12/image-20.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-21.png)
Note: Everytime I enter a special character, the program loops back to ask me the input again.
![](https://heartburn.dev/content/images/2021/12/image-22.png)
However, the one place that this does not happen is if you enter it in the second name part of the deletion function.
![](https://heartburn.dev/content/images/2021/12/image-23.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-24.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-25.png)
With access to the target, I just went through and got the md5sum.
![](https://heartburn.dev/content/images/2021/12/image-26.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-33.png)
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')
exit(0)
def printer(packet):
if packet.haslayer(Raw):
print(packet.getlayer(Raw).load)
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 127.0.0.1 and port 20001", prn=printer, iface="lo")
pass
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('127.0.0.1',20001)
#Start the game
p.sendline(f'{{"StartGame":{{"game_mode":"Easy"}}}}'.encode('utf-8'))
#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
p.sendline(f'{{"ClientClick":{{"x":{x},"y":{y}}}}}'.encode('utf-8'))
p.sendline(f'{{"ClientHeartBeat":{{}}}}'.encode('utf-8'))
elif clientClick in targ:
continue
elif targHit in targ:
continue
elif clientBeat in targ:
continue
#End on GameEnded string
elif GameEnded in targ:
break
![](https://heartburn.dev/content/images/2021/12/image-34.png)
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'{"StartGame":{"game_mode":"HardPractice"}}\n'
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
//b'{"ClientHeartBeat":{}}\n'
b'\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x01\x00\x00\x00\x01'
//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
//b'{"TargetCreated":{"target_id":0,"x":?,"y":?}}\n'
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'
id = 0
x = 408
y = 282
//Target 1 Created
//b'{"TargetCreated":{"target_id":1,"x":?,"y":?}}\n'
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\x01\x00\x00\x00\x0c\x00\x02OM\x00\x00\x02\xe1\x00\x00\x00\x0c\x00\x02ON\x00\x00\x00\xa8'
id = 1
x = 737
y = 168
//Target 2 Created
//b'{"TargetCreated":{"target_id":2,"x":?,"y":?}}\n'
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\x02\x00\x00\x00\x0c\x00\x02OM\x00\x00\x01A\x00\x00\x00\x0c\x00\x02ON\x00\x00\x01\xe1'
//Target 3 Created
//b'{"TargetCreated":{"target_id":3,"x":?,"y":?}}\n'
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\x03\x00\x00\x00\x0c\x00\x02OM\x00\x00\x01_\x00\x00\x00\x0c\x00\x02ON\x00\x00\x00$'
//Client Click
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\x01z\x00\x00\x00\x0c\x00\x02N\xe9\x00\x00\x01\x9d'
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\x01z\x00\x00\x00\x0c\x00\x02N\xe9\x00\x00\x01\x9c'
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\x01*\x00\x00\x00\x0c\x00\x02N\xe9\x00\x00\x01\xa7'
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'
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('127.0.0.1',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
p.send(startHard)
#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]
print(x)
y = targ[52:56]
print(y)
#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
p.send(payload)
p.send(clientBeat)
elif clientClick == const:
quantity = clientClickLength - len(const)
read_more = p.recv(quantity)
continue
elif clientBeat == const:
quantity = clientBeatLength - len(const)
read_more = p.recv(quantity)
continue
elif startHard in targ:
print("Game Start String Detected")
#End on GameEnded string
elif GameEnded in targ:
break
![](https://heartburn.dev/content/images/2021/12/image-35.png)
You can just about see the game finished string and flag URL in the output above!
![](https://heartburn.dev/content/images/2021/12/image-36.png)
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:
http://127.0.0.1:20011/gallery?galleryUrl=test
This is likely an SSRF vulnerability. First, access http://127.0.0.1:20011/gallery?galleryUrl=http://127.0.0.1:20011/admin. 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.
![](https://heartburn.dev/content/images/2021/12/image-7.png)
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
UNKNOWN_ERROR = 1001
def get_salt(seed=1337): # Need a seed so the salt stays the same
try:
generator = random.Random(seed)
if DEBUG:
print(generator.getstate())
return generator.randbytes(32)
except:
return UNKNOWN_ERROR
def get_token():
try:
generator = random.SystemRandom()
if DEBUG:
print(generator.getstate())
return generator.randbytes(32)
except:
return UNKNOWN_ERROR
def encrypt_flag(file):
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=get_salt(),
iterations=100000,
)
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('input_file')
parser.add_argument('output_file')
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:
f.write(encrypted_file)
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():
try:
generator = random.SystemRandom()
if DEBUG:
print(generator.getstate())
return generator.randbytes(32)
except:
return UNKNOWN_ERROR
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 encrypt_flag.py encrypted_flag 8_of_clubs.png --debug
![](https://heartburn.dev/content/images/2021/12/image-19.png)
Three of Hearts (Port 33337)
This challenge required that I add threeofhearts.ctf.net
to my /etc/hosts
folder pointing to 127.0.0.1, as that's where our local machine would see the challenge. Then the challenge page was available at threeofhearts.ctf.net:33337
.
![](https://heartburn.dev/content/images/2021/12/image-27.png)
Googling ATS 7.1.1, which is in the response headers, will reveal a few blog posts on HTTP smuggling and CVE-2018-8004.
https://medium.com/@knownsec404team/protocol-layer-attack-http-request-smuggling-cc654535b6f
![](https://heartburn.dev/content/images/2021/12/image-28.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-29.png)
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.
![](https://heartburn.dev/content/images/2021/12/image-31.png)
After performing the above and checking the logs, there's a new entry.
![](https://heartburn.dev/content/images/2021/12/image-30.png)
Swapping the cookies over and bobs your uncle, a flag!
![](https://heartburn.dev/content/images/2021/12/image-32.png)
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.