# Parameter mining: brute-forcing 65,000 inputs in 40 requests

> You can't send 65,000 candidate parameter names one per request — the target would rate-limit you into next week. Binary-search bucketing packs 128 names into one request and splits only the buckets that move the response. Here's the mechanism, the who-wins HPP table, and the mass-assignment payloads that turn a hidden field into admin.

**Key takeaways:**
- The naive approach — one candidate name per request — makes mining infeasible: 65,000 names is 65,000 requests and a rate-limit ban. Binary-search bucketing sends ~128 names in one request and only splits the buckets that change the response, testing tens of thousands of names in dozens of requests.
- Most live parameters never reflect their value. You detect them by diffing response length, word count, status, and timing against a stable baseline — and you cache-bust every request or a shared cache fakes and masks hits.
- HTTP Parameter Pollution is a per-stack coin flip: send role=user&role=admin and PHP takes the last, JSP the first, ASP.NET concatenates, Express makes an array. The validator checks one occurrence, the app reads another — that gap is an auth bypass.
- The payoff shapes are mass-assignment (is_admin, nested {"user":{"role":"admin"}}), prototype pollution via __proto__ params on merge-happy Node apps, and framework magic params like _method=DELETE.

**Series:** Recon Notes  
**Published:** July 5, 2026 · 8 min read  
**Canonical:** https://crusaderproxy.com/research/en/posts/hidden-parameter-mining/

---


Every endpoint has two input surfaces: the parameters the client sends, and the parameters the server will bind *if you happen to name them*. The second surface never appears in your proxy history, never appears in the OpenAPI spec, and never gets a test written against it — which is exactly why the privilege flags and debug toggles live there. A framework binds request fields onto a model. The web and mobile clients populate six of them. The other four — `is_admin`, `debug`, `internal_notes`, `account_id` — are wired up, honored, and completely untested because nobody sends inputs they don't know exist.

The problem is that there are a *lot* of names to try, and you can't try them the obvious way.

> **Mining isn't guessing names one at a time. It's a search: pack a wordlist into buckets, send a bucket per request, and let the response tell you which bucket to split.**

[Passive recon]({{< relref "posts/passive-recon-from-proxy-history.md" >}}) gives you the parameters the app already uses. Mining finds the ones it never advertises — and does it without getting you banned.

## The math that kills the naive approach

A serious parameter wordlist is 65,000 names. One name per request is 65,000 requests against a single endpoint. The target rate-limits you, the WAF flags you, and you've spent an afternoon to test one route. Mining "one at a time" is why most people never do it.

Binary-search bucketing is the fix, and it's the whole reason the technique is viable. Send **~128 candidate names in a single request** — all in the query string, all in the body, whatever the endpoint reads:

```
GET /api/account?a1b2=x&debug=x&role=x&is_admin=x&verbose=x&... (128 names) HTTP/2
```

Compare that response to a clean **baseline** (the same request with no candidate names, or with a single junk name). Three outcomes:

- **No difference** → none of the 128 names is live. Discard the entire bucket. One request just cleared 128 names.
- **Difference in length, reflection, or status** → at least one name in the bucket did something. **Split the bucket in half** and re-send each 64-name half.
- Recurse only on halves that keep differing.

Isolating one live parameter out of 65,000 costs about `log2(65000) ≈ 16` requests. Even with several live hits and buckets to split, a full 65,000-name run lands in **dozens of requests, not tens of thousands**. That's the difference between mining every endpoint on a target and mining none of them.

## Detecting a hit when nothing reflects

Reflection — your parameter's value echoed in the response body — is the tutorial signal, and it's maybe one hit in ten. The other nine change server behavior with no echo at all. What you're actually diffing against a **stable baseline** is:

```
# Baseline (send 3-4 times first to confirm it's stable):
GET /api/account?fields=name HTTP/2          → 200, 412 bytes, 47 words

# Candidate bucket trips a delta (no reflection, body grew):
GET /api/account?fields=name&debug=x&... HTTP/2  → 200, 1,840 bytes, 210 words   ← something bound
```

- **Length / word-count delta.** The parameter altered the response even though it never echoed your value. Word count is more robust than byte length — it survives a changing timestamp or nonce.
- **Status change.** `200 → 400/500` means the server *parsed* your input and choked on it. A parameter that causes an error is a parameter the server recognizes.
- **Timing.** A name that triggers a DB lookup, an external fetch, or a heavier code path adds latency. A consistent multi-hundred-millisecond delta on one bucket is a live parameter doing real work.
- **Behavior.** The action succeeds when it shouldn't, or returns a different object.

The non-negotiable: **add a cache-buster to every single request** (a random unused param like `&cb=8f3a1`). Without it, a shared cache serves one candidate's response to the next and you get phantom hits — and, worse, it *masks* real hits by returning a stale baseline for a request that actually changed state. Establish that your baseline is stable across 3-4 sends *before* you trust any delta.

## HTTP Parameter Pollution: the per-stack coin flip

Once you know an endpoint reads a parameter, send it *twice* with different values. Every stack resolves the duplicate differently, and the discrepancy is the bug:

```
POST /api/promote  role=user&role=admin
```

| Stack | `role=user&role=admin` resolves to |
| --- | --- |
| PHP / Apache | `admin` (**last** wins) |
| ASP.NET | `user,admin` (**concatenated**) |
| Express / Node.js | `["user","admin"]` (**array**) |
| JSP / Tomcat | `user` (**first** wins) |
| Python (Flask/Django) | `user` typically (**first**, or a list via `getlist`) |

The exploit is a **split-brain**: the validation layer reads one occurrence and the application logic reads another. A WAF or auth middleware inspects `role=user`, waves it through, and the PHP backend then reads `role=admin`. Or a Java validator sees the first `user` and the downstream service — a different language — reads the last. Whenever a request crosses a boundary between two components that disagree on duplicate handling, HPP is an authorization bypass hiding in plain sight.

## The payoff classes

**Mass assignment / over-posting.** The single highest-yield hit. Add fields to a JSON body the UI never sends and see if the binder honors them:

```json
{ "name": "me", "is_admin": true }
{ "name": "me", "role": "admin" }
{ "name": "me", "verified": true }
```

The nested variant catches ORMs that map nested objects — devastating when the binder walks relations:

```json
{ "user": { "role": "admin" } }
{ "account": { "id": 1337 } }
```

That last one — reassigning `account.id` to a victim's ID — is over-posting straight into [IDOR/BOLA]({{< relref "posts/finding-idor-bola-with-shadow-replay.md" >}}) territory. And when a naive check does `if (body.is_admin === true)`, beat it with **type juggling**:

```json
{ "is_admin": "true" }
{ "is_admin": [true] }
```

A string or a single-element array can be truthy where a strict `=== true` isn't, or can slip past a filter that only blocks the literal boolean.

**Prototype pollution via parameters.** On Node/Express apps that recursively merge query or body params into config objects (a shockingly common pattern), you don't need a JSON body — you pollute `Object.prototype` from the query string:

```
?__proto__[isAdmin]=true
?constructor[prototype][isAdmin]=true
```

Every object in the process now inherits `isAdmin: true`. That flips auth flags, and on the right gadget chain — a template engine, a child-process spawn that reads an inherited property — it reaches RCE.

**Framework magic parameters.** Names the framework treats specially, none of which the client ever sends:

- `_method=DELETE` / `_method=PUT` — Rails and Laravel method override. Turn a permitted POST into a DELETE the routing layer would otherwise reject.
- `param[]=value` — Rails/PHP array syntax that changes a value's *type*, breaking parsers and validators that assumed a string.
- `_csrf`, `_token` — Spring/Laravel token fields worth probing for how they're validated.
- `?debug=1`, `?test=1`, `?trace=1` — verbose modes that unlock stack traces, SQL, and internal object fields.

Redirect and fetch params — `url`, `next`, `redirect`, `callback` — are open-redirect and [SSRF]({{< relref "posts/ssrf-past-the-easy-filters.md" >}}) primitives the moment the server dereferences them.

## Where the names come from — and the trap that hides them

The wordlist matters, but the highest-yield sources are target-specific: the app's **own JavaScript** (parameter names the developers actually use somewhere in the codebase are the ones most likely wired up here too), **sibling endpoints** (if `/users` binds `account_id`, try it on `/orders`), and **source maps** you recovered during recon, which hand you the real backend field names.

Then the detection trap that eats real findings: **combination-only parameters**. Some fields do nothing on their own and only take effect when the endpoint's *real* parameters are also present — a `sort` field is inert until there's a `query`, a `role` override is ignored until a valid `user_id` is in the body. If a candidate looks dead in isolation, **re-send it with the endpoint's genuine parameters alongside it**. And **method matters**: a field the server ignores on `GET` is frequently honored on `POST`/`PATCH`, because binding only runs on the mutating verb. Mine the verb the endpoint actually writes on, not the one that's easy to fuzz.

## Remediation

For the report: bind an **allowlist**, never the whole request. Frameworks give you the tool — Rails `strong_parameters`, a `@JsonIgnore` / DTO boundary in Spring, an explicit `pick()` before the ORM sees the body. Reject duplicate parameters at the edge instead of letting each layer resolve them its own way, which closes HPP. Freeze `Object.prototype` and reject `__proto__`/`constructor` keys in any recursive merge. And gate every debug and verbose flag behind a server-side environment check, not a request parameter.

## The point

Parameter mining is the cheapest privilege-escalation primitive on the board, and it's underdone for one reason: done naively it's 65,000 requests and a ban, so people conclude it doesn't scale and skip it. It scales fine — it's a bucketed binary search over a good wordlist, and the whole run is a baseline and a diff. The `is_admin` the front-end never sends is the one still bound on the backend, and the fastest way to find it is to stop guessing names and start splitting buckets.

Crusader fires candidate parameters in buckets and diffs every response against a stable baseline — length, word count, status, and timing — then splits only the buckets that moved, cache-busting each request so no cache invents or hides a hit. To mine a real endpoint that way, [download Crusader free](https://crusaderproxy.com/#install).


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
