banner

Three weeks into a hunt on a well-known B2B SaaS program on HackerOne, I was watching network traffic in Chrome DevTools when a lazy-loaded chunk finally appeared: banned-page.f8a3c2d1.chunk.js.

I’d been grepping through the main webpack bundle for weeks. I had the UpdateBannedOrganization mutation in my notes. But I couldn’t find where or how the client actually called it, because my test organization wasn’t banned. 

deep dive in graphql for bug bounty

This post is about what depth actually looks like on a mature target. Not the clean “recon → exploit → report” arc. The real thing: dead ends, lazy-loading walls, and the patience most hunters don’t have.

Note: The target in this post is disclosed as redacted.com — a well-known program on HackerOne. All findings were reported through the proper disclosure process. No real organization data was accessed or modified.

Why I Chose This Target

I picked redacted.com over other properties on the same program for a specific reason: it’s a B2B SaaS platform. Corporate accounts, billing workflows, document verification, user roles, organization states. Complex business logic always means more state machines — and more state machines means more opportunities for inconsistent validation.

The first thing I did was read the entire program policy. Not skim it. Read it. Most hunters skip this. Mature programs like this one are explicit about what they want: they don’t want another “user can view their own email” report. They want impact.

The business model tells you where to look:

Feature Area Why It’s Interesting
Organization approval workflows Multi-state transitions with compliance implications
Document verification Validation checks that may differ between frontend and backend
User roles within orgs Permission boundaries across member, admin, billing contact
Banned/rejected org states Terminal states that should be strictly enforced

The first week looked exactly like everyone else’s week one: testing IDOR patterns on org UUIDs, probing organization switching, trying to escalate from member to admin. Everything was hardened. The obvious stuff had been found and fixed years ago.

That doubt you feel at that point — is this target too mature? — is actually useful. It forces you to decide: dig deeper, or move on. Opportunity cost is real in bug bounty hunting. I decided to dig.

Extracting the GraphQL Surface from Webpack Bundles

Modern SPAs don’t give you an API documentation page. The entire API surface is compiled into JavaScript bundles, split into chunks, and loaded on demand. If you want to know what mutations exist, you have to extract them yourself.

My process:

  1. Download every chunk — not just the main bundle. Check the Sources tab in DevTools and watch the network tab as you navigate through the app. I grabbed around 40 files.
  2. Format them all. Minified code is unreadable and unsearchable. I ran everything through Prettier:
find ./bundles -name "*.js" -exec prettier --write --single-quote {} ;

Then grep for GraphQL patterns:

grep -n "mutations|querys|fragments" *.js > graphql_operations.txt
grep -n "gql`|graphql(" *.js > graphql_usage.txt

Two views: where operations are defined, and where they’re used. The gap between those two lists is where things get interesting.

I found UpdateOrganization quickly — standard mutation for editing org details. Then I found UpdateBannedOrganization. A specific mutation for updating a banned org’s state means it’s a privileged operation. Who can call it? What does it accept? The schema definition was there, partially:

mutation UpdateBannedOrganization($params: UpdateBannedOrganizationRequest!) {
  updateBannedOrganization(params: $params) {
    success
    organization { id status }
  }
}

But TypeScript types get stripped at compile time. I had the mutation name and a partial schema. I needed to see it actually called.

The Finding That Opened Everything

A few weeks in, I caught a break on a different mutation. Organizations in MANUAL_REVIEW_STATUS_DOCUMENTS_REQUESTED state are required to upload specific documents before approval. The app shows a form with required fields.

I intercepted the submission in Burp Suite and modified the request to send requiredApprovalDocuments: [] instead of the actual document list. It went through. The org moved to the next approval state without uploading anything.

Document verification bypass. Confirmed, reported, accepted as a critical finding.

But more importantly, it shifted how I was thinking. If validation on document requirements was this loose, what about other state transitions? Could banned state be manipulated the same way?

When Static Analysis Hits a Wall

I started testing UpdateBannedOrganization with everything I could think of:

Params Sent Response Actual State Change
params: {} 200 OK, success: true None
organizationId only 200 OK, success: true None
status: "ACTIVE" 200 OK, success: true None
banned: false 200 OK, success: true None
banStatus: "CLEARED" 200 OK, success: true None

Every request returned success. Nothing changed. This is where most hunters move on — “tried it, doesn’t work.” But a success response is not the same as success. The backend can return 200 OK with a success: true flag and do absolutely nothing. The mutation exists for a reason. It gets called somewhere.

The problem was structural. The mutation was in the main bundle as a definition, but the actual call site — the component that builds the params object and fires the request — wasn’t there. That code was lazy-loaded.

Modern React apps code-split by route. When you navigate to /orgs/:id/banned, the app loads a separate chunk containing everything specific to that view. I couldn’t trigger that route because my test org wasn’t banned. And I couldn’t get it banned because I didn’t know the correct mutation params. A perfect loop.

Most write-ups don’t show this part. They show the clean path from recon to exploit. They skip the weeks of being stuck waiting for a chunk to load that you can’t trigger.

My solution: set up Burp to intercept all traffic passively, and wait. When I eventually needed to review a banned organization for other testing purposes, that chunk would load. When it did, I’d capture the actual mutation call with real parameters. No guessing required.

The Methodology That Actually Works

If you’re spending 3-4 weeks on a single target, “poke around and see what breaks” is not a strategy. Here’s what worked for me.

Phase 1: Understand the Business (Days 1-3)

Read everything public: help docs, admin guides, release notes, onboarding flows. You’re trying to understand the domain model well enough to predict where security boundaries should exist — so you notice when they don’t.

Map out: what can each user role do? What states can an organization be in? What transitions between states exist? What actions are irreversible?

Phase 2: API Surface Extraction (Week 1-2)

Download and format all webpack chunks. Build a reference document of every operation you find. Mine looked like this:

## Mutations
- UpdateOrganization      → Edit org details
- ApproveOrganization     → Admin-only, moves pending org to approved  
- UpdateBannedOrganization → ??? usage not found (lazy-loaded)
- RequestDocuments        → Triggers document upload requirement
 
## Queries
- GetOrganization(id)           → Full org object
- ListOrganizationMembers(orgId) → Users in org

You’ll miss the lazy-loaded stuff. That’s expected. This is your starting point, not your complete picture.

Phase 3: Happy Path First (Week 2-3)

Create accounts in every role. Execute every feature exactly as intended. Capture everything in Burp. Save baseline requests for every legitimate action.

When something behaves unexpectedly later, you need to know what the legitimate call looked like. What headers? What cookie state? What was the exact param structure?

Phase 4: State Transition Hunting (Week 3+)

Anything with an arrow in your domain model is a target. Test state transitions:

  • With the wrong role
  • With a different org’s UUID
  • With params missing
  • Backwards — can you move from approved back to pending?
  • Skipping steps — can you jump from pending directly to active?

Creation endpoints tend to have strict validation because they get more code review attention. Update endpoints sometimes get lazy: “We’re just modifying an existing record, what’s the harm?” That asymmetry is where bugs hide.

What Separates Productive Hunters

Four months on one program gives you opinions. Here’s what I actually believe now:

Common Belief What I Actually Think
Breadth beats depth On mature programs, depth beats breadth. On new programs, reverse is true.
Dead ends waste time Dead ends narrow the search space. They’re data.
Static analysis is enough Code shows capabilities. Traffic shows reality. The gap is where bugs are.
More automation = more bugs On complex B2B apps, clean manual observations beat noisy automated scanning.

The compounding effect of context is real. In week one, I was just learning the app. By week four, I could look at a mutation name and immediately reason about which backend service it probably touches and what that means for validation strictness. That intuition only comes from accumulated time on target.

Practical Things You Can Actually Use

Script your extraction pipeline early. Don’t grep manually every time:

#!/bin/bash
find ./bundles -name "*.js" | while read file; do
  grep -H "mutation|query|fragment" "$file" >> operations.txt
done

Map state machines explicitly, even just in a text file:

Org States:
PENDING → MANUAL_REVIEW → APPROVED → ACTIVE
                         → REJECTED
                         → BANNED
 
Test questions:
- Can you skip MANUAL_REVIEW entirely?
- Can you go from REJECTED back to APPROVED?
- Can you self-serve out of BANNED state?

Know what these signals mean when you see them:

Signal What It Means
Mutation exists but no call site in bundle Probably lazy-loaded — wait for the right app state
Feature visible in UI but no API calls captured Might be server-side rendered or WebSocket-driven
200 OK + success: true but no state change Wrong params or silent permission check — don’t quit yet
Enum value in bundle that doesn’t appear in any UI Hidden state — find out what triggers it

Common Questions

How do you know when to quit vs. dig deeper?

Two weeks is my personal cutoff. If I haven’t seen anything interesting in two weeks — not a bug, just something unexpected — I move on. “Interesting” means a weird API response, an inconsistent validation pattern, a feature more complex than it should be. If everything is perfectly hardened and boring, the signal-to-noise ratio isn’t worth it.

What if the target uses REST instead of GraphQL?

Same concept, different grep patterns. Look for fetch(, axios.post(, API base URL constants. Check if the app loads an OpenAPI spec or Swagger UI client-side. The extraction method changes, the methodology doesn’t.

Is depth always the right strategy?

No. On new or smaller programs, breadth wins — collect the obvious bugs and move on. Depth makes sense when the program is mature, pays well for high-severity findings, and has complex business logic worth investing time into. Match the strategy to the target.

The Real Point

Most hunters test the obvious surface and move on. On programs where everyone is doing the same thing, you need a different approach. Going deeper means longer hunts, more dead ends, and a lot more patience. It also means finding the bugs that actually pay — and more importantly, the bugs that actually matter.

The frontend always has more than it shows. The backend almost always has looser validation than you’d expect. The gap between those two facts is where real findings live.

If you haven’t read the previous post on mapping GraphQL operations beyond introspection, that’s a good starting point before applying any of this.

banner
Crypto & Web3 Researcher

Yassine Doecoin

Web3 security researcher and crypto enthusiast since 2012. I cover DeFi, NFTs, blockchain gaming, and the intersection of technology and finance. Built NullStack to share honest, research-backed insights for the crypto community.

Follow Me

Top Selling Multipurpose WP Theme

Newsletter

banner

Leave a Comment

Focus Mode