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 1
Understand the Traefik semicolon-to-ampersand quirkObservationI noticed the source code listed Traefik as the reverse proxy and the app accepted a user-controlled url parameter for the bot, which suggested researching Traefik's quirky semicolon handling as the mechanism 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 2
Craft a JavaScript payload to exfiltrate the flagObservationI noticed the bot stored the flag in localStorage and would navigate to any URL passed through the semicolon injection, which suggested using a javascript: URI to run code in the bot's browser context and POST the localStorage flag 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: URL-encode the semicolon as %3B (uppercase) instead of %3b and find the injection does not trigger.
Some servers and proxies treat percent-encoding as case-insensitive, but Traefik's query-string parser in the affected versions only recognizes the literal semicolon character or its lowercase encoding in certain configurations. Testing with %3B may produce a 400 or simply pass the raw value through without splitting, so the second url= never appears. Use the literal semicolon or confirmed lowercase %3b and verify the split is happening by checking what 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-based headless browsers block navigation to data: URIs from non-file origins as a phishing mitigation. The bot will refuse to load the data: URL and no code executes. The javascript: scheme bypasses this restriction because it runs in the current page's context rather than navigating to a new origin, which is exactly why it is the correct vehicle here.
Learn more
The
--globoffflag in curl prevents curl from interpreting curly braces as a range expression. Without it, curl tries to expand{screenshot:...as a glob pattern and fails.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 3
Retrieve the report containing the flagObservationI noticed the reports endpoint had no authentication check on GET requests, which suggested that after the bot POSTed the flag as a screenshot value I could simply fetch the reports list to read the exfiltrated flag.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 browser session holds the flag in localStorage because it authenticates with the challenge API and stores the returned flag. Your own browser session never receives the flag token, so inspecting your local DevTools storage shows nothing useful. The entire point of the XSS exfiltration chain is to make the bot read its own localStorage and send the value somewhere you can access.
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
- 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.