Description
A frog image generator with a bot that visits submitted report URLs. The Traefik reverse proxy version used by the app converts semicolons to ampersands in query strings, letting you replace the URL parameter with a JavaScript payload that steals the flag from the bot's localStorage.
Setup
Read the provided source code to understand the four-container architecture: API, bot, Traefik, and OpenResty.
# Connect to the challenge instance and note the port numberSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand the Traefik semicolon-to-ampersand quirk
ObservationThe source names Traefik as the reverse proxy, and the app takes a user-controlled url parameter for the bot. Traefik's odd semicolon handling in the query string is the way to inject a second url parameter that overrides the first.Traefik versions above 2.7.2 changed how they handle semicolons in query strings: a semicolon is treated as a parameter separator (equivalent to ampersand). So a URL like ?url=X;url=Y becomes ?url=X&url=Y, effectively replacing the first url parameter with the second.Learn more
When you submit a report, the request flows through Traefik to the backend. The backend passes the URL parameter to the bot. By including a semicolon followed by another url= parameter, Traefik splits the query string at the semicolon, and the second url= value overwrites the first.
This lets you replace any URL the backend would send to the bot with a JavaScript URI of your choosing, even though the application intends to validate and restrict which URLs the bot visits.
Step 2Craft a JavaScript payload to exfiltrate the flag
ObservationThe bot keeps the flag in localStorage and will navigate to whatever URL the injection supplies. A javascript: URI runs code in the bot's own browser context, which can POST that stored value to the open reports API.Write JavaScript that reads the flag from localStorage, then calls the API to add a report with the flag as the screenshot value. Use URL encoding carefully: percent-encode the semicolon as %3b, quote characters as needed.js# The payload structure (URL-encoded): # url=http://api/some-path%3burl=javascript:fetch('/api/reports/add',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+localStorage.getItem('flag')},body:JSON.stringify({screenshot:localStorage.getItem('flag')})})bash# Full curl command (replace PORT with your instance port):bashcurl --globoff 'http://saturn.picoctf.net:PORT/api/reports/add?url=http://api/reports/add%3burl=javascript:fetch(%27/api/reports/add%27,{method:%27POST%27,headers:{%27Content-Type%27:%27application/json%27,%27Authorization%27:%27Bearer%20%27+localStorage.getItem(%27flag%27)},body:JSON.stringify({screenshot:localStorage.getItem(%27flag%27)})})'Expected output
[{"screenshot":"picoCTF{fr33_50ftw4r3_fr33_fr0gs_...}", ...}]What didn't work first
Tried: Paste the URL into the shell with a bare, unquoted semicolon instead of encoding it or quoting the argument.
An unquoted semicolon is a shell command separator, so bash cuts the line in half: curl receives only the part before the semicolon, and the rest runs as its own (nonsense) command. Nothing about Traefik is involved in that failure. Either single-quote the whole URL, as the command above does, or percent-encode the semicolon as %3b (percent-encoding is case-insensitive, so %3B works identically). Confirm the split landed by checking which URL the bot actually visits.
Tried: Use a data: URI instead of a javascript: URI to run the payload, expecting the bot's browser to execute inline HTML with a script tag.
Modern Chromium headless browsers block navigation to data: URIs from non-file origins as a phishing mitigation, so the bot refuses to load one and no code runs. The javascript: scheme sidesteps that by executing in the current page's context rather than navigating to a new origin, which is why it is the right vehicle.
Learn more
The
--globoffflag in curl turns off URL globbing, which otherwise treats{}as a set expression and[]as a range. Without it, curl tries to expand{screenshot:...as a glob and errors out instead of sending the URL.The bot visits the JavaScript URI, executes the code in its browser context, reads
localStorage.getItem('flag')(which the bot loaded from the API), then POSTs a new report entry with the flag as the screenshot value. The Authorization header uses the flag value itself as the bearer token because that is how the bot is authenticated.Step 3Retrieve the report containing the flag
ObservationThe reports endpoint performs no authentication check on GET. Once the bot has POSTed the flag as a screenshot value, fetching the reports list reads it straight back.After the bot processes the report, fetch the reports list from the API. The most recent report should contain the flag as the screenshot value.bashcurl 'http://saturn.picoctf.net:PORT/api/reports'What didn't work first
Tried: Check localStorage in the browser DevTools directly by opening the app and inspecting storage, expecting to see the flag there.
Only the bot's session holds the flag, because it authenticates with the challenge API and stores what comes back. Your own session never receives the token, so your DevTools storage shows nothing. The whole point of the exfiltration chain is making the bot read its own localStorage and send the value somewhere you can reach.
Learn more
The reports endpoint has no authentication check, so any user can read it. The bot's POST added a report whose screenshot field contains the flag. Parse the JSON response and look for the picoCTF string.
Interactive tools
- URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
- Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
Flag
Reveal flag
picoCTF{fr33_50ftw4r3_fr33_fr0gs_...}
The Traefik semicolon quirk lets you inject a second url= parameter that replaces the intended destination with a JavaScript URI. The bot executes it and leaks the flag via the open reports API.