Challenge: ICEMAN · CTF: DalCTF 2026 · Category: Web · Difficulty: Hard
Drake’s label OVO is days away from dropping ICEMAN — his most guarded project yet. They run a private API for members on the early-access list. You managed to snag a fan account. The vault is locked down tight… or is it?
Flag: dalctf2026{open-ticket-send-me-ur-fav-song-in-album6}
TL;DR
The challenge is a GraphQL API guarding an unreleased album. Getting the flag is a two-bug chain:
- Broken authentication — the JWT is signed with HS256 using a laughably weak secret (
iceman, the challenge name). Crack it, forge a token, and bump yourtierfromfantoovoto get vault access. - Broken object-level authorization — even with vault access, the album-lookup resolvers only ever return released albums. The unreleased album is hidden from
album(id)andreleasedAlbums, but it’s still reachable through a different resolver path (label → artists → albums) where the access check was never applied. Walk the graph the “side way” and the secret field falls out.
Let’s walk through how to get there from zero.
1. Recon — what are we even talking to?
We’re handed a single endpoint:
| |
The /graphql path is a dead giveaway. First, a sanity ping to confirm it’s alive and speaks GraphQL:
| |
| |
It answers, and it’s served by gunicorn (a Python WSGI server — so probably Flask/Graphene behind it). A working GraphQL endpoint that responds to __typename is begging for the first thing every GraphQL hunter tries: introspection.
Pulling the schema
Introspection is GraphQL’s self-documentation feature. When it’s left enabled (it very often is), the server will happily hand you the entire schema — every type, field, and argument. No guessing endpoints, no wordlists. The map is just given to you.
| |
After cleaning it up, the schema looks like this:
| |
A few things jump out immediately:
Album.vaultManifest— a string field on an album, sitting right next to astatusfield. In a challenge about a locked vault, a field literally namedvaultManifestis where the flag lives. This is our objective.User.tier— there’s a tier/role system. We were given a “fan” account; presumably the vault needs something better.- There are two ways to reach an
Album: directly viaalbum(id)/releasedAlbums, and indirectly vialabel → artists → albums. Whenever a piece of data is reachable through more than one resolver path, that’s a classic spot for inconsistent authorization. Keep this in your back pocket.
2. Getting a foothold — register and inspect the token
The register mutation hands back an auth token, so let’s grab one:
| |
| |
That’s a JWT (three base64url chunks separated by dots). Decoding the first two parts:
| |
So the server signs a JWT with HS256 (symmetric — same secret signs and verifies) and stuffs our tier right into it. We’re a fan. The role decision is being made client-side-ish: whatever tier lives in the token is trusted by the server. If we can change that value and re-sign, we control our own privilege level.
Confirming the wall
Let’s try the vault with our fan token (sent as Authorization: Bearer <token>):
| |
| |
The error spells out the fix for us: we need OVO membership, not a fan account. The tier we want is almost certainly ovo. Now we just need to forge a token that says so — which means we need the signing secret.
3. Breaking the JWT — cracking a weak HS256 secret
HS256 tokens are only as strong as their secret. If the secret is a dictionary word or anything guessable, the whole signature scheme collapses — we can mint tokens that say whatever we want and the server will trust them.
john can brute-force HS256 secrets directly. Drop the token in a file and throw rockyou at it:
| |
| |
Instant hit. The secret is iceman — the name of the challenge (and the album). A reminder that secrets derived from the project name are no secret at all.
Forging an OVO token
With the secret known, signing our own token is trivial. Keep the payload, swap tier to ovo, and HMAC it:
| |
Out comes a freshly signed tier:"ovo" JWT. Let’s spend it:
| |
| |
The membership wall is gone — me.tier is now ovo and the vault queries run. But the prize isn’t here. Both released albums have vaultManifest: null. So privilege escalation alone wasn’t the whole challenge; it was just the price of admission.
4. The real bug — broken object-level authorization
The unreleased ICEMAN album clearly exists somewhere. The naming convention even hints the released albums are IDs 1 and 2, so let’s probe higher IDs directly:
| |
| |
Dead end. The album(id) resolver only ever returns released albums — anything unreleased comes back null, regardless of the ID. Same story with releasedAlbums (the name says it all). The developers correctly filtered the album lookup resolvers to hide unreleased projects.
But remember the schema had two paths to an Album. We’ve only tried the direct one. The other path runs through label:
| |
This is the same Album object, but reached by traversing the object graph from a Label instead of asking for it by ID. If the access-control filter lives on the album-lookup resolvers and not on the Artist.albums relationship, the unreleased album leaks straight through the side door. This is Broken Object Level Authorization (BOLA / IDOR) — the access check is bound to one entry point instead of to the data itself.
We just need a label name. OVO is Drake’s real-world label, so:
| |
| |
There it is. Album id 9, status UNRELEASED, title ICEMAN — the exact record that album(id:"9") insisted was null — shows up in full through the label traversal, vaultManifest and all.
Flag: dalctf2026{open-ticket-send-me-ur-fav-song-in-album6}
5. Why it worked — root cause
Two independent failures chained together:
| # | Bug | Root cause | Fix |
|---|---|---|---|
| 1 | Weak JWT secret | HS256 signed with iceman, a guessable word tied to the project. Trivially cracked, letting an attacker forge any tier. | Use a long, random, high-entropy secret stored as a managed secret. Better still, don’t put authorization-bearing claims (tier) in a client-held token without server-side validation against the user record. |
| 2 | Inconsistent authorization | The “unreleased albums are hidden” rule was enforced on the album/releasedAlbums resolvers, but the Artist.albums resolver returned everything unfiltered. | Enforce authorization on the data/field level (e.g., a check on Album.vaultManifest or a single source-of-truth filter on every Album resolver), not per-entry-point. |
The second bug is the more interesting lesson and a recurring theme in GraphQL security: a graph has many paths to the same node. Bolting access control onto the “front door” resolver is meaningless if a nested relationship somewhere else returns the same object without the check. Authorization belongs to the object, not to the route you took to reach it.
Tools used
curl— manual GraphQL requests- GraphQL introspection — schema mapping
john(HMAC-SHA256 mode) +rockyou.txt— cracking the JWT secret- A few lines of Python — forging the signed token
Takeaways
- Always run introspection first on a GraphQL target — it hands you the entire attack surface, including suspiciously named fields like
vaultManifest. - Never trust a JWT secret you didn’t generate randomly. Project-themed secrets are cracked in milliseconds.
- Map every path to your objective. When a field is locked on one resolver, ask whether the same object is reachable another way. In GraphQL the answer is frequently yes.