A Pentester’s Methodology for Toxic Vulnerability Combinations
How a Low, a Medium, and a High Compose Into a CriticalPress enter or click to view image in full si 2026-5-19 09:0:37 Author: infosecwriteups.com(查看原文) 阅读量:22 收藏

How a Low, a Medium, and a High Compose Into a Critical

Hemanth Gorijala

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.

The Outcome First

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:

  • [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 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 Starting Position

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.

Phase 1: Information Gathering

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.

Phase 2: Vulnerability Analysis

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

Verbose login response leaks PII when the email exists

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.

Phase 3: Attack Execution

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/json
Response:
{
"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

Hash and salt returned by the legacy reset-lookup endpoint for any profile id

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.

Get Hemanth Gorijala’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

At this point I had, anonymously:

  • A user-enumeration primitive (the verbose login response)
  • A salt+hash leak for any profile ID (the GET reset-lookup)
  • An email, name, and policy disclosure for any profile ID (the account/info endpoint)
  • The knowledge that profile ID 9999 is a leftover admin account
  • The original IDOR observation from the catalog comment (“admin only” gate that wasn’t enforced)

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

BOLA write: password overwritten with no current-password check

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.

Phase 4: Exploitation

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.”

The Methodology, Extracted

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.

Remediation

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.

1. Authentication and Authorization

These are the load-bearing fixes. Everything else is detection on top of a broken foundation.

  • Require authentication on every endpoint that returns account-scoped data. GET /api/profile, GET /api/profile/password, POST /api/account/info, and PUT /api/profile/password all need a valid session token. Endpoints accept anonymous calls because the developer assumed they would only be called from “trusted internal contexts.” There is no trusted internal context for an HTTP endpoint published on the public internet.
  • Implement Role-Based Access Control (RBAC) at the route layer. Admin endpoints check role === ‘admin’ in middleware before the handler runs. Comments like // admin only in source code are not access control. The check has to be in code that executes on every request.
  • Implement object-level authorization (BOLA prevention). Every endpoint that takes an identifier in input verifies the requester owns the resource. The pattern is if (resource.ownerId !== session.userId) return 403. Apply this at the database query layer when possible. SELECT * FROM profiles WHERE id = ? AND owner_id = ? removes the possibility of forgetting the ownership check in handler code.
  • Require the current password on self-service password changes. Not optional. The optional id or currentPassword shape on PUT /api/profile/password was the write-side bug. Endpoint signatures should have exactly one execution path. If a function needs two paths, split it into two functions.
  • Do not return credential material in API responses. The legacy reset-lookup endpoint that returned hash and salt should not exist. Password reset flows are token-based and never expose hashes to any client. If a legacy SOAP or JSON wrapper produced this response shape, the fix is to delete the wrapper or rewrite it to return only what the reset flow actually needs.

2. Information Disclosure

These close the channels Phase 1 and Phase 2 used to seed the chain.

  • Generic error responses on the login endpoint. The verbose IsSuccess: false shape that disclosed email, name, profile ID, and role for any registered email is a user-enumeration oracle. Login failures should return { IsSuccess: false, error: ‘Invalid credentials’ } for every failure case (wrong password, nonexistent email, locked account, expired account) with consistent timing.
  • Strip developer artifacts from production bundles. The API catalog and TODO comment that powered Phase 1 of this chain should not have been in app.js. Webpack’s TerserPlugin removes comments when comments: false is set in production builds. Inline JSDoc annotations on internal routes belong in source files, not built artifacts.
  • Audit preload chains. The <link rel=”preload” as=”script” href=”/js/app.js”> tag shipped the post-login dashboard’s bundle to every visitor of the login page. Route-based code splitting can prevent this. app.js should be lazy-loaded after authentication, not preloaded eagerly.
  • Remove seed and test accounts before production deployment. The QA seed account [email protected] should not have shipped to production. CI/CD pipelines should fail builds if seed-only email patterns appear in the database migration set targeted at a production environment.

3. Operational Layer

Detection that catches the chain even when prevention fails.

  • Rate limit the login endpoint by IP and by email address. Enumeration probing requires many requests against /api/login in a short window. Rate limits slow this enough that it becomes detectable before the attacker completes the enumeration.
  • Monitor for the chain’s operational signature. High-volume password resets followed by successful logins from the same IP, across multiple accounts, is the fingerprint of this attack. Detection should fire on the rate and the across-account pattern, not on any single request.
  • Anomaly detection on BOLA writes. Legitimate users change their own passwords occasionally. A single client changing many accounts’ passwords in a short window is anomalous and should fire. And should fire before the chain reaches Phase 4.

Takeaways

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:

  • Pentesters and bug bounty researchers. Apply the four questions in sequence. When you find an IDOR, do not stop at the IDOR. Phase 2’s output is the input to Phase 3. Look at the next write endpoint that accepts the same identifier type. And test every endpoint without an Authorization header at least once. Missing-auth on the read side is one of the most common ways a chain that should have required a token becomes anonymously exploitable.
  • Code reviewers. Ask two questions of every endpoint that takes an identifier in input. “Does this endpoint require authentication?” and “What happens if a user passes someone else’s id here?” Apply both to every endpoint, not just the ones that “look” sensitive. That pair of questions, applied consistently, turns chained authorization bugs into single-endpoint authorization bugs that tooling can find before the chain forms.
  • Triage teams. Read the chain as a unit, not the findings as separate items. A verbose error, an unauthenticated IDOR on a read endpoint, and a missing current-password check on a write endpoint are not three unrelated tickets. They are three halves of one chain that need to be closed in the same release.
  • Developers. Every parameter that names an object the requester might not own is an authorization decision waiting to happen. Every endpoint without an authentication middleware is an authorization decision the application has already refused to make. If your endpoint signature has an optional id parameter alongside an optional currentPassword parameter, you have written two execution paths into one function and one of them is going to skip a check it shouldn’t skip.

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.

Author

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.


文章来源: https://infosecwriteups.com/a-pentesters-methodology-for-toxic-vulnerability-combinations-993cd63ba2cf?source=rss----7b722bfd1b8d--bug_bounty
如有侵权请联系:admin#unsafe.sh