Press enter or click to view image in full size
Senior pentesters find these toxic combinations the same way every time. A four-phase methodology. Each phase asks one question. This post walks the methodology through a real discovery from an authorized assessment.
A handful of anonymous API calls. One legitimate login at the end, as the victim. Five thousand customer accounts, every one of them reachable. Every password overwritten in a few seconds. Every account logged in to with the credentials the attacker chose. No privilege escalation, no token forgery, no exploit primitive. Every request the chain made was authorized as the attacker, because most of the chain never asked the attacker who they were.
The scanner that ran against the same application before the engagement reported the underlying findings as separate items. None rated Critical. All sat unremarkably in the triage backlog.
This post walks through how a pentester gets from those findings to that outcome. The mechanism is a methodology. It does not appear on any scanner’s findings list, but every experienced practitioner uses some version of it, and the structure is consistent enough to teach.
The methodology has four phases. Each phase asks one specific question of the application. The answers from each phase become inputs to the next. When all four answers connect, you have a toxic combination. Three or four findings whose individual severities understate what they enable when chained. In the engagement walked through here, that combination is one Low, one Medium, and one High that compose into a Critical.
The four questions:
Each phase carries a severity rating taken in isolation. None of the individual ratings is Critical. The combination is.
What follows comes from an authorized assessment. The endpoint paths, request shapes, and overall chain are reproduced from the engagement. The data values (names, emails, profile IDs, hash strings) are synthesized so nothing in this post identifies the assessed application or any real customer. The pattern reported here was filed, escalated, and remediated before the application’s planned sunset.
The engagement began the way most do. Pre-test triage produced the usual mix. A few missing security headers, a reflective XSS hint that turned out to be a false positive, a “verbose error message” entry against the login endpoint marked Medium, and a generic “missing headers on JavaScript file” against app.js.
If I had treated that as a finished list, the engagement would have been a four-paragraph memo. Three findings to acknowledge, one to push back on, sign off, move on. The chain that ended in mass account takeover would have stayed undiscovered.
I opened the JavaScript file anyway. Not because I expected anything interesting. Reading the bundle is a habit I picked up from senior reviewers years ago. Automated tooling can tell me a file is missing a header. It cannot tell me what the file does.
Every step of the methodology starts from the lowest-privilege legitimate position the application allows. Not because elevated access is hard to get, but because chains that start from admin tell us nothing about the threat model that matters most. The user the application already trusts. The chain that follows holds against an unauthenticated visitor for the first three phases. The position rises to a legitimate customer login only at the very end, when the chain reaches its outcome.
What does the application teach me, that I shouldn’t have learned, just by being here?
I never logged in for this phase. Visiting the login page through Burp’s proxy was enough.
The login page’s HTML loads four obvious script files (main.js, apim-auth.js, env.js, firebase.js) and one less obvious one. A <link rel=”preload” as=”script” href=”/js/app.js”> declaration in the page head causes the browser to issue a GET for /js/app.js during initial page load. This is the kind of tag a webpack build emits when it wants the browser to prefetch a route chunk for the next navigation. Burp captured all five files in the same proxy history sequence, before I had typed a single character into the login form. The fifth file, app.js, is the post-login dashboard’s bundle. Its presence in the proxy history meant the application had already shipped its post-login API catalog to me, an unauthenticated visitor.
Opening app.js in Burp’s response viewer, the first dozen lines told me everything I needed to know about the API surface I was about to test.
// Internal API endpoint catalog (used by build tools, do not remove)
// POST /api/login body { email, password }
// GET /api/profile?id=N profile by numeric id (admin only)
// GET /api/profile/password?id=N legacy reset-lookup (returns salt+hash)
// PUT /api/profile/password body { id?, currentPassword?, newPassword }
// POST /api/account/info body { userProfileID: N }
//
// TODO(qa): remove [email protected] seed account before release.
// Leftover from QA cycle. Admin role, used for password-reset regression tests.Three things jump out of the catalog in under five seconds.
1. /api/profile?id=N is annotated “admin only”, but it is rendered by the customer dashboard. Either the comment is wrong, the gate is wrong, or both. Worth probing.
2. /api/profile/password GET returns salt+hash. That phrase belongs in a database row, not an HTTP response body. Worth probing harder.
3. PUT /api/profile/password accepts an id in addition to currentPassword. Optional id. Two execution branches in one endpoint. The branch that takes id cannot also be requiring the current password, otherwise the optional structure makes no sense.
The TODO comment names a specific email, [email protected]. The developer who wrote the comment intended to remove the seed account before release. They clearly did not. The seed account also appears to have an admin role.
I rated this finding Low in isolation. Information disclosure to unauthenticated visitors is a notch worse than disclosure to authenticated users, but the file does not contain credentials, tokens, or directly exploitable data on its own. A reviewer skimming the bundle would tag it “Low, verify, accept the risk if no PII is exposed.” That triage is technically correct. It also prematurely closes the most interesting input the chain ever produces.
The methodology question for Phase 1 is not “what is the severity of the JS bundle exposure?” The question is what does the application teach me that I shouldn’t have learned just by being here? The answer is an inventory of inputs the developers thought the requester would not see.
That inventory is the input for Phase 2.
Which inputs identify objects, and which of those are not bound to my session?
Phase 2 lives inside the login endpoint, which the application has to expose to unauthenticated visitors by definition. I stayed unauthenticated.
Before testing the other endpoints from the catalog directly, I tried something the JS bundle made specifically possible. I attempted to log in using [email protected], the email left behind in the TODO comment, with a deliberately wrong password. I expected the standard “Invalid credentials” response. What I got was different.
POST /api/login HTTP/1.1
Content-Type: application/json {"email":"[email protected]","password":"wrongpass"}
Response:
{
"IsSuccess": false,
"error": "Email exists but password is incorrect",
"email": "[email protected]",
"userName": "QA Test Account",
"userProfileID": 9999,
"role": "admin"
}The IsSuccess: false field told me authentication failed. Everything below it told me the account exists, that it is named “QA Test Account”, that its profile ID is 9999, and that its role is admin. None of that should be in a failed-login response.
Press enter or click to view image in full size
I tested the same path against an email I knew was not in the system ([email protected]).
{ "IsSuccess": false, "error": "Invalid credentials" }Generic 401, no metadata. The verbose response only fires when the email exists. That is not a verbose error message. That is a user-enumeration oracle. Type any email at the login endpoint and the response shape tells you whether the email is registered. If it is, it also tells you the user’s name, profile ID, and role.
I confirmed the same shape against a known regular customer.
{
"IsSuccess": false,
"error": "Email exists but password is incorrect",
"email": "[email protected]",
"userName": "Sarah Thompson",
"userProfileID": 2,
"role": "customer"
}The failed-login path leaks email, name, profile ID, and role for any registered email, to an unauthenticated visitor, with no rate limit applied. I rated this Medium in isolation. Verbose-error responses that disclose user metadata land squarely in standard Medium territory, and that rating is the right one to file. What elevates this finding inside the chain is the role field. That field tells the attacker which profile IDs are admin accounts, which becomes the prioritization for Phase 3. The Medium rating is correct. The chain is what makes it dangerous.
The output of Phase 2 is the data the gap leaks. Numeric profile IDs, and the knowledge that profile ID 9999 holds an admin role. That data is the input for Phase 3.
Does the same identifier I just exfiltrated work on a write endpoint? And was authentication required at all?
The catalog from Phase 1 listed a curious endpoint.
GET /api/profile/password?id=N legacy reset-lookup (returns salt+hash)Why would a password reset endpoint return the salt and hash? In a normal architecture, the reset flow generates a token, emails it, and accepts a new password. The hash never leaves the database. A “legacy reset-lookup” endpoint that returns hash and salt is the kind of thing that exists because some backend developer wrote a JSON wrapper around a legacy SOAP method without thinking about what they were exposing.
Out of habit, my first probe carried an Authorization header, a Bearer token I had picked up from a separate test session. The endpoint returned 200 with the salt and hash. I made the same call again with the Authorization header removed entirely. Same 200. Same salt and hash.
The endpoint had not checked the token. The endpoint was not checking authentication at all.
GET /api/profile/password?id=2 HTTP/1.1
Accept: application/jsonResponse:
{
"IsSuccess": true,
"userProfileID": 2,
"userName": "Sarah Thompson",
"email": "[email protected]",
"role": "customer",
"passwordHash": "A917B980DA07B1051F071DCBD0CDA0BAED53567AEEE5E92B2E4765631ED4FEEF",
"salt": "4329e437404ef2f8"
}Press enter or click to view image in full size
No Authorization header. No session cookie. No API key. The endpoint never asked. Hash, salt, email, username, role, all returned to an anonymous caller, for any profile ID that exists.
This is two authorization failures stacked on the same endpoint. The first is missing authentication entirely. The endpoint accepts requests from anyone on the public internet without checking who is calling. The second is missing object-level authorization. Even if the endpoint did check the caller, it would have no notion of which profile IDs that caller is allowed to read, because the code doesn’t bind the id parameter to any session at all. A scanner would catch neither pattern by itself. It would see a 200 response and move on.
A senior pentester recognizes the broken-object-level-authorization shape on a credential-bearing endpoint and rates this High. Practical impact is offline credential cracking against any user the attacker can name, with no rate limit and no authentication barrier.
The methodology pushes one more step before rating. The output of an enumeration step is rarely the data of one record. It is the proof that the enumeration itself works, which means the next step is to scale. I added a second enumeration target, the account/info endpoint that the catalog also listed.
POST /api/account/info HTTP/1.1
Content-Type: application/json {"userProfileID":3}
Response:
{
"IsSuccess": true,
"userProfileID": 3,
"userName": "Robert Chen",
"email": "[email protected]",
"policyNumber": "POL-10004"
}Same pattern. No authentication required. No ownership check. The endpoint accepts any profile ID and returns email, username, and policy number. Looped over an incrementing range of profile IDs, this becomes a full customer enumeration in a few seconds. The customer database held close to 5000 records. The loop returned every one of them.
Join Medium for free to get updates from this writer.
At this point I had, anonymously:
Three independent findings, each of which a triage process would handle separately. The methodology does not let me stop and report yet. Phase 3’s question is not just “did the read work?” It is does the same identifier I just exfiltrated work on a write endpoint?
The catalog listed the answer.
PUT /api/profile/password body { id?, currentPassword?, newPassword }Optional id. Optional currentPassword. The shape itself is the bug. There is no execution path where the request both takes an id and requires the current password, because the optional structure does not allow it. The attacker passes the ID and skips the password check entirely.
PUT /api/profile/password HTTP/1.1
Content-Type: application/json {"id": 2, "newPassword": "pwned!2026"}
Response:
{ "message": "Password updated", "userId": "CUST-002" }Press enter or click to view image in full size
No Authorization. No current-password challenge. Sarah Thompson’s password rewritten by an anonymous request, using only the profile ID enumerated from Phase 2.
I rated this High in isolation. Broken Object Level Authorization on the write side, sitting on top of broken authentication on the same endpoint. The read side already established that profile IDs are enumerable. The write side converts that enumeration into account-level state changes. This is the moment the chain becomes practical.
Can the chain produce a state change the application’s normal threat model would not detect?
The previous phases each produced a finding. This phase produces an outcome.
Phase 4 is the only authenticated request in the chain, and the attacker authenticates as the victim.
POST /api/login HTTP/1.1
Content-Type: application/json {"email":"[email protected]","password":"pwned!2026"}
Response:
{
"IsSuccess": true,
"token": "eyJhbGci…Sarah's customer token…",
"role": "customer",
"name": "Sarah Thompson",
"customerId": "CUST-002"
}I am Sarah Thompson now, as far as the application is concerned. The login endpoint cannot tell me apart from her, because the credential store says I am her.
Looped across the enumerated profile IDs, this is mass account takeover. Every one of the nearly 5000 customer records was reachable from the chain. There is no impersonation, no token forgery, no privilege escalation. The application’s authentication did its job at the login endpoint, but the application’s authentication never ran at the other endpoints, because those endpoints did not require it. Authorization decisions on the object level (should this requester be allowed to act on this specific record) never happened either.
I stopped after demonstrating impact on a single account. Exploiting the rest of the enumerable population would have served no purpose for the report.
That is the asymmetry the methodology exposes. Mass takeover by a “legitimate” login does not look like an attack to most monitoring. It looks like a customer changing their password and logging in with the new password, repeatedly. Detection systems built around “unauthorized access” do not fire, because every request to the chain’s anonymous endpoints returned 200, and every login at the end returned a valid token. Nothing in the audit log says “attack.”
Read backwards from the chain that worked, the methodology has four phases.
1. Information Gathering. What does the application teach me, that I shouldn’t have learned, just by being here?
2. Vulnerability Analysis. Which inputs identify objects, and which of those are not bound to my session?
3. Attack Execution. Does the same identifier I just exfiltrated work on a write endpoint? And was authentication required at all?
4. Exploitation. Can the chain produce a state change the application’s normal threat model would not detect?
Each phase carries a severity rating taken in isolation. None is Critical. The combination is.
When the four phases are complete, the chain documents into a single table that forces clarity about three things every chain has but reports often muddle. What the attacker learned at this step, what they could do with it, and what the next step needed as input.
Toxic Vulnerability Combinations: A Pentester’s Methodology in Four Phases
Press enter or click to view image in full size
Three things the table makes explicit that a typical pentest report does not.
Press enter or click to view image in full size
The “Available info” row tracks what the attacker carries forward. Each column’s available info is the input needed for the next column. Phase 2 needed the API catalog and the admin hint from Phase 1. Phase 3 needed the profile IDs from Phase 2. Phase 4 needed the rewritten passwords from Phase 3. If the table has a column where “Available info” cannot be reused as input to the next column, the chain breaks. If it can, the chain holds.
The “Auth required” row records what the application demanded at each step. Three of the four phases, including the one that exfiltrated credential material and the one that rewrote passwords, demanded nothing. That single row is the structural failure the entire chain rests on.
The “Methodology question” row is the teaching artifact. When a junior pentester reviews the table, the questions are reusable. The next time they hunt a chain, they ask the same four questions of a different application. The answers will be different. The methodology will be the same.
The chain in this post worked because three different layers of defense were missing. Authentication, object-level authorization, and information-disclosure controls. Closing the chain means closing all three. Here is the layered fix, ordered by structural importance.
These are the load-bearing fixes. Everything else is detection on top of a broken foundation.
These close the channels Phase 1 and Phase 2 used to seed the chain.
Detection that catches the chain even when prevention fails.
Automated tooling is good at finding individual classes of vulnerability and bad at finding sequences. A finding has a severity, a remediation, and a report entry. A chain has none of those. The taxonomy our tooling was built around was designed for bugs, not for combinations of bugs. That is why three findings rated Low, Medium, and High can sit unremarkably in a triage backlog while the chain they compose is Critical. The tooling is necessary. It is not sufficient. The methodology is what closes the gap.
What that means for the people doing the work:
The chain is a category of vulnerability that does not exist on a scanner’s findings list and never will. Until our tools understand sequences, the work belongs to the practitioners who already do. The methodology in this post is one way to write that work down. Four questions a junior pentester can apply to a different application tomorrow morning, with the same instinct it used to take a senior reviewer ten years to build.
Hemanth Gorijala is an application security practitioner. He builds open-source security tooling, conducts web application assessments, and reviews vulnerability reports in enterprise bug bounty programs. His open-source tooling for runtime credential detection, SecretSifter, is at github.com/secretsifter.